From 2f40290f0055996be30db27fb2c7c00ee922fa24 Mon Sep 17 00:00:00 2001 From: GGThed Date: Sat, 8 Aug 2026 15:50:35 -0400 Subject: [PATCH] fix(deps): nommer le pilote PostgreSQL, sinon rien ne demarre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUA-001, volet dialecte. `postgresql://` ne veut pas dire "le pilote installe" : SQLAlchemy y lit psycopg2 et importe ce module a la creation du moteur. requirements.txt epingle psycopg 3 (`psycopg[binary]`) et pas psycopg2. Une installation propre demarree sur cette URL leve donc ModuleNotFoundError: No module named 'psycopg2' avant la premiere requete. Verifie dans le .venv du depot, et c est exactement la forme que Render distribue -- celle que docs/deployment.md et docs/database-restore.md donnaient en exemple. normalise_database_url() nomme le pilote quand l URL n en nomme pas. `postgres://` (alias hérite, abandonne par SQLAlchemy en 1.4) est traite de meme. Une URL qui nomme deja son pilote est laissee telle quelle, y compris `postgresql+psycopg2://` : un environnement qui a psycopg2 garde le choix. La normalisation a lieu apres l application de la configuration passee en argument, pour couvrir aussi les appels de test. backup.py n avait pas besoin d etre touche : il retirait deja le suffixe +pilote. Documentation alignee sur les trois fichiers qui donnaient l exemple, dont docs/deployment.md qui proposait sqlite:/// pour DATABASE_URL alors que create_app refuse de demarrer sans PostgreSQL. Reste de QUA-001, dit franchement - les trois paquets parasites (dotenv, login, discord) ne sont plus dans requirements.txt : deja retires. psycopg est deja epingle. - la consolidation vers des groupes de dependances n est PAS faite. Le deploiement est un miroir de fichiers lftp sans etape de construction ; les groupes PEP 735 demandent pip >= 25.1 sur une machine dont on ne peut pas verifier la version d ici. A revoir avec OPS-011. 14 tests, dont trois qui prouvent que l echec est reel et non theorique. Co-Authored-By: Claude Opus 5 --- app/.env.exemple | 2 + app/app.py | 39 +++++++++++++++++ docs/database-restore.md | 2 +- docs/deployment.md | 5 ++- pyproject.toml | 14 ++++-- tests/test_database_url.py | 90 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 tests/test_database_url.py diff --git a/app/.env.exemple b/app/.env.exemple index 6ec56e3..97044b3 100644 --- a/app/.env.exemple +++ b/app/.env.exemple @@ -24,6 +24,8 @@ DISCORD_CLIENT_SECRET= DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback #where to find the db (hosted on render for now) +#Forme attendue : postgresql://utilisateur:motdepasse@hote:5432/base +#Le pilote psycopg 3 est nomme automatiquement par create_app(). DATABASE_URL=URI_vers_db_posgres diff --git a/app/app.py b/app/app.py index f81d82f..46b0a1a 100644 --- a/app/app.py +++ b/app/app.py @@ -35,6 +35,40 @@ def nl2br(value): return '' +def normalise_database_url(url): + """Name the PostgreSQL driver explicitly in a connection URL. + + `postgresql://…` does not mean "whichever driver is installed": it means + psycopg2, which SQLAlchemy imports at create_engine() time. requirements + .txt pins psycopg 3 (`psycopg[binary]`) and no psycopg2, so a clean + install starting against the URL Render hands out — and the one this + project's own documentation shows — raises + + ModuleNotFoundError: No module named 'psycopg2' + + before the first request. Anything with a driver already spelled out + (`postgresql+psycopg://`, `postgresql+psycopg2://`) is left alone, so + naming psycopg2 stays possible for an environment that has it. + + `postgres://` is the legacy alias several hosts still emit; SQLAlchemy + dropped it in 1.4. + + Args: + url: Value of DATABASE_URL, or None. + + Returns: + str | None: The URL, with a driver named when it was PostgreSQL. + """ + if not url: + return url + scheme, separator, rest = url.partition('://') + if not separator or '+' in scheme: + return url + if scheme in ('postgres', 'postgresql'): + return f'postgresql+psycopg://{rest}' + return url + + def build_csp(*, allow_inline_script, nonce=None): """Assemble the Content-Security-Policy header. @@ -155,6 +189,11 @@ def create_app(config=None): 'DATABASE_URL environment variable must be set to a PostgreSQL connection string' ) + # After the overrides, so a caller-supplied URL is normalised too. + app.config['SQLALCHEMY_DATABASE_URI'] = normalise_database_url( + app.config['SQLALCHEMY_DATABASE_URI'] + ) + # File upload size limit (16 MB) app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 diff --git a/docs/database-restore.md b/docs/database-restore.md index c29dfcf..5e32690 100644 --- a/docs/database-restore.md +++ b/docs/database-restore.md @@ -33,7 +33,7 @@ Requires the PostgreSQL client tools (`pg_dump`, `pg_restore`) on `PATH`, or | Variable | Default | Purpose | |---|---|---| -| `DATABASE_URL` | — | Required. `postgresql://user:pass@host:port/dbname` | +| `DATABASE_URL` | — | Required. `postgresql://user:pass@host:port/dbname`. The application names the psycopg 3 driver itself; this script accepts either form. | | `BACKUP_DIR` | `./backups` | Destination directory | | `BACKUP_RETENTION_DAYS` | `30` | Files older than this are deleted | | `PG_DUMP` / `PG_RESTORE` | `pg_dump` / `pg_restore` | Full paths if not on `PATH` | diff --git a/docs/deployment.md b/docs/deployment.md index 397d4fa..baafc41 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -39,8 +39,9 @@ Create a `.env` file in the project root: # Security (REQUIRED - generate with: python -c "import secrets; print(secrets.token_hex(32))") SECRET_KEY= -# Database -DATABASE_URL=sqlite:///team_tryouts.db +# Database — PostgreSQL. create_app() refuses to start without this. +# Either form works; the driver is named for you if you leave it out. +DATABASE_URL=postgresql://user:password@host:5432/dbname # Security settings SESSION_COOKIE_SECURE=true diff --git a/pyproject.toml b/pyproject.toml index 11d0acd..7a37bdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,17 @@ # Tooling configuration. # # Deliberately limited to tool settings: the project is run from wsgi.py, -# not installed as a distribution, so there is no [project] table yet. -# Consolidating requirements.txt / requirements-dev.txt into dependency -# groups here is tracked as QUA-001. +# not installed as a distribution, so there is no [project] table. +# +# QUA-001 asked for requirements.txt / requirements-dev.txt to be folded +# into dependency groups here. Not done, on purpose. Deployment is an +# lftp file mirror with no build step: the server runs whatever +# `pip install -r requirements.txt` gives it, and PEP 735 groups need +# pip >= 25.1 on a machine whose pip version cannot be checked from here. +# Trading a working install path for a tidier declaration is not a trade +# worth making blind. The parts of QUA-001 that were real defects — the +# psycopg dialect and the parasitic packages — are fixed; revisit the +# consolidation when the deployment gains a build step (OPS-011). [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_database_url.py b/tests/test_database_url.py new file mode 100644 index 0000000..84479da --- /dev/null +++ b/tests/test_database_url.py @@ -0,0 +1,90 @@ +"""Which PostgreSQL driver the application actually asks for — QUA-001. + +`postgresql://…` is not "whichever driver is installed". SQLAlchemy reads it +as psycopg2 and imports that module when the engine is created. +requirements.txt pins psycopg 3 and no psycopg2, so a clean install against +the URL Render hands out — the same form docs/database-restore.md documents +— fails before the first request: + + ModuleNotFoundError: No module named 'psycopg2' + +create_app() now names the driver. These tests pin that down, and the last +one proves the failure is real rather than theoretical. +""" + +import pytest +from sqlalchemy import create_engine + +from app.app import normalise_database_url + + +class TestNormalisation: + @pytest.mark.parametrize('given', [ + 'postgresql://user:pass@host:5432/tryouts', + 'postgres://user:pass@host:5432/tryouts', + ]) + def test_a_driverless_postgres_url_gets_psycopg(self, given): + assert normalise_database_url(given).startswith('postgresql+psycopg://') + + def test_the_rest_of_the_url_is_untouched(self): + result = normalise_database_url( + 'postgresql://u:p%40ss@db.internal:5432/tryouts?sslmode=require') + assert result == ( + 'postgresql+psycopg://u:p%40ss@db.internal:5432/tryouts?sslmode=require') + + @pytest.mark.parametrize('given', [ + 'postgresql+psycopg://user:pass@host/db', + 'postgresql+psycopg2://user:pass@host/db', + ]) + def test_an_explicit_driver_is_left_alone(self, given): + """Naming psycopg2 stays possible for an environment that has it.""" + assert normalise_database_url(given) == given + + def test_sqlite_is_left_alone(self): + assert normalise_database_url('sqlite:///x.db') == 'sqlite:///x.db' + + @pytest.mark.parametrize('given', [None, '', 'not-a-url']) + def test_nothing_to_normalise(self, given): + assert normalise_database_url(given) == given + + +class TestTheFactory: + @staticmethod + def _config(uri): + return { + 'SECRET_KEY': 'test-secret-not-used-anywhere-real', + 'SQLALCHEMY_DATABASE_URI': uri, + 'TESTING': True, + 'WTF_CSRF_ENABLED': False, + 'FORCE_HTTPS': False, + 'SESSION_COOKIE_SECURE': False, + 'ENABLE_DISCORD_BOT': False, + 'AUTO_CREATE_TABLES': False, + 'CORS_ALLOWED_ORIGINS': '', + 'RATELIMIT_ENABLED': False, + } + + def test_the_configured_uri_names_a_driver(self): + from app.app import create_app + + app = create_app(self._config('postgresql://u:p@db.internal:5432/tryouts')) + assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('postgresql+psycopg://') + + def test_the_sqlite_suite_is_unaffected(self, app): + assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///') + + +class TestTheFailureIsReal: + """Not a hypothetical: this is what the deployed configuration did.""" + + def test_psycopg2_is_not_installed(self): + with pytest.raises(ImportError): + import psycopg2 # noqa: F401 + + def test_a_driverless_url_cannot_build_an_engine(self): + with pytest.raises(ModuleNotFoundError): + create_engine('postgresql://u:p@host/db') + + def test_the_normalised_url_can(self): + engine = create_engine(normalise_database_url('postgresql://u:p@host/db')) + assert engine.dialect.driver == 'psycopg'