Skip to content
Open
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
74 changes: 74 additions & 0 deletions .github/workflows/ci-main.yml
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions .github/workflows/ci-pr.yml
Original file line number Diff line number Diff line change
@@ -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

32 changes: 0 additions & 32 deletions .github/workflows/ci.yml

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ venv/
# Models/artifacts (adjust to your project)
models/
artifacts/
.coverage
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
6 changes: 6 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pytest
pytest-cov
requests
ruff
mypy
httpx
74 changes: 74 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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






9 changes: 9 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -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"]


Empty file added tests/test_metrics.py
Empty file.
37 changes: 37 additions & 0 deletions tests/test_predict.py
Original file line number Diff line number Diff line change
@@ -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)

32 changes: 32 additions & 0 deletions tests/test_predict_batch.py
Original file line number Diff line number Diff line change
@@ -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)


5 changes: 5 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -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)