diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml new file mode 100644 index 0000000..f845888 --- /dev/null +++ b/.github/workflows/ci-main.yml @@ -0,0 +1,74 @@ +name: Main CI (full) +on: + push: + branches: [ main ] + +jobs: + test-and-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Unit tests + env: + PYTHONDONTWRITEBYTECODE: 1 + run: | + python -m pytest --cov=serving_app --cov-report=term-missing + + - name: Train model + run: python -m training.train + + - name: Boot API + env: + API_KEY: test-key + run: | + set -euo pipefail + uvicorn serving_app.main:app --host 127.0.0.1 --port 8011 >/tmp/uvicorn.log 2>&1 & + echo $! > /tmp/uvicorn.pid + for i in {1..40}; do + if curl -sf http://127.0.0.1:8011/health >/dev/null; then + echo "API is up" + exit 0 + fi + sleep 0.5 + done + echo "API failed to start"; cat /tmp/uvicorn.log || true; exit 1 + + - name: Predict smoke (trained) + env: + API_KEY: test-key + run: | + set -euo pipefail + RESP=$(curl -s -X POST "http://127.0.0.1:8011/predict" \ + -H 'Content-Type: application/json' \ + -H "x-api-key: ${API_KEY}" \ + -d '{"features":[5.1,3.5,1.4,0.2], "return_proba":true}') + echo "$RESP" | jq . + echo "$RESP" | jq -e 'has("prediction") and has("proba") and has("latency_ms")' >/dev/null + + - name: Shutdown + if: always() + run: | + [ -f /tmp/uvicorn.pid ] && kill $(cat /tmp/uvicorn.pid) || true + sleep 1 + pkill -f "uvicorn" || true + + - name: Upload logs (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: uvicorn-logs + path: /tmp/uvicorn.log diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 0000000..83458ff --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,76 @@ +name: PR CI (fast) + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Unit tests + env: + PYTHONDONTWRITEBYTECODE: 1 + PYTHONPATH: ${{ github.workspace }} + API_KEY: test-key + run: | + python -m pytest --cov=serving_app --cov-report=term-missing + + smoke: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install runtime deps only + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Boot API (no training for speed) + env: + API_KEY: test-key + run: | + set -euo pipefail + uvicorn serving_app.main:app --host 127.0.0.1 --port 8011 >/tmp/uvicorn.log 2>&1 & + echo $! > /tmp/uvicorn.pid + for i in {1..40}; do + if curl -sf http://127.0.0.1:8011/health >/dev/null; then + echo "API is up" + exit 0 + fi + sleep 0.5 + done + echo "API failed to start"; cat /tmp/uvicorn.log || true; exit 1 + + - name: Shutdown + if: always() + run: | + [ -f /tmp/uvicorn.pid ] && kill "$(cat /tmp/uvicorn.pid)" || true + sleep 1 + pkill -f "uvicorn" || true + + - name: Upload logs (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: uvicorn-logs + path: /tmp/uvicorn.log + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 17ac317..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: CI -on: [push, pull_request] -jobs: - smoke: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: 'requirements.txt' - - run: pip install -r requirements.txt - - name: Train model - run: python -m training.train - - name: Boot API - env: { API_KEY: "" } - run: | - uvicorn serving_app.main:app --host 127.0.0.1 --port 8011 & - echo $! > uvicorn.pid - for i in {1..40}; do - curl -sf http://127.0.0.1:8011/health && break - sleep 0.5 - done - - name: Predict smoke - run: | - curl -s -X POST http://127.0.0.1:8011/predict \ - -H 'Content-Type: application/json' \ - -d '{"features":[5.1,3.5,1.4,0.2], "return_proba":true}' | python -m json.tool - - name: Shutdown - if: always() - run: kill $(cat uvicorn.pid) || true diff --git a/.gitignore b/.gitignore index 4f7011c..cb66d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ venv/ # Models/artifacts (adjust to your project) models/ artifacts/ +.coverage diff --git a/README.md b/README.md index dd26753..3bacd89 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ -# Serving App (FastAPI + scikit-learn) +# Serving App +🚀 A production-style ML API built with **FastAPI** + **scikit-learn** + +[![CI](https://github.com/KyleSDeveloper/serving_app/actions/workflows/ci-pr.yml/badge.svg)](https://github.com/KyleSDeveloper/serving_app/actions/workflows/ci-pr.yml) ![Python 3.11](https://img.shields.io/badge/Python-3.11-blue) ![FastAPI](https://img.shields.io/badge/FastAPI-ready-teal) ![Docker](https://img.shields.io/badge/Docker-ready-informational) + A tiny, production-style ML serving skeleton. Trains a scikit-learn classifier (Iris demo) and serves predictions via FastAPI. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..785a62e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +pytest +pytest-cov +requests +ruff +mypy +httpx diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..363ae40 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +# tests/conftest.py +import os +import inspect +import contextlib +import numpy as np +import pytest +from fastapi.testclient import TestClient +from serving_app import main as m # where app/_model/_n_features live + + +def _noop(): + """No-op dependency override.""" + return None + + +class _DummyModel: + def predict(self, X): + X = np.asarray(X) + # return numpy array so .astype works + return np.zeros(len(X), dtype=int) + + def predict_proba(self, X): + X = np.asarray(X) + # shape (n_samples, 2) + return np.tile([0.4, 0.6], (len(X), 1)) + + +@pytest.fixture(scope="session") +def client(): + # --- Save originals so we can restore later --- + orig_overrides = dict(getattr(m.app, "dependency_overrides", {})) + orig_model = getattr(m, "_model", None) + orig_n_features = getattr(m, "_n_features", None) + + # 1) Env for any key checks + os.environ.setdefault("API_KEY", "test-key") + os.environ.setdefault("X_API_KEY", "test-key") + + # 2) Disable ALL route dependencies (auth/key checks etc.) + for route in m.app.routes: + if getattr(route, "dependencies", None): + for dep in route.dependencies: + if callable(getattr(dep, "dependency", None)): + m.app.dependency_overrides[dep.dependency] = _noop + + # Also best-effort override any module callables that look like auth/key checks + for name, obj in inspect.getmembers(m): + if callable(obj) and any(t in name.lower() for t in ("key", "auth", "token", "apikey")): + m.app.dependency_overrides[obj] = _noop + + # 3) Start app, then stub the model so startup can’t overwrite it + with TestClient(m.app) as c: + c.headers.update({ + "x-api-key": "test-key", + "X-API-Key": "test-key", + "Authorization": "Bearer test-key", + "api-key": "test-key", + }) + m._model = _DummyModel() + m._n_features = 4 + yield c + + # --- Restore originals --- + with contextlib.suppress(Exception): + m.app.dependency_overrides.clear() + m.app.dependency_overrides.update(orig_overrides) + m._model = orig_model + m._n_features = orig_n_features + + + + + + diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..8007e78 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,9 @@ +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + body = r.json() + assert body.get("ok") is True + assert isinstance(body.get("model_loaded"), bool) + assert isinstance(body.get("version"), str) and body["version"] + + diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_predict.py b/tests/test_predict.py new file mode 100644 index 0000000..66605d0 --- /dev/null +++ b/tests/test_predict.py @@ -0,0 +1,37 @@ +import pytest + +def test_predict_happy_path(client): + # requires 4 features + payload = {"features": [0.1, 0.2, 0.3, 0.4]} + r = client.post("/predict", json=payload) + assert r.status_code == 200 + out = r.json() + # Accept the app's actual schema + assert isinstance(out, dict) + assert "prediction" in out + assert "proba" in out # may be None if return_proba=False + assert "latency_ms" in out + +def test_predict_with_proba(client): + payload = {"features": [0.9, -0.1, 0.3, 0.0], "return_proba": True} + r = client.post("/predict", json=payload) + assert r.status_code == 200 + out = r.json() + assert "prediction" in out + assert "proba" in out and out["proba"] is not None + # if your proba is a list of class probs, sanity-check shape/type: + assert isinstance(out["proba"], (list, tuple)) + + +@pytest.mark.parametrize("bad", [ + {}, # missing features + {"features": None}, + {"features": "not-a-list"}, + {"features": []}, # empty not allowed (if you allow empty, drop this) + {"features": [None, 1, 2]}, + {"features": ["a", "b"]}, +]) +def test_predict_bad_payloads(client, bad): + r = client.post("/predict", json=bad) + assert r.status_code in (400, 422) + diff --git a/tests/test_predict_batch.py b/tests/test_predict_batch.py new file mode 100644 index 0000000..d58ba69 --- /dev/null +++ b/tests/test_predict_batch.py @@ -0,0 +1,32 @@ +import pytest + +def test_predict_batch_happy_path(client): + payload = {"items": [[0.0, 1.0, 0.0, 1.0], + [1.0, 0.0, 1.0, 0.0]]} + r = client.post("/predict_batch", json=payload) + assert r.status_code == 200 + data = r.json() + if isinstance(data, dict) and "predictions" in data: + assert isinstance(data["predictions"], list) and len(data["predictions"]) == 2 + else: + assert isinstance(data, list) and len(data) == 2 + +def test_predict_batch_with_proba(client): + payload = {"items": [[0.2, 0.3, 0.4, 0.5], + [0.8, 0.1, 0.2, 0.3]], "return_proba": True} + r = client.post("/predict_batch", json=payload) + assert r.status_code == 200 + out = r.json() + assert isinstance(out, (dict, list)) + +@pytest.mark.parametrize("bad", [ + {}, # missing items + {"items": None}, + {"items": "nope"}, + {"items": [[0.0, 1.0], ["a", "b"]]}, # mixed types +]) +def test_predict_batch_bad_payloads(client, bad): + r = client.post("/predict_batch", json=bad) + assert r.status_code in (400, 422) + + diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..1850c6f --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,5 @@ +def test_version(client): + r = client.get("/version") + assert r.status_code == 200 + data = r.json() + assert "version" in data and isinstance(data["version"], str)