fix(deps): nommer le pilote PostgreSQL, sinon rien ne demarre
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 <[email protected]>
This commit is contained in:
@@ -24,6 +24,8 @@ DISCORD_CLIENT_SECRET=
|
|||||||
DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
|
||||||
|
|
||||||
#where to find the db (hosted on render for now)
|
#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
|
DATABASE_URL=URI_vers_db_posgres
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+39
@@ -35,6 +35,40 @@ def nl2br(value):
|
|||||||
return ''
|
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):
|
def build_csp(*, allow_inline_script, nonce=None):
|
||||||
"""Assemble the Content-Security-Policy header.
|
"""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'
|
'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)
|
# File upload size limit (16 MB)
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Requires the PostgreSQL client tools (`pg_dump`, `pg_restore`) on `PATH`, or
|
|||||||
|
|
||||||
| Variable | Default | Purpose |
|
| 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_DIR` | `./backups` | Destination directory |
|
||||||
| `BACKUP_RETENTION_DAYS` | `30` | Files older than this are deleted |
|
| `BACKUP_RETENTION_DAYS` | `30` | Files older than this are deleted |
|
||||||
| `PG_DUMP` / `PG_RESTORE` | `pg_dump` / `pg_restore` | Full paths if not on `PATH` |
|
| `PG_DUMP` / `PG_RESTORE` | `pg_dump` / `pg_restore` | Full paths if not on `PATH` |
|
||||||
|
|||||||
+3
-2
@@ -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))")
|
# Security (REQUIRED - generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
||||||
SECRET_KEY=<your-generated-64-char-hex-key>
|
SECRET_KEY=<your-generated-64-char-hex-key>
|
||||||
|
|
||||||
# Database
|
# Database — PostgreSQL. create_app() refuses to start without this.
|
||||||
DATABASE_URL=sqlite:///team_tryouts.db
|
# Either form works; the driver is named for you if you leave it out.
|
||||||
|
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
SESSION_COOKIE_SECURE=true
|
SESSION_COOKIE_SECURE=true
|
||||||
|
|||||||
+11
-3
@@ -1,9 +1,17 @@
|
|||||||
# Tooling configuration.
|
# Tooling configuration.
|
||||||
#
|
#
|
||||||
# Deliberately limited to tool settings: the project is run from wsgi.py,
|
# Deliberately limited to tool settings: the project is run from wsgi.py,
|
||||||
# not installed as a distribution, so there is no [project] table yet.
|
# not installed as a distribution, so there is no [project] table.
|
||||||
# Consolidating requirements.txt / requirements-dev.txt into dependency
|
#
|
||||||
# groups here is tracked as QUA-001.
|
# 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]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -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%40s[email protected]:5432/tryouts?sslmode=require')
|
||||||
|
assert result == (
|
||||||
|
'postgresql+psycopg://u:p%40s[email protected]: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:[email protected]: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'
|
||||||
Reference in New Issue
Block a user