From 9fc678740c4ba2b52bc31a01234f26e9a1eae952 Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 10:28:58 -0500 Subject: [PATCH 01/11] Green tests: stub model, auth overrides, align schemas; 92% cov --- .coverage | Bin 0 -> 53248 bytes requirements-dev.txt | 6 ++++++ tests/conftest.py | 29 +++++++++++++++++++++++++++++ tests/test_health.py | 9 +++++++++ tests/test_metrics.py | 0 tests/test_predict.py | 32 ++++++++++++++++++++++++++++++++ tests/test_predict_batch.py | 16 ++++++++++++++++ tests/test_version.py | 5 +++++ 8 files changed, 97 insertions(+) create mode 100644 .coverage create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_health.py create mode 100644 tests/test_metrics.py create mode 100644 tests/test_predict.py create mode 100644 tests/test_predict_batch.py create mode 100644 tests/test_version.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..c61f1b3454b9328396316c9fe323d6e114f2df27 GIT binary patch literal 53248 zcmeI)O>Y}T7zglOJGEoSW&=@VMNyTxKpHi1YJrM?0|dySqEcIkxFF$<*W+Yc@2<1E z&dY(Kv`CRE@eL5)gdeh89QljMtu8hRH#z(_DAg&jOxR;%GTFLDfgpc zeOta^t(1N$zcclF$t^sb`Z+HPHl4r*0SG_<0{?G;gIlG1Wp2*A|6L>-Z50L5R)Kyj zfA;12+Qz!rSo`$)y3ohOrJ|s1Wksxsz~2=e6^NG8R>JX`jx8g{+Z553N>g`3)#Qba zj?q%b39E5_-m5!J$`z?iDxwoOt_=3YUA4a)CrGXqsl7-aphA?h>2V5iDapPh0@YH1 z@@y6AC@wk8rQ*_q?~D1$%#3-c;yTH|rrXp|ZRkM1D%V<6knIOej=m8{&)!nuvXH$R z+CIg(7wOstzPlRvqV0HbHNDV@9N!aaPubl_HBYVJGNYv%;SJV?%DT-Jx~+32*Ba32 zXFerol03=WqToC~W$kC=OH<}JX0Piui2P31?Qr8z4t?RsAC+@d-pEfX`@(H=N?Z2p zJJg&hIk%|J)%Be1uIe@Zvgqerj;rDM%YrLGJ>c!BT;+oq)1Y3d_QLHpz22(JZsf<$ z>(u!58a~@F;4xh2M&Y z4CfZQai}*Bj+xaILk2w1gr6j&NMCDj<6tQU(P;aP;l394m{8FGp}8PU*6MmT50#MX zVEa2NkejNSoYm99>+VR@b!vrNWqn~(*XebEdabuxJebSnEA#W_{X*Q0lGaN8!w6Up-?$uviQmQ2S;a)zq2lFwDv=0{a0Nms8WJCg_biF{>t);!Q7 zg6Ac^Ul^(cAL$Q)V++BHhu@LWmN*$ZAEv+;FXSp8&5jD3L#WsIG56q`OdRszT&(ZX zq|f*4P{@3wAMzJ!e`QCuX*TIdC!irqbFf|k1Sf1tN-P_GH;Vf&KaIO5uRZ;Ebpie7 zsXe7yij&juzUhadL(3{JTj7W4QL(rsL!O1gMbYs6wvwJ+8z{%Mkgn;3B97tYs`PQc zjBuaJ*xXr+V@eknp5`}b(52&xc3f9_&1AjeWmuO=b3YGGzK)(mD5F@UK^f8zJyvE~ zXmXZkthD@UhfKIsS2p!<=SCSQad%6dC{C|NpUX%^j;jPGiGztd)~*bN9Vj|UYw6Nr z_IcfaTdQ8z{}fB*y_009U<00Izz00bcL z-xJ8188grC|1;KKhV_IVut5L<5P$##AOHafKmY;|fB*y_a4rQ(ne0U?{f5VtX)`-N zmwX4{{c7z>^}Qn1Dq}r1tjE?r=dywb8Uhf200bZa0SG_<0uX=z1Rwx`o0^!NWC8P=m-QtUtg0uX=z1Rwwb2tWV=5P$##ATXu^SIw*uu5S6RTHSA} zyQ?u36`n?4H{NJ>GGw6m50uX=z1Rwwb2tWV=5P$## zAOL}J7r2@Y3uk!$kKg|r_l8A$5P$##AOHafKmY;|fB*y_0D)H$$kJB^%KG#FXNL9c zmC8b92tWV=5P$##AOHafKmY;|fB*!>R3Muz7xd@ Date: Mon, 18 Aug 2025 10:29:44 -0500 Subject: [PATCH 02/11] gitignore coverage artifact --- .coverage | Bin 53248 -> 0 bytes .gitignore | 1 + 2 files changed, 1 insertion(+) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index c61f1b3454b9328396316c9fe323d6e114f2df27..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI)O>Y}T7zglOJGEoSW&=@VMNyTxKpHi1YJrM?0|dySqEcIkxFF$<*W+Yc@2<1E z&dY(Kv`CRE@eL5)gdeh89QljMtu8hRH#z(_DAg&jOxR;%GTFLDfgpc zeOta^t(1N$zcclF$t^sb`Z+HPHl4r*0SG_<0{?G;gIlG1Wp2*A|6L>-Z50L5R)Kyj zfA;12+Qz!rSo`$)y3ohOrJ|s1Wksxsz~2=e6^NG8R>JX`jx8g{+Z553N>g`3)#Qba zj?q%b39E5_-m5!J$`z?iDxwoOt_=3YUA4a)CrGXqsl7-aphA?h>2V5iDapPh0@YH1 z@@y6AC@wk8rQ*_q?~D1$%#3-c;yTH|rrXp|ZRkM1D%V<6knIOej=m8{&)!nuvXH$R z+CIg(7wOstzPlRvqV0HbHNDV@9N!aaPubl_HBYVJGNYv%;SJV?%DT-Jx~+32*Ba32 zXFerol03=WqToC~W$kC=OH<}JX0Piui2P31?Qr8z4t?RsAC+@d-pEfX`@(H=N?Z2p zJJg&hIk%|J)%Be1uIe@Zvgqerj;rDM%YrLGJ>c!BT;+oq)1Y3d_QLHpz22(JZsf<$ z>(u!58a~@F;4xh2M&Y z4CfZQai}*Bj+xaILk2w1gr6j&NMCDj<6tQU(P;aP;l394m{8FGp}8PU*6MmT50#MX zVEa2NkejNSoYm99>+VR@b!vrNWqn~(*XebEdabuxJebSnEA#W_{X*Q0lGaN8!w6Up-?$uviQmQ2S;a)zq2lFwDv=0{a0Nms8WJCg_biF{>t);!Q7 zg6Ac^Ul^(cAL$Q)V++BHhu@LWmN*$ZAEv+;FXSp8&5jD3L#WsIG56q`OdRszT&(ZX zq|f*4P{@3wAMzJ!e`QCuX*TIdC!irqbFf|k1Sf1tN-P_GH;Vf&KaIO5uRZ;Ebpie7 zsXe7yij&juzUhadL(3{JTj7W4QL(rsL!O1gMbYs6wvwJ+8z{%Mkgn;3B97tYs`PQc zjBuaJ*xXr+V@eknp5`}b(52&xc3f9_&1AjeWmuO=b3YGGzK)(mD5F@UK^f8zJyvE~ zXmXZkthD@UhfKIsS2p!<=SCSQad%6dC{C|NpUX%^j;jPGiGztd)~*bN9Vj|UYw6Nr z_IcfaTdQ8z{}fB*y_009U<00Izz00bcL z-xJ8188grC|1;KKhV_IVut5L<5P$##AOHafKmY;|fB*y_a4rQ(ne0U?{f5VtX)`-N zmwX4{{c7z>^}Qn1Dq}r1tjE?r=dywb8Uhf200bZa0SG_<0uX=z1Rwx`o0^!NWC8P=m-QtUtg0uX=z1Rwwb2tWV=5P$##ATXu^SIw*uu5S6RTHSA} zyQ?u36`n?4H{NJ>GGw6m50uX=z1Rwwb2tWV=5P$## zAOL}J7r2@Y3uk!$kKg|r_l8A$5P$##AOHafKmY;|fB*y_0D)H$$kJB^%KG#FXNL9c zmC8b92tWV=5P$##AOHafKmY;|fB*!>R3Muz7xd@ Date: Mon, 18 Aug 2025 10:42:05 -0500 Subject: [PATCH 03/11] Add GitHub Actions CI for tests --- .github/workflows/ci.yml | 58 +++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17ac317..ebdadb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,32 +1,70 @@ name: CI on: [push, pull_request] + jobs: - smoke: + 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' - - run: pip install -r requirements.txt + 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: "" } + env: + API_KEY: test-key run: | - uvicorn serving_app.main:app --host 127.0.0.1 --port 8011 & - echo $! > uvicorn.pid + set -euo pipefail + uvicorn serving_app.main:app --host 127.0.0.1 --port 8011 >/tmp/uvicorn.log 2>&1 & + echo $! > /tmp/uvicorn.pid + # Wait up to 20s for health for i in {1..40}; do - curl -sf http://127.0.0.1:8011/health && break + 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. Logs:" + cat /tmp/uvicorn.log || true + exit 1 + - name: Predict smoke + env: + API_KEY: test-key run: | - curl -s -X POST http://127.0.0.1:8011/predict \ + set -euo pipefail + RESP=$(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 + -H "x-api-key: ${API_KEY}" \ + -d '{"features":[5.1,3.5,1.4,0.2], "return_proba":true}') + echo "$RESP" | jq . + # Basic shape checks + echo "$RESP" | jq -e 'has("prediction") and has("proba") and has("latency_ms")' >/dev/null + - name: Shutdown if: always() - run: kill $(cat uvicorn.pid) || true + run: | + [ -f /tmp/uvicorn.pid ] && kill $(cat /tmp/uvicorn.pid) || true + sleep 1 + pkill -f "uvicorn" || tr + From 75ea4bfd89cd876f38efce357339ed48c870259c Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 10:44:24 -0500 Subject: [PATCH 04/11] Add GitHub Actions CI (PR fast, main full) and update tests --- .github/workflows/ci-main.yml | 74 ++++++++++++++++++++++++++++++ .github/workflows/ci-pr.yml | 84 +++++++++++++++++++++++++++++++++++ tests/conftest.py | 42 ++++++++++++++---- tests/test_predict.py | 21 +++++---- tests/test_predict_batch.py | 24 ++++++++-- 5 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci-main.yml create mode 100644 .github/workflows/ci-pr.yml 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..9b58256 --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,84 @@ +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 + 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: Predict smoke + 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/tests/conftest.py b/tests/conftest.py index 4fba92c..a5516af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,29 +1,53 @@ +import os +import inspect import pytest from fastapi.testclient import TestClient -from serving_app import main as m # import the module to set its module-level vars +from serving_app import main as m # module where app/_model/_n_features live class _DummyModel: def predict(self, X): - # return 1 prediction per row return [0 for _ in X] def predict_proba(self, X): - # 2-class probs per row return [[0.4, 0.6] for _ in X] +def _noop(): + return None + @pytest.fixture(scope="session") def client(): - # Override API-key dependency so tests don't need headers - m.app.dependency_overrides[m.check_key] = lambda: None + # 1) Make any env/key values present (covers env-based checks) + os.environ.setdefault("API_KEY", "test-key") + os.environ.setdefault("X_API_KEY", "test-key") - # Ensure the module-level model is "loaded" and feature count is known + # 2) Stub module-level model + feature count so handlers don’t 503 or 400 m._model = _DummyModel() - m._n_features = 3 # adjust if your model expects a different length + m._n_features = 4 # adjust if your model expects a different length + + # 3) Blanket override: disable ALL route dependencies (auth, key checks, etc.) + # This catches Depends(check_key) and any other guard you may have. + for route in m.app.routes: + if hasattr(route, "dependencies") and route.dependencies: + for dep in route.dependencies: + if callable(dep.dependency): + m.app.dependency_overrides[dep.dependency] = _noop + + # 4) Also best-effort override any callable on the module that looks like a key/auth check + for name, obj in inspect.getmembers(m): + if callable(obj) and any(tok in name.lower() for tok in ("key", "auth", "token", "apikey")): + m.app.dependency_overrides[obj] = _noop - # Use lifespan so startup/shutdown run + # 5) Spin up TestClient, add common auth headers just in case handlers read them directly 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", + }) yield c - # Clean up overrides after session m.app.dependency_overrides.clear() + + diff --git a/tests/test_predict.py b/tests/test_predict.py index dd0eeee..66605d0 100644 --- a/tests/test_predict.py +++ b/tests/test_predict.py @@ -1,22 +1,27 @@ import pytest def test_predict_happy_path(client): - # schema: {"features": [number,...], "return_proba": bool?} - payload = {"features": [0.1, 0.2, 0.3]} + # 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() - assert "predictions" in out and isinstance(out["predictions"], list) - # optional shape checks if your handler returns a float/class per row - assert len(out["predictions"]) == 1 + # 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], "return_proba": True} + 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() - # could be "probas" or "predictions" as probabilities; assert one exists - assert any(k in out for k in ("probas", "probabilities", "predictions")) + 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 diff --git a/tests/test_predict_batch.py b/tests/test_predict_batch.py index ff94505..d58ba69 100644 --- a/tests/test_predict_batch.py +++ b/tests/test_predict_batch.py @@ -1,16 +1,32 @@ import pytest -# ... keep other tests as-is ... +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 + {}, # missing items {"items": None}, {"items": "nope"}, - {"items": []}, # may raise 500 in current impl; still invalid input {"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, 500) + assert r.status_code in (400, 422) From 75ec51b09421093a4c927012cf7723db6b159d81 Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 11:03:13 -0500 Subject: [PATCH 05/11] deleted old yml file --- .github/workflows/ci.yml | 70 ---------------------------------------- 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ebdadb2..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: CI -on: [push, pull_request] - -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 - # Wait up to 20s for health - 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. Logs:" - cat /tmp/uvicorn.log || true - exit 1 - - - name: Predict smoke - 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 . - # Basic shape checks - 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" || tr - From 307c3fe0244273216b02797717c0d719a2e8b2b5 Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 11:07:35 -0500 Subject: [PATCH 06/11] updated yml file --- .github/workflows/ci-pr.yml | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 9b58256..83458ff 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -1,4 +1,5 @@ name: PR CI (fast) + on: pull_request: @@ -24,6 +25,8 @@ jobs: - name: Unit tests env: PYTHONDONTWRITEBYTECODE: 1 + PYTHONPATH: ${{ github.workspace }} + API_KEY: test-key run: | python -m pytest --cov=serving_app --cov-report=term-missing @@ -57,22 +60,10 @@ jobs: done echo "API failed to start"; cat /tmp/uvicorn.log || true; exit 1 - - name: Predict smoke - 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 + [ -f /tmp/uvicorn.pid ] && kill "$(cat /tmp/uvicorn.pid)" || true sleep 1 pkill -f "uvicorn" || true @@ -82,3 +73,4 @@ jobs: with: name: uvicorn-logs path: /tmp/uvicorn.log + From 7a8f532cf619f7d1e705dc5a1a80e50bb6bc3348 Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 11:18:26 -0500 Subject: [PATCH 07/11] updated conftest --- tests/conftest.py | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a5516af..d2c4f2f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,43 +1,56 @@ +# tests/conftest.py import os import inspect +import contextlib import pytest from fastapi.testclient import TestClient -from serving_app import main as m # module where app/_model/_n_features live +from serving_app import main as m # where app/_model/_n_features live + class _DummyModel: + """Minimal deterministic model stub used during tests.""" def predict(self, X): + # Return a stable class (0) for each row return [0 for _ in X] def predict_proba(self, X): + # Two-class probs that sum to 1 for each row return [[0.4, 0.6] for _ in X] + def _noop(): + """Dependency override that does nothing (e.g., for auth checks).""" return None + @pytest.fixture(scope="session") def client(): - # 1) Make any env/key values present (covers env-based checks) + # --- Preserve original globals so we can restore after the session --- + orig_model = getattr(m, "_model", None) + orig_n_features = getattr(m, "_n_features", None) + orig_overrides = dict(m.app.dependency_overrides) + + # --- Env required by code paths that read API keys directly --- os.environ.setdefault("API_KEY", "test-key") os.environ.setdefault("X_API_KEY", "test-key") - # 2) Stub module-level model + feature count so handlers don’t 503 or 400 + # --- Provide a stub model + expected feature count so handlers don't 503/400 --- m._model = _DummyModel() - m._n_features = 4 # adjust if your model expects a different length + m._n_features = 4 # adjust if your trained model expects a different length - # 3) Blanket override: disable ALL route dependencies (auth, key checks, etc.) - # This catches Depends(check_key) and any other guard you may have. + # --- Blanket-disable route dependencies (e.g., Depends(check_key)) --- for route in m.app.routes: - if hasattr(route, "dependencies") and route.dependencies: + if getattr(route, "dependencies", None): for dep in route.dependencies: - if callable(dep.dependency): + if callable(getattr(dep, "dependency", None)): m.app.dependency_overrides[dep.dependency] = _noop - # 4) Also best-effort override any callable on the module that looks like a key/auth check + # --- Best-effort: disable any module callables that look like auth/key checks --- for name, obj in inspect.getmembers(m): if callable(obj) and any(tok in name.lower() for tok in ("key", "auth", "token", "apikey")): m.app.dependency_overrides[obj] = _noop - # 5) Spin up TestClient, add common auth headers just in case handlers read them directly + # --- Spin up TestClient and attach common auth headers (in case handlers read them) --- with TestClient(m.app) as c: c.headers.update({ "x-api-key": "test-key", @@ -47,7 +60,13 @@ def client(): }) yield c - m.app.dependency_overrides.clear() + # --- Restore app state after tests finish (session scope) --- + 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 + From 8da89de929fb7e52fe181ce7ea3d51ebd2445fa2 Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 11:27:20 -0500 Subject: [PATCH 08/11] tests: fix teardown; restore original overrides/model state --- tests/conftest.py | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d2c4f2f..40512df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,53 +4,39 @@ import contextlib import pytest from fastapi.testclient import TestClient -from serving_app import main as m # where app/_model/_n_features live - +from serving_app import main as m # app/_model/_n_features live here class _DummyModel: - """Minimal deterministic model stub used during tests.""" def predict(self, X): - # Return a stable class (0) for each row return [0 for _ in X] - def predict_proba(self, X): - # Two-class probs that sum to 1 for each row return [[0.4, 0.6] for _ in X] - def _noop(): - """Dependency override that does nothing (e.g., for auth checks).""" return None - @pytest.fixture(scope="session") def client(): - # --- Preserve original globals so we can restore after the session --- + # --- 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) - orig_overrides = dict(m.app.dependency_overrides) - # --- Env required by code paths that read API keys directly --- + # 1) Env for any key checks os.environ.setdefault("API_KEY", "test-key") os.environ.setdefault("X_API_KEY", "test-key") - # --- Provide a stub model + expected feature count so handlers don't 503/400 --- - m._model = _DummyModel() - m._n_features = 4 # adjust if your trained model expects a different length - - # --- Blanket-disable route dependencies (e.g., Depends(check_key)) --- + # 2) Blanket override: disable ALL route dependencies (auth/keys) 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 - - # --- Best-effort: disable any module callables that look like auth/key checks --- for name, obj in inspect.getmembers(m): if callable(obj) and any(tok in name.lower() for tok in ("key", "auth", "token", "apikey")): m.app.dependency_overrides[obj] = _noop - # --- Spin up TestClient and attach common auth headers (in case handlers read them) --- + # 3) Start app, THEN stub model so startup can't overwrite it with TestClient(m.app) as c: c.headers.update({ "x-api-key": "test-key", @@ -58,9 +44,11 @@ def client(): "Authorization": "Bearer test-key", "api-key": "test-key", }) + m._model = _DummyModel() + m._n_features = 4 yield c - # --- Restore app state after tests finish (session scope) --- + # --- Restore originals --- with contextlib.suppress(Exception): m.app.dependency_overrides.clear() m.app.dependency_overrides.update(orig_overrides) @@ -70,3 +58,4 @@ def client(): + From 9ee9e0edfe045b32eca14dd7698236b9eb1fea32 Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 11:33:00 -0500 Subject: [PATCH 09/11] updated conftest --- tests/conftest.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 40512df..363ae40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,18 +2,28 @@ import os import inspect import contextlib +import numpy as np import pytest from fastapi.testclient import TestClient -from serving_app import main as m # app/_model/_n_features live here +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): - return [0 for _ in X] + X = np.asarray(X) + # return numpy array so .astype works + return np.zeros(len(X), dtype=int) + def predict_proba(self, X): - return [[0.4, 0.6] for _ in X] + X = np.asarray(X) + # shape (n_samples, 2) + return np.tile([0.4, 0.6], (len(X), 1)) -def _noop(): - return None @pytest.fixture(scope="session") def client(): @@ -26,17 +36,19 @@ def client(): os.environ.setdefault("API_KEY", "test-key") os.environ.setdefault("X_API_KEY", "test-key") - # 2) Blanket override: disable ALL route dependencies (auth/keys) + # 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(tok in name.lower() for tok in ("key", "auth", "token", "apikey")): + 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 model so startup can't overwrite it + # 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", @@ -59,3 +71,4 @@ def client(): + From 25bd1f6b85d65b3b1b3cef1dd833295da2ac037d Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 11:36:01 -0500 Subject: [PATCH 10/11] docs: add CI status badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index dd26753..8e43b27 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ ![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) +[![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) A tiny, production-style ML serving skeleton. Trains a scikit-learn classifier (Iris demo) and serves predictions via FastAPI. From 2f8af83cfee5accb2f59300abf0ed6b6f4e29dc9 Mon Sep 17 00:00:00 2001 From: Kyle Spengler Date: Mon, 18 Aug 2025 11:39:12 -0500 Subject: [PATCH 11/11] docs: polish README with badges, and CI details --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e43b27..3bacd89 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ -# 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) -[![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) + A tiny, production-style ML serving skeleton. Trains a scikit-learn classifier (Iris demo) and serves predictions via FastAPI.