diff --git a/Dockerfile.prod b/Dockerfile.prod index c8262380..38f8352f 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,56 +1,68 @@ -# OntoKit API Production Dockerfile -FROM python:3.13-slim +# OntoKit API Production Dockerfile (multi-stage) +# ---------- Stage 1: builder ---------- +FROM python:3.13-slim AS builder -# Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 -# Install system dependencies (git + libgit2-dev required by pygit2/gitpython) +# Build-time deps only: gcc is a safety net for any source-only sdists. +# pygit2 ships manylinux wheels with libgit2 bundled, so no libgit2-dev is needed. RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - git \ - libgit2-dev \ + gcc \ && rm -rf /var/lib/apt/lists/* -# Create non-root user -RUN useradd --create-home --shell /bin/bash ontokit -WORKDIR /home/ontokit/app +# Install dependencies into a dedicated virtualenv so the runtime stage can +# copy a single self-contained tree. +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" -# Create git repo storage directory -RUN mkdir -p /data/repos && chown ontokit:ontokit /data/repos - -# Install Python dependencies +WORKDIR /build COPY pyproject.toml README.md ./ -COPY ontokit/version.py ./ontokit/version.py +COPY ontokit/ ./ontokit/ RUN pip install --upgrade pip && \ pip install . -# Copy application code -COPY --chown=ontokit:ontokit ontokit/ ./ontokit/ +# Strip bytecode caches and test directories from site-packages to shrink the venv. +RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + && \ + find /opt/venv -type d -name "tests" -exec rm -rf {} + && \ + find /opt/venv -type d -name "test" -exec rm -rf {} + + +# ---------- Stage 2: runtime ---------- +FROM python:3.13-slim AS runtime -# Copy alembic configuration for migrations +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" + +# Runtime-only deps: curl for healthcheck, git for GitPython subprocess calls. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --shell /bin/bash ontokit && \ + mkdir -p /data/repos && \ + chown ontokit:ontokit /data/repos + +COPY --from=builder /opt/venv /opt/venv + +WORKDIR /home/ontokit/app + +# The ontokit package is already installed in /opt/venv by the builder stage, +# so we only need the alembic migration files in the workdir at runtime. COPY --chown=ontokit:ontokit alembic.ini ./ COPY --chown=ontokit:ontokit alembic/ ./alembic/ - -# Copy entrypoint script (runs migrations before starting the app) COPY --chown=ontokit:ontokit scripts/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh -# Switch to non-root user USER ontokit -# Pre-download sentence-transformers model to avoid first-request latency -RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" - -# Expose port EXPOSE 8000 -# Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1 -# Auto-migrate on startup, then run the app ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["uvicorn", "ontokit.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/ontokit/services/embedding_providers/local_provider.py b/ontokit/services/embedding_providers/local_provider.py index e68f1bb5..8f5d9efe 100644 --- a/ontokit/services/embedding_providers/local_provider.py +++ b/ontokit/services/embedding_providers/local_provider.py @@ -14,7 +14,14 @@ def _get_model(model_name: str) -> object: """Get or load a sentence-transformers model (cached).""" if model_name not in _models: - from sentence_transformers import SentenceTransformer + try: + from sentence_transformers import SentenceTransformer + except ImportError as exc: + raise RuntimeError( + "The local embedding provider requires 'sentence-transformers'. " + "Install it with: pip install 'ontokit[local-embeddings]' " + "(or choose the 'openai' or 'voyage' provider instead)." + ) from exc logger.info(f"Loading sentence-transformers model: {model_name}") _models[model_name] = SentenceTransformer(model_name) diff --git a/pyproject.toml b/pyproject.toml index e5311331..ff8fe58d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,11 +43,15 @@ dependencies = [ "minio>=7.2.0", "websockets>=14.0", "slowapi>=0.1.9", - "sentence-transformers>=5.4.0", "pgvector>=0.3.0", "numpy>=2.4.4", ] +[project.optional-dependencies] +local-embeddings = [ + "sentence-transformers>=5.4.0", +] + [dependency-groups] dev = [ "pytest>=9.0.3", @@ -112,6 +116,12 @@ init_forbid_extra = true init_typed = true warn_required_dynamic_aliases = true +# sentence-transformers is an optional extra ([local-embeddings]); it won't be +# present in the default environment, so mypy should not fail on the import. +[[tool.mypy.overrides]] +module = ["sentence_transformers"] +ignore_missing_imports = true + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/tests/unit/test_embedding_service.py b/tests/unit/test_embedding_service.py index f16fc7c9..5d20a9f7 100644 --- a/tests/unit/test_embedding_service.py +++ b/tests/unit/test_embedding_service.py @@ -107,6 +107,8 @@ async def test_creates_new_config_when_none_exists( self, service: EmbeddingService, mock_db: AsyncMock ) -> None: """Creates a new ProjectEmbeddingConfig when none exists.""" + from unittest.mock import patch + result = MagicMock() result.scalar_one_or_none.return_value = None mock_db.execute.return_value = result @@ -118,7 +120,13 @@ async def test_creates_new_config_when_none_exists( update.api_key = None update.auto_embed_on_save = True - await service.update_config(PROJECT_ID, update) + mock_provider = MagicMock() + mock_provider.dimensions = 384 + with patch( + "ontokit.services.embedding_service.get_embedding_provider", + return_value=mock_provider, + ): + await service.update_config(PROJECT_ID, update) mock_db.add.assert_called_once() added = mock_db.add.call_args[0][0] assert added.auto_embed_on_save is True diff --git a/tests/unit/test_local_embedding_provider.py b/tests/unit/test_local_embedding_provider.py new file mode 100644 index 00000000..24a4e985 --- /dev/null +++ b/tests/unit/test_local_embedding_provider.py @@ -0,0 +1,31 @@ +"""Tests for the local embedding provider (ontokit/services/embedding_providers/local_provider.py).""" + +from __future__ import annotations + +import sys + +import pytest + +from ontokit.services.embedding_providers import local_provider +from ontokit.services.embedding_providers.local_provider import LocalEmbeddingProvider + + +class TestSentenceTransformersMissing: + """sentence-transformers is an optional extra; verify the friendly error path.""" + + def test_dimensions_raises_runtime_error_pointing_at_extras( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Poison sys.modules so `from sentence_transformers import ...` fails + # with ImportError even if the package is installed in the dev env. + monkeypatch.setitem(sys.modules, "sentence_transformers", None) + # Reset the model cache so _get_model actually attempts the import. + monkeypatch.setattr(local_provider, "_models", {}) + + provider = LocalEmbeddingProvider(model_name="all-MiniLM-L6-v2") + with pytest.raises(RuntimeError, match="local-embeddings") as excinfo: + _ = provider.dimensions + + # The RuntimeError preserves the original ImportError as its cause, + # so callers (and logs) can trace back to the real failure. + assert isinstance(excinfo.value.__cause__, ImportError) diff --git a/uv.lock b/uv.lock index 2f5498f6..5f6b7a54 100644 --- a/uv.lock +++ b/uv.lock @@ -1521,13 +1521,17 @@ dependencies = [ { name = "python-multipart" }, { name = "rdflib" }, { name = "redis" }, - { name = "sentence-transformers" }, { name = "slowapi" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, { name = "websockets" }, ] +[package.optional-dependencies] +local-embeddings = [ + { name = "sentence-transformers" }, +] + [package.dev-dependencies] dev = [ { name = "httpx" }, @@ -1562,12 +1566,13 @@ requires-dist = [ { name = "python-multipart", specifier = ">=0.0.26" }, { name = "rdflib", specifier = ">=7.1.0" }, { name = "redis", specifier = ">=5.2.0" }, - { name = "sentence-transformers", specifier = ">=5.4.0" }, + { name = "sentence-transformers", marker = "extra == 'local-embeddings'", specifier = ">=5.4.0" }, { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0.49" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.44.0" }, { name = "websockets", specifier = ">=14.0" }, ] +provides-extras = ["local-embeddings"] [package.metadata.requires-dev] dev = [