From 7f17b4bb2f97b7de358741a161aa4294bc1bf664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20de=20Saint=20Martin?= Date: Mon, 19 Oct 2026 13:34:11 +0200 Subject: [PATCH] fix: drop the hard psycopg2 dependency Chartreuse imports no database driver. SQLAlchemy selects the driver from the dialect: "postgresql" means psycopg2 with SQLAlchemy 2.0 and psycopg (3) with SQLAlchemy 2.1. The hard psycopg2 dependency forced a compiled psycopg2 into images that migrate to psycopg 3. - Remove psycopg2 from the dependencies. - Add the extras "psycopg2" and "psycopg" to install a driver. - Build the URL with URL.create, so special characters in the user or the password do not break it. - Double "%" when writing alembic.ini, because ConfigParser and Alembic interpolate it. - Release as 7.1.0, with the Python 3.14 minimum from 8.0.0. The Helm chart requires the same major version as the package. --- CHANGELOG.md | 28 ++++++++++++++++-- README.md | 1 + VERSION | 2 +- docs/multi-database.md | 2 +- pyproject.toml | 11 ++++++- requirements.txt | 2 -- src/chartreuse/config_loader.py | 15 ++++++++-- .../tests/unit_tests/test_alembic.py | 29 +++++++++++++++++++ .../tests/unit_tests/test_config_loader.py | 19 ++++++++++++ .../utils/alembic_migration_helper.py | 5 ++-- 10 files changed, 102 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a378eb..8602beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,31 @@ # Changelog -## v8.0.0 (unreleased) +## v7.1.0 (unreleased) -### BREAKING CHANGE -- Raise minimum supported Python version to 3.14. Wiremind decommissions Python 3.13. +v7.1.0 replaces v8.0.0. Chartreuse keeps major 7, because the +[Chartreuse Helm Chart](https://github.com/wiremind/wiremind-helm-charts/tree/main/charts/chartreuse) +requires the same major version as the Python package. Do not use v8.0.0. + +### Upgrade +Chartreuse no longer installs a database driver. Pick the extra that matches your SQLAlchemy version: + +| Your project | Dependency | SQLAlchemy | +| --- | --- | --- | +| Stays on psycopg2 (no change) | `chartreuse[psycopg2]` | `<2.1` | +| Moves to psycopg 3 | `chartreuse[psycopg]` | `>=2.1` | + +- With SQLAlchemy 2.1, a `postgresql://` URL needs psycopg 3. Pin `sqlalchemy<2.1` while you stay on psycopg2. +- To use psycopg 3 with SQLAlchemy 2.0, set `dialect: postgresql+psycopg` in the Chartreuse configuration. + +### Python +- The minimum supported Python version is 3.14. Wiremind decommissions Python 3.13. + +### Fix +- Chartreuse no longer depends on `psycopg2`. The project image provides the database driver. + SQLAlchemy selects the driver from the dialect: `postgresql` means psycopg2 with SQLAlchemy 2.0 + and psycopg (3) with SQLAlchemy 2.1. See [Upgrade](#upgrade). +- The database URL is built with `sqlalchemy.engine.URL.create`. Special characters in the user or + the password (`@`, `:`, `/`, `%`) no longer break the URL or the `alembic.ini` interpolation. ## v7.0.2 (2026-10-05) diff --git a/README.md b/README.md index 33b6e94..2bafe71 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ All you have to do is build your own container image containing: - Chartreuse Python package - Your Alembic migrations in an `alembic` directory - All required dependencies to run your alembic migrations. + This includes the database driver: Chartreuse does not install one. Use the extra `chartreuse[psycopg]` or `chartreuse[psycopg2]`, or depend on the driver directly. Usually, it will be the same container image for your project with your code as usual, with Chartreuse added as dependency in your setup.py. diff --git a/VERSION b/VERSION index ae9a76b..a3fcc71 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0 +7.1.0 diff --git a/docs/multi-database.md b/docs/multi-database.md index d715d1f..c5cbbad 100644 --- a/docs/multi-database.md +++ b/docs/multi-database.md @@ -49,7 +49,7 @@ databases: - `alembic_config_file_path`: Alembic configuration file name **Database connection components (all required):** -- `dialect`: Database dialect (e.g., postgresql, mysql, sqlite) +- `dialect`: Database dialect (e.g., postgresql, mysql, sqlite). Add a driver to force it, e.g. `postgresql+psycopg`; otherwise SQLAlchemy uses its default driver - `user`: Database username - `password`: Database password - `host`: Database host diff --git a/pyproject.toml b/pyproject.toml index 278b9a6..2270bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ classifiers = [ dependencies = [ "alembic", "kubernetes", - "psycopg2", "pydantic>=2.0.0", "PyYAML", ] @@ -35,8 +34,17 @@ where = ["src"] exclude = ["*.tests", "*.tests.*", "tests.*", "tests"] [project.optional-dependencies] +# Chartreuse imports no database driver. SQLAlchemy picks the driver from the dialect: +# "postgresql" means psycopg2 with SQLAlchemy 2.0 and psycopg (3) with SQLAlchemy 2.1. +psycopg2 = [ + "psycopg2", +] +psycopg = [ + "psycopg[binary]", +] test = [ "mock", + "psycopg2", "pytest", "pytest-mock", ] @@ -48,6 +56,7 @@ dev = [ "pip-tools", "mock", + "psycopg2", "pytest", "pytest-mock", ] diff --git a/requirements.txt b/requirements.txt index 77771ea..707a467 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,8 +26,6 @@ markupsafe==3.0.3 # via mako oauthlib==3.3.1 # via requests-oauthlib -psycopg2==2.9.10 - # via chartreuse (pyproject.toml) pyasn1==0.6.1 # via # pyasn1-modules diff --git a/src/chartreuse/config_loader.py b/src/chartreuse/config_loader.py index 85fc50d..754b428 100644 --- a/src/chartreuse/config_loader.py +++ b/src/chartreuse/config_loader.py @@ -6,6 +6,7 @@ import yaml from pydantic import BaseModel, Field, computed_field, field_validator +from sqlalchemy.engine import URL logger = logging.getLogger(__name__) @@ -32,8 +33,18 @@ class DatabaseConfig(BaseModel): @computed_field @property def url(self) -> str: - """Build database URL from components.""" - return f"{self.dialect}://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" + """Build database URL from components. + + URL.create escapes special characters in the user and the password. + """ + return URL.create( + drivername=self.dialect, + username=self.user, + password=self.password, + host=self.host, + port=self.port, + database=self.database, + ).render_as_string(hide_password=False) @field_validator("dialect") @classmethod diff --git a/src/chartreuse/tests/unit_tests/test_alembic.py b/src/chartreuse/tests/unit_tests/test_alembic.py index f478a44..8e9c46b 100644 --- a/src/chartreuse/tests/unit_tests/test_alembic.py +++ b/src/chartreuse/tests/unit_tests/test_alembic.py @@ -1,6 +1,7 @@ import os import tempfile +from alembic.config import Config from pytest_mock.plugin import MockerFixture import chartreuse.utils @@ -401,3 +402,31 @@ def test_single_database_configuration_with_alembic_section(mocker: MockerFixtur assert "postgresql://old@localhost:5432/old" not in content # Verify section is still intact assert "[alembic]" in content + + +def test_configure_escapes_percent_in_url() -> None: + """ + Test that a URL with an escaped password survives the alembic.ini interpolation + """ + sample_alembic_ini = """[postgresql] +script_location = postgresl +sqlalchemy.url = postgresql://wiremind_owner@localhost:5432/wiremind +""" + database_url = "postgresql://user:p%40ss%25@host:5432/db" + + with tempfile.TemporaryDirectory() as temp_dir: + alembic_ini_path = os.path.join(temp_dir, "alembic.ini") + with open(alembic_ini_path, "w") as f: + f.write(sample_alembic_ini) + + chartreuse.utils.AlembicMigrationHelper( + alembic_directory_path=temp_dir, + alembic_config_file_path="alembic.ini", + database_url=database_url, + alembic_section_name="postgresql", + configure=True, + skip_db_checks=True, + ) + + alembic_config = Config(alembic_ini_path, ini_section="postgresql") + assert alembic_config.get_main_option("sqlalchemy.url") == database_url diff --git a/src/chartreuse/tests/unit_tests/test_config_loader.py b/src/chartreuse/tests/unit_tests/test_config_loader.py index 17472c3..6e3789f 100644 --- a/src/chartreuse/tests/unit_tests/test_config_loader.py +++ b/src/chartreuse/tests/unit_tests/test_config_loader.py @@ -9,6 +9,7 @@ import pytest import yaml from pydantic import ValidationError +from sqlalchemy.engine import make_url from chartreuse.config_loader import DatabaseConfig, MultiDatabaseConfig, load_multi_database_config @@ -56,6 +57,24 @@ def test_database_config_url_computation(self): expected_url = "postgresql://myuser:mypass@example.com:5432/mydb" assert config.url == expected_url + def test_database_config_url_escapes_password(self): + """Test that special characters in the password do not break the URL.""" + config = DatabaseConfig( + dialect="postgresql+psycopg", + user="myuser", + password="p@ss:w/rd%", + host="example.com", + port=5432, + database="mydb", + alembic_directory_path="/app/alembic", + ) + + url = make_url(config.url) + assert url.drivername == "postgresql+psycopg" + assert url.password == "p@ss:w/rd%" + assert url.host == "example.com" + assert url.database == "mydb" + def test_database_config_with_optional_fields(self): """Test database configuration with all optional fields set.""" config_data = { diff --git a/src/chartreuse/utils/alembic_migration_helper.py b/src/chartreuse/utils/alembic_migration_helper.py index 57adf3c..29b6651 100755 --- a/src/chartreuse/utils/alembic_migration_helper.py +++ b/src/chartreuse/utils/alembic_migration_helper.py @@ -55,8 +55,9 @@ def _configure(self) -> None: if not config.has_section(self.alembic_section_name): raise ValueError(f"Section '{self.alembic_section_name}' not found in {self.alembic_config_file_path}") - # Update the sqlalchemy.url in the specific section - config.set(self.alembic_section_name, "sqlalchemy.url", self.database_url) + # Update the sqlalchemy.url in the specific section. + # ConfigParser and Alembic interpolate "%", and an escaped password contains "%XX". + config.set(self.alembic_section_name, "sqlalchemy.url", self.database_url.replace("%", "%%")) # Write the updated configuration back to the file with open(config_path, "w") as f: