diff --git a/.github/workflows/backend-metadata.yml b/.github/workflows/backend-metadata.yml index ab3a1ec..0d3afbf 100644 --- a/.github/workflows/backend-metadata.yml +++ b/.github/workflows/backend-metadata.yml @@ -15,3 +15,7 @@ jobs: python-version: '3.11' - run: pip install 'numpy<2' tflite-runtime==2.14.0 fastapi pillow - run: python -m unittest discover -s tests -p test_backend_model_info.py -v + - name: Install API startup test dependencies + run: pip install pydantic-settings uvicorn httpx python-multipart shapely sentry-sdk + - name: Verify default readiness and lazy optional engines + run: python -m unittest discover -s tests -p test_startup.py -v diff --git a/README.md b/README.md index ba4ebf1..b62da49 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ A lightweight Python API for object detection with pluggable backends. This API allows you to detect objects in images using different detection backends, starting with TensorFlow Lite. +The API initializes `DEFAULT_BACKEND` before accepting requests and fails startup +if its model or runtime is unavailable. Other backends initialize on demand; +listing them in `AVAILABLE_BACKENDS` does not require installing every optional +model, runtime, or accelerator. Requests for an optional backend still require its +corresponding model and dependencies. For optional TFLite use, provision the model +with `python scripts/download_model.py` or configure an existing model path. + ## Features - FastAPI-based REST API for object detection @@ -33,7 +40,7 @@ A lightweight Python API for object detection with pluggable backends. This API pipenv run python scripts/download_model.py ``` - If you skip this step, the API will try to download the default model on startup when the `tflite` backend is enabled (requires internet access). Docker builds download the default model by default. + If you skip this step, the API will try to download the default model on startup when `DEFAULT_BACKEND=tflite` (requires internet access). Docker builds download the default model by default. ## Usage diff --git a/main.py b/main.py index bdb3937..bf97ecd 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from api.router import router as api_router from config import settings from backends.factory import get_backend -from utils.model_download import ensure_tflite_ssd_mobilenet_v1, ModelDownloadError +from utils.model_download import ensure_tflite_ssd_mobilenet_v1 from observability import init_sentry @@ -35,33 +35,25 @@ async def startup_event(): logger.info(f"Available backends: {settings.AVAILABLE_BACKENDS}") logger.info(f"Default backend: {settings.DEFAULT_BACKEND}") - # Ensure the default TFLite model is present if TFLite is enabled. - # This keeps the out-of-the-box experience working, while still allowing - # users to override paths via .env. - if "tflite" in settings.AVAILABLE_BACKENDS: - try: - ensure_result = ensure_tflite_ssd_mobilenet_v1( - model_path=settings.TFLITE_MODEL_PATH, - labels_path=settings.TFLITE_LABELS_PATH, - force=False, - ) - if ensure_result.did_download: - logger.info(f"✓ Downloaded default TFLite model: {ensure_result.message}") - except ModelDownloadError as e: - logger.error( - "✗ TFLite model is missing and could not be downloaded. " - f"Reason: {e}. " - "Fix: run 'python scripts/download_model.py' or set TFLITE_MODEL_PATH to an existing model." - ) + # Optional engines stay lazy: their models, runtimes or hardware may not + # exist on this host. The configured default must actually initialize before + # the application can start accepting requests (including /health). + backend_name = settings.DEFAULT_BACKEND + if backend_name not in settings.AVAILABLE_BACKENDS: + raise ValueError("DEFAULT_BACKEND must be included in AVAILABLE_BACKENDS") + if backend_name == "tflite": + ensure_result = ensure_tflite_ssd_mobilenet_v1( + model_path=settings.TFLITE_MODEL_PATH, + labels_path=settings.TFLITE_LABELS_PATH, + force=False, + ) + if ensure_result.did_download: + logger.info("Downloaded default TFLite model: %s", ensure_result.message) - # Test each backend - for backend_name in settings.AVAILABLE_BACKENDS: - try: - detector = get_backend(backend_name) - model_info = detector.get_model_info() - logger.info(f"✓ {backend_name.upper()} backend: {model_info.get('model_path', 'N/A')}") - except Exception as e: - logger.error(f"✗ {backend_name.upper()} backend failed: {str(e)}") + detector = get_backend(backend_name) + model_info = detector.get_model_info() + logger.info("Default %s backend ready: %s", backend_name.upper(), + model_info.get("model_path", "N/A")) logger.info("=" * 60) diff --git a/tests/test_startup.py b/tests/test_startup.py new file mode 100644 index 0000000..858a232 --- /dev/null +++ b/tests/test_startup.py @@ -0,0 +1,57 @@ +"""Startup requires the default engine, without probing unrelated hardware.""" +import unittest +from unittest.mock import Mock, patch + +from fastapi.testclient import TestClient +import main + + +class StartupTests(unittest.TestCase): + def setup_settings(self, default='onnx', available=None): + return patch.multiple(main.settings, DEFAULT_BACKEND=default, + AVAILABLE_BACKENDS=available or ['onnx','tflite','opencv','edgetpu','moondream']) + + def test_optional_backends_are_not_loaded_or_downloaded_during_startup(self): + detector = Mock() + detector.get_model_info.return_value = {'model_path':'fixture.onnx'} + with self.setup_settings(), patch.object(main, 'get_backend', return_value=detector) as get, \ + patch.object(main, 'ensure_tflite_ssd_mobilenet_v1') as download: + with TestClient(main.app) as client: + self.assertEqual(client.get('/health').status_code, 200) + get.assert_called_once_with('onnx') + download.assert_not_called() + + def test_missing_default_model_or_driver_prevents_healthy_startup(self): + for error in (FileNotFoundError('fixture model'), OSError('fixture driver')): + with self.subTest(error=type(error).__name__), self.setup_settings('edgetpu'), \ + patch.object(main, 'get_backend', side_effect=error): + with self.assertRaises(type(error)): + with TestClient(main.app):pass + + def test_disabled_default_prevents_startup(self): + with self.setup_settings('edgetpu', ['onnx']), patch.object(main, 'get_backend') as get: + with self.assertRaises(ValueError): + with TestClient(main.app):pass + get.assert_not_called() + + def test_tflite_default_is_provisioned_before_initialization(self): + calls = [] + def download(**kwargs): + calls.append('download');return Mock(did_download=False) + def get(name): + calls.append(name);return Mock(get_model_info=lambda:{}) + with self.setup_settings('tflite'), patch.object(main, 'get_backend', side_effect=get), \ + patch.object(main, 'ensure_tflite_ssd_mobilenet_v1', side_effect=download): + with TestClient(main.app) as client: + self.assertEqual(client.get('/health').status_code, 200) + self.assertEqual(calls, ['download','tflite']) + + def test_default_download_failure_prevents_startup(self): + with self.setup_settings('tflite'), patch.object(main, 'get_backend') as get, \ + patch.object(main, 'ensure_tflite_ssd_mobilenet_v1', side_effect=OSError('fixture download')): + with self.assertRaises(OSError): + with TestClient(main.app):pass + get.assert_not_called() + + +if __name__ == '__main__':unittest.main()