Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
desaintmartin marked this conversation as resolved.
[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)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
8.0.0
7.1.0
2 changes: 1 addition & 1 deletion docs/multi-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ classifiers = [
dependencies = [
"alembic",
"kubernetes",
"psycopg2",
"pydantic>=2.0.0",
"PyYAML",
]
Expand All @@ -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",
]
Expand All @@ -48,6 +56,7 @@ dev = [
"pip-tools",

"mock",
"psycopg2",
"pytest",
"pytest-mock",
]
Expand Down
2 changes: 0 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/chartreuse/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import yaml
from pydantic import BaseModel, Field, computed_field, field_validator
from sqlalchemy.engine import URL

logger = logging.getLogger(__name__)

Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/chartreuse/tests/unit_tests/test_alembic.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import tempfile

from alembic.config import Config
from pytest_mock.plugin import MockerFixture

import chartreuse.utils
Expand Down Expand Up @@ -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
19 changes: 19 additions & 0 deletions src/chartreuse/tests/unit_tests/test_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand Down
5 changes: 3 additions & 2 deletions src/chartreuse/utils/alembic_migration_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading