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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ jobs:
- name: Scan git history for secrets
run: gitleaks detect --source . --redact --no-banner

contract:
name: API contract is fresh
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install import-only deps
run: pip install fastapi "uvicorn[standard]" python-multipart celery redis sqlalchemy pydantic psutil
- name: Regenerate the contract and verify it is committed
run: |
python scripts/dump_contract.py > /tmp/contract.json
if ! diff -u dl_api_contract.json /tmp/contract.json; then
echo "::error::dl_api_contract.json is stale. Regenerate it with: python scripts/dump_contract.py > dl_api_contract.json"
exit 1
fi

smoke:
name: Pipeline smoke test (CPU)
runs-on: ubuntu-latest
Expand Down
134 changes: 134 additions & 0 deletions dl_api_contract.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
{
"/data/upload/{task_type}": {
"POST": [
"data_file",
"data_name"
]
},
"/explain/image-classification": {
"POST": [
"image",
"model_checkpoint"
]
},
"/inference/image-classification": {
"POST": [
"image",
"model_checkpoint"
]
},
"/inference/image-segmentation": {
"POST": [
"image",
"model_checkpoint"
]
},
"/inference/object-detection": {
"POST": [
"classes",
"image",
"imgsz",
"iou",
"max_det",
"model_checkpoint",
"threshold"
]
},
"/train/image-classification": {
"POST": [
"brightness",
"contrast",
"dataset_id",
"dev_ratio",
"early_stopping_patience",
"early_stopping_threshold",
"enable_augmentation",
"epochs",
"eval_batch_size",
"flip_prob",
"gradient_accumulation_steps",
"gradient_checkpointing",
"is_presplit",
"learning_rate",
"max_image_size",
"model_checkpoint",
"optimizer",
"rotate_limit",
"run_name",
"scheduler",
"seed",
"train_batch_size",
"train_ratio",
"version",
"warmup_epochs",
"weight_decay"
]
},
"/train/image-segmentation": {
"POST": [
"brightness",
"contrast",
"dataset_id",
"dev_ratio",
"early_stopping_patience",
"early_stopping_threshold",
"enable_augmentation",
"epochs",
"eval_batch_size",
"flip_prob",
"gradient_accumulation_steps",
"gradient_checkpointing",
"is_presplit",
"learning_rate",
"max_image_size",
"model_checkpoint",
"optimizer",
"rotate_limit",
"run_name",
"scheduler",
"seed",
"train_batch_size",
"train_ratio",
"version",
"warmup_epochs",
"weight_decay"
]
},
"/train/object-detection": {
"POST": [
"brightness",
"contrast",
"dataset_id",
"early_stopping_patience",
"early_stopping_threshold",
"enable_augmentation",
"epochs",
"eval_batch_size",
"flip_prob",
"gradient_accumulation_steps",
"gradient_checkpointing",
"hsv_h",
"hsv_s",
"hsv_v",
"learning_rate",
"lr0",
"max_image_size",
"mixup",
"model_checkpoint",
"momentum",
"mosaic",
"optimizer",
"optimizer_hf",
"rotate_limit",
"run_name",
"scheduler_hf",
"seed",
"train_batch_size",
"version",
"warmup_epochs",
"warmup_epochs_hf",
"weight_decay_hf",
"weight_decay_yolo"
]
}
}
56 changes: 56 additions & 0 deletions scripts/dump_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Emit the API's form-field contract as JSON.

For every endpoint that accepts a form/multipart body, lists the accepted field
names. This is the source of truth the R client is checked against: if the
client sends a field not listed here, that's the drift class of bug that took
the platform down. The R repo vendors the emitted file and validates its
requests against it in CI.

Usage: python scripts/dump_contract.py > dl_api_contract.json
"""
import json
import os
import sys


def _resolve_props(schema: dict, node: dict) -> dict:
"""Return the properties of a schema node, following a $ref if present."""
if "$ref" in node:
ref = node["$ref"].split("/")[-1]
node = schema.get("components", {}).get("schemas", {}).get(ref, {})
return node.get("properties", {})


def extract_contract(app) -> dict:
schema = app.openapi()
contract = {}
for path, methods in schema.get("paths", {}).items():
for method, op in methods.items():
body = op.get("requestBody", {})
content = body.get("content", {})
form = content.get("multipart/form-data") or content.get(
"application/x-www-form-urlencoded"
)
if not form:
continue
props = _resolve_props(schema, form.get("schema", {}))
contract.setdefault(path, {})[method.upper()] = sorted(props.keys())
return contract


def main():
# Avoid needing a live broker just to import the app.
os.environ.setdefault("CELERY_BROKER_URL", "memory://")
os.environ.setdefault("CELERY_RESULT_BACKEND", "cache+memory://")
# Ensure the repo root (parent of scripts/) is importable.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fastapi_app

contract = extract_contract(fastapi_app.app)
json.dump(contract, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write("\n")


if __name__ == "__main__":
main()
Loading