diff --git a/Dockerfile b/Dockerfile index 0df1227..a8b6358 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,8 @@ COPY locales ./locales COPY package.json ./ # Install backend deps against the final pyproject.toml/uv.lock -RUN cd backend && uv sync --frozen --no-dev +RUN cd backend && uv sync --frozen --no-dev \ + && uv run --frozen python -c "import psycopg" || (echo "FATAL: psycopg not installed — package sync missing the Postgres driver" >&2 && exit 1) # Make the migration-runner entrypoint executable RUN chmod +x /app/backend/docker_entrypoint.sh diff --git a/backend/app/db.py b/backend/app/db.py index 232571d..7c64cc8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -22,6 +22,15 @@ def create_database_engine(database_url: str | None = None, **kwargs) -> Engine: data_dir.mkdir(parents=True, exist_ok=True) url = f"sqlite+pysqlite:///{(data_dir / 'crowdsight.db').resolve()}" + # Normalize bare `postgres://` (dialect alias SQLAlchemy only resolves in + # some versions) into the explicit psycopg3 dialect. Without this, + # create_engine raises "NoSuchModuleError: Can't load plugin: + # sqlalchemy.dialects:postgres" even though psycopg is installed. + if url.startswith("postgres://"): + url = url.replace("postgres://", "postgresql+psycopg://", 1) + elif url.startswith("postgres+pq://"): # legacy psycopg2 scheme + url = url.replace("postgres+pq://", "postgresql+psycopg://", 1) + connect_args = dict(kwargs.pop("connect_args", {})) if url.startswith("sqlite"): connect_args.setdefault("check_same_thread", False) diff --git a/backend/tests/test_db_url_normalization.py b/backend/tests/test_db_url_normalization.py new file mode 100644 index 0000000..722925e --- /dev/null +++ b/backend/tests/test_db_url_normalization.py @@ -0,0 +1,42 @@ +"""Tests for create_database_engine URL normalization. + +Bare `postgres://` and legacy `postgres+pq://` schemes must be normalized to +the explicit `postgresql+psycopg://` dialect, otherwise SQLAlchemy raises +"NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:postgres" even when +psycopg is installed. +""" + +from app.db import create_database_engine + + +def test_normalizes_bare_postgres_scheme(): + engine = create_database_engine("postgres://user:pass@db.example.com:5432/mydb") + try: + assert str(engine.url).startswith("postgresql+psycopg://") + assert "user:***@db.example.com:5432/mydb" in str(engine.url) + finally: + engine.dispose() + + +def test_normalizes_legacy_psycopg2_scheme(): + engine = create_database_engine("postgres+pq://user:pass@localhost:5432/db") + try: + assert str(engine.url).startswith("postgresql+psycopg://") + finally: + engine.dispose() + + +def test_leaves_explicit_psycopg_scheme_unchanged(): + engine = create_database_engine("postgresql+psycopg://user:pass@localhost:5432/db") + try: + assert str(engine.url).startswith("postgresql+psycopg://") + finally: + engine.dispose() + + +def test_leaves_sqlite_unchanged(): + engine = create_database_engine("sqlite:////tmp/db-test.sqlite") + try: + assert str(engine.url).startswith("sqlite:") + finally: + engine.dispose()