From b62d1580d88719c33a0cd1f3bdf4126f00828589 Mon Sep 17 00:00:00 2001 From: Yossi <54272821+Apakottur@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:41:42 +0000 Subject: [PATCH 1/6] experiment: swap psycopg3 for asyncpg on introspection read path Replace synchronous psycopg3 with asyncpg for the database introspection path (src/pgmig/_build/*). build_db_info keeps its sync signature via asyncio.run, so api.py, the CLI, and tests are untouched. The 10 loaders and _run_query become async; connect uses server_settings for the read-only transaction. Bad-DSN/connection failures are surfaced as PgmigError (catching OSError, PostgresError, InterfaceError). asyncpg uses the binary protocol, which decodes the pg internal "char" type as bytes. A non-identity attidentity is byte \x00, which pydantic coerced to the non-empty string '\x00' and broke the identity check. Casting the four returned "char" columns (attidentity, contype, prokind, relkind) to ::text restores the old text-protocol values. Test fixtures stay on psycopg (DDL setup, orthogonal to the read path). Co-Authored-By: Claude Opus 4.8 (1M context) --- linters/cspell/words.txt | 1 + pyproject.toml | 2 + src/pgmig/_build/_core.py | 16 ++--- src/pgmig/_build/_engine.py | 25 +++++-- src/pgmig/_build/constraints.py | 7 +- src/pgmig/_build/enums.py | 7 +- src/pgmig/_build/extensions.py | 7 +- src/pgmig/_build/functions.py | 7 +- src/pgmig/_build/indexes.py | 7 +- src/pgmig/_build/queries/constraints.sql | 2 +- src/pgmig/_build/queries/functions.sql | 2 +- src/pgmig/_build/queries/tables.sql | 2 +- src/pgmig/_build/queries/unsupported.sql | 2 +- src/pgmig/_build/schemas.py | 7 +- src/pgmig/_build/sequences.py | 7 +- src/pgmig/_build/tables.py | 7 +- src/pgmig/_build/triggers.py | 7 +- src/pgmig/_build/unsupported.py | 7 +- uv.lock | 85 ++++++++++++++++++++++++ 19 files changed, 147 insertions(+), 60 deletions(-) diff --git a/linters/cspell/words.txt b/linters/cspell/words.txt index f0b33318..c19bf5c6 100644 --- a/linters/cspell/words.txt +++ b/linters/cspell/words.txt @@ -27,6 +27,7 @@ pycache pyproject softprops psycopg +asyncpg dataclass dataclasses conftest diff --git a/pyproject.toml b/pyproject.toml index c98e59c3..769dcfd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ requires = ["hatchling", "hatch-vcs"] [dependency-groups] dev = [ + "asyncpg-stubs>=0.31.3", "httpx==0.28.1", "mypy==2.2.0", "pre-commit==4.6.0", @@ -41,6 +42,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ + "asyncpg>=0.31.0", # "psycopg[binary]>=3.3.4", "pydantic>=2", diff --git a/src/pgmig/_build/_core.py b/src/pgmig/_build/_core.py index 38b0bb58..24940106 100644 --- a/src/pgmig/_build/_core.py +++ b/src/pgmig/_build/_core.py @@ -1,26 +1,24 @@ from pathlib import Path -from typing import Any, Protocol, TypeVar, cast +from typing import Protocol, TypeVar -import psycopg -from psycopg.rows import class_row +import asyncpg from pydantic import BaseModel -from typing_extensions import LiteralString from pgmig._models import DbInfo _RowT = TypeVar("_RowT", bound=BaseModel) -def _run_query(conn: psycopg.Connection[Any], file_name: str, model: type[_RowT]) -> list[_RowT]: +async def _run_query(conn: asyncpg.Connection, file_name: str, model: type[_RowT]) -> list[_RowT]: """ Load a bundled SQL query from the queries directory, run it, and parse each row into the given Pydantic model (by SELECT column alias). Validation happens at parse time, so a schema/type drift surfaces here rather than silently downstream. """ file_path = Path(__file__).parent.joinpath("queries").joinpath(file_name) - query = cast("LiteralString", file_path.read_text(encoding="utf-8")) # type: ignore[redundant-cast] - with conn.cursor(row_factory=class_row(model)) as cur: - return cur.execute(query).fetchall() + query = file_path.read_text(encoding="utf-8") + records = await conn.fetch(query) + return [model(**dict(record)) for record in records] class Loader(Protocol): @@ -30,4 +28,4 @@ class Loader(Protocol): and tables before the objects that attach to them). """ - def __call__(self, conn: psycopg.Connection[Any], db_info: DbInfo) -> None: ... + async def __call__(self, conn: asyncpg.Connection, db_info: DbInfo) -> None: ... diff --git a/src/pgmig/_build/_engine.py b/src/pgmig/_build/_engine.py index 685f756d..36e86f26 100644 --- a/src/pgmig/_build/_engine.py +++ b/src/pgmig/_build/_engine.py @@ -1,4 +1,6 @@ -import psycopg +import asyncio + +import asyncpg from pgmig._build import ( constraints, @@ -35,18 +37,27 @@ ) -def build_db_info(dsn: str) -> DbInfo: +async def _build_db_info(dsn: str) -> DbInfo: """ - Build the full structure of the given database. + Build the full structure of the given database (async core). """ # Open the connection, surfacing connection failures as a clean PgmigError. try: - conn = psycopg.connect(dsn, options="-c default_transaction_read_only=on") - except psycopg.Error as error: + conn = await asyncpg.connect(dsn, server_settings={"default_transaction_read_only": "on"}) + except (OSError, asyncpg.PostgresError, asyncpg.InterfaceError) as error: raise PgmigError(f"Could not connect to database: {error}") from error db_info = DbInfo(schema_by_name={}, extension_by_name={}) - with conn: + try: for load in _LOADERS: - load(conn, db_info) + await load(conn, db_info) + finally: + await conn.close() return db_info + + +def build_db_info(dsn: str) -> DbInfo: + """ + Build the full structure of the given database. + """ + return asyncio.run(_build_db_info(dsn)) diff --git a/src/pgmig/_build/constraints.py b/src/pgmig/_build/constraints.py index a255c03e..aed42fa0 100644 --- a/src/pgmig/_build/constraints.py +++ b/src/pgmig/_build/constraints.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -17,12 +16,12 @@ class _ConstraintRow(BaseModel): con_comment: str | None -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Constraints (primary key, unique, and check). Foreign keys are routed to their own bucket on the table. """ - for con_row in _run_query(conn, "constraints.sql", _ConstraintRow): + for con_row in await _run_query(conn, "constraints.sql", _ConstraintRow): constraint = Constraint( name=con_row.con_name, definition=con_row.con_def, diff --git a/src/pgmig/_build/enums.py b/src/pgmig/_build/enums.py index 2811fe1e..0eb8ecf5 100644 --- a/src/pgmig/_build/enums.py +++ b/src/pgmig/_build/enums.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -14,11 +13,11 @@ class _EnumRow(BaseModel): enum_comment: str | None -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Enum types (user enums only; extension-owned ones are excluded). """ - for enum_row in _run_query(conn, "enums.sql", _EnumRow): + for enum_row in await _run_query(conn, "enums.sql", _EnumRow): db_info.schema_by_name[enum_row.schema_name].enum_by_name[enum_row.enum_name] = EnumType( name=enum_row.enum_name, values=enum_row.enum_values, comment=enum_row.enum_comment ) diff --git a/src/pgmig/_build/extensions.py b/src/pgmig/_build/extensions.py index e210ff81..de40f83d 100644 --- a/src/pgmig/_build/extensions.py +++ b/src/pgmig/_build/extensions.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -13,11 +12,11 @@ class _ExtensionRow(BaseModel): extension_schema: str -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Extensions (database-level). """ - for ext_row in _run_query(conn, "extensions.sql", _ExtensionRow): + for ext_row in await _run_query(conn, "extensions.sql", _ExtensionRow): db_info.extension_by_name[ext_row.name] = Extension( name=ext_row.name, version=ext_row.version, schema=ext_row.extension_schema ) diff --git a/src/pgmig/_build/functions.py b/src/pgmig/_build/functions.py index 06159b10..17c46be7 100644 --- a/src/pgmig/_build/functions.py +++ b/src/pgmig/_build/functions.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -18,11 +17,11 @@ class _FunctionRow(BaseModel): func_has_dependents: bool -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Functions and procedures (excluding aggregates, window functions, and extension-owned ones). """ - for func_row in _run_query(conn, "functions.sql", _FunctionRow): + for func_row in await _run_query(conn, "functions.sql", _FunctionRow): signature = f"{func_row.func_name}({func_row.func_args})" db_info.schema_by_name[func_row.schema_name].function_by_signature[signature] = Function( name=func_row.func_name, diff --git a/src/pgmig/_build/indexes.py b/src/pgmig/_build/indexes.py index 81186b45..a11b6aff 100644 --- a/src/pgmig/_build/indexes.py +++ b/src/pgmig/_build/indexes.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -16,11 +15,11 @@ class _IndexRow(BaseModel): index_comment: str | None -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Indexes (standalone only; constraint-backed indexes are excluded). """ - for index_row in _run_query(conn, "indexes.sql", _IndexRow): + for index_row in await _run_query(conn, "indexes.sql", _IndexRow): table = db_info.schema_by_name[index_row.schema_name].table_by_name[index_row.table_name] table.index_by_name[index_row.index_name] = Index( name=index_row.index_name, diff --git a/src/pgmig/_build/queries/constraints.sql b/src/pgmig/_build/queries/constraints.sql index daa15a19..738a2b4d 100644 --- a/src/pgmig/_build/queries/constraints.sql +++ b/src/pgmig/_build/queries/constraints.sql @@ -4,7 +4,7 @@ SELECT c.relname AS table_name, con.conname AS con_name, pg_get_constraintdef(con.oid) AS con_def, - con.contype AS con_type, + con.contype::text AS con_type, ( SELECT array_agg(a.attname ORDER BY k.ord) diff --git a/src/pgmig/_build/queries/functions.sql b/src/pgmig/_build/queries/functions.sql index c2c8dd55..be15c51f 100644 --- a/src/pgmig/_build/queries/functions.sql +++ b/src/pgmig/_build/queries/functions.sql @@ -5,7 +5,7 @@ SELECT pg_get_function_identity_arguments(p.oid) AS func_args, pg_get_functiondef(p.oid) AS func_def, format_type(p.prorettype, NULL) AS func_rettype, - p.prokind AS func_kind, + p.prokind::text AS func_kind, obj_description(p.oid, 'pg_proc') AS func_comment, EXISTS ( SELECT diff --git a/src/pgmig/_build/queries/tables.sql b/src/pgmig/_build/queries/tables.sql index a9186da8..14e0e951 100644 --- a/src/pgmig/_build/queries/tables.sql +++ b/src/pgmig/_build/queries/tables.sql @@ -8,7 +8,7 @@ SELECT pg_get_expr(ad.adbin, ad.adrelid) AS column_default, col_description(a.attrelid, a.attnum) AS column_comment, obj_description(c.oid, 'pg_class') AS table_comment, - a.attidentity AS column_identity, + a.attidentity::text AS column_identity, pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(c.relname), a.attname) AS column_serial_sequence FROM pg_class c diff --git a/src/pgmig/_build/queries/unsupported.sql b/src/pgmig/_build/queries/unsupported.sql index ad02f820..4fb04cf5 100644 --- a/src/pgmig/_build/queries/unsupported.sql +++ b/src/pgmig/_build/queries/unsupported.sql @@ -6,7 +6,7 @@ SELECT n.nspname AS schema_name, c.relname AS rel_name, - c.relkind AS rel_kind + c.relkind::text AS rel_kind FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace diff --git a/src/pgmig/_build/schemas.py b/src/pgmig/_build/schemas.py index b6b517a0..90f95497 100644 --- a/src/pgmig/_build/schemas.py +++ b/src/pgmig/_build/schemas.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -12,11 +11,11 @@ class _SchemaRow(BaseModel): schema_comment: str | None -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Schemas (user namespaces, excluding system and extension-owned ones). """ - for schema_row in _run_query(conn, "schemas.sql", _SchemaRow): + for schema_row in await _run_query(conn, "schemas.sql", _SchemaRow): db_info.schema_by_name[schema_row.schema_name] = Schema( name=schema_row.schema_name, comment=schema_row.schema_comment, diff --git a/src/pgmig/_build/sequences.py b/src/pgmig/_build/sequences.py index bf67f1af..fc24293e 100644 --- a/src/pgmig/_build/sequences.py +++ b/src/pgmig/_build/sequences.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -20,11 +19,11 @@ class _SequenceRow(BaseModel): seq_comment: str | None -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Sequences (standalone only; sequences owned by a serial/identity column are excluded). """ - for seq_row in _run_query(conn, "sequences.sql", _SequenceRow): + for seq_row in await _run_query(conn, "sequences.sql", _SequenceRow): db_info.schema_by_name[seq_row.schema_name].sequence_by_name[seq_row.seq_name] = Sequence( name=seq_row.seq_name, data_type=seq_row.seq_type, diff --git a/src/pgmig/_build/tables.py b/src/pgmig/_build/tables.py index 17634552..91e913b9 100644 --- a/src/pgmig/_build/tables.py +++ b/src/pgmig/_build/tables.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -20,11 +19,11 @@ class _TableRow(BaseModel): column_serial_sequence: str | None -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Tables (and their columns, in physical order). """ - for table_row in _run_query(conn, "tables.sql", _TableRow): + for table_row in await _run_query(conn, "tables.sql", _TableRow): schema = db_info.schema_by_name[table_row.schema_name] if table_row.table_name not in schema.table_by_name: schema.table_by_name[table_row.table_name] = Table( diff --git a/src/pgmig/_build/triggers.py b/src/pgmig/_build/triggers.py index 37df8b21..84babf08 100644 --- a/src/pgmig/_build/triggers.py +++ b/src/pgmig/_build/triggers.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -16,11 +15,11 @@ class _TriggerRow(BaseModel): trigger_comment: str | None -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Triggers (user triggers only; internal RI/constraint-backing triggers are excluded). """ - for trigger_row in _run_query(conn, "triggers.sql", _TriggerRow): + for trigger_row in await _run_query(conn, "triggers.sql", _TriggerRow): table = db_info.schema_by_name[trigger_row.schema_name].table_by_name[trigger_row.table_name] table.trigger_by_name[trigger_row.trigger_name] = Trigger( name=trigger_row.trigger_name, diff --git a/src/pgmig/_build/unsupported.py b/src/pgmig/_build/unsupported.py index aa540a6d..b23af0e1 100644 --- a/src/pgmig/_build/unsupported.py +++ b/src/pgmig/_build/unsupported.py @@ -1,6 +1,5 @@ -from typing import Any -import psycopg +import asyncpg from pydantic import BaseModel from pgmig._build._core import _run_query @@ -21,14 +20,14 @@ class _UnsupportedRow(BaseModel): rel_kind: str -def load(conn: psycopg.Connection[Any], db_info: DbInfo) -> None: +async def load(conn: asyncpg.Connection, db_info: DbInfo) -> None: """ Guard: reject relation kinds that are not modelled yet (views, materialized views, partitioned tables, foreign tables). Without this, generate() diffs only regular tables and returns "" for a database whose whole relations are missing on one side, falsely claiming convergence. Consistent with raising on unsupported column changes. """ - rows = _run_query(conn, "unsupported.sql", _UnsupportedRow) + rows = await _run_query(conn, "unsupported.sql", _UnsupportedRow) if rows: row = rows[0] kind = _RELKIND_NAMES.get(row.rel_kind, row.rel_kind) diff --git a/uv.lock b/uv.lock index 46050a87..5c1b56f2 100644 --- a/uv.lock +++ b/uv.lock @@ -79,6 +79,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "asyncpg-stubs" +version = "0.31.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asyncpg" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/9e/562ab47de58afc6fa4e34530c0ed49343e2c7623a578cccbcb076fa0c8e8/asyncpg_stubs-0.31.3.tar.gz", hash = "sha256:37968e766d1e63a4993084ee1ed897e49a6c06a031767b2a3473b4ab81618b38", size = 20685, upload-time = "2026-07-10T00:18:19.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/f9/08d8ce5d312e05f3c53368ce993d2f6346519d212e8801fa9c5cdbd299a3/asyncpg_stubs-0.31.3-py3-none-any.whl", hash = "sha256:1ca28026dd36808363ece589fc3cd047f2ef1d431b18d8dd6bd9a36d539de26e", size = 27631, upload-time = "2026-07-10T00:18:20.154Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -511,6 +592,7 @@ wheels = [ name = "pgmig" source = { editable = "." } dependencies = [ + { name = "asyncpg" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "typer" }, @@ -518,6 +600,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "asyncpg-stubs" }, { name = "httpx" }, { name = "mypy" }, { name = "pre-commit" }, @@ -533,6 +616,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "asyncpg", specifier = ">=0.31.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "pydantic", specifier = ">=2" }, { name = "typer", specifier = ">=0.12" }, @@ -540,6 +624,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "asyncpg-stubs", specifier = ">=0.31.3" }, { name = "httpx", specifier = "==0.28.1" }, { name = "mypy", specifier = "==2.2.0" }, { name = "pre-commit", specifier = "==4.6.0" }, From 7b5dfbe0550cf870c6e85cc0ae805408361d5ec1 Mon Sep 17 00:00:00 2001 From: Yossi <54272821+Apakottur@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:40:29 +0000 Subject: [PATCH 2/6] wip: dual-driver introspection (psycopg + asyncpg) match driver in _db.py, thread driver arg through api/cli/engine, --driver pytest flag, both drivers in dev deps, cast char catalog cols to text. Both driver suites pass (316 each). Lint/type-check + packaging default TBD. --- pyproject.toml | 13 +- src/pgmig/_api.py | 10 +- src/pgmig/_cli.py | 10 ++ src/pgmig/_db.py | 118 +++++++++++++----- src/pgmig/_introspect/_engine.py | 12 +- .../queries/matview_dependencies.sql | 4 +- src/pgmig/_introspect/queries/tables.sql | 2 +- tests/_api/generate_setup.py | 8 +- tests/_introspect/test_pgbouncer.py | 2 +- .../test_view_dependency_filters.py | 2 +- tests/conftest.py | 44 ++++++- tests/fixtures/db_utils.py | 6 +- tests/pytest.ini | 3 + uv.lock | 80 +----------- 14 files changed, 175 insertions(+), 139 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3560291..a4dd0659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,8 @@ requires = ["hatchling", "hatch-vcs"] [dependency-groups] dev = [ + # Both drivers live here while we benchmark them; the base-package driver + extras are TBD. + "asyncpg>=0.31.0", "asyncpg-stubs>=0.31.3", "httpx==0.28.1", "mypy==2.3.0", @@ -43,20 +45,13 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] +# No database driver is a hard dependency yet: the default driver and the psycopg/asyncpg +# install extras are decided after benchmarking. Both drivers are in the dev group meanwhile. dependencies = [ - "asyncpg>=0.31.0", - # - "psycopg>=3.3.4", "pydantic>=2", "typer>=0.12", ] -[project.optional-dependencies] -binary = [ - # - "psycopg[binary]>=3.3.4", -] - [project.scripts] pgmig = "pgmig._cli:app" diff --git a/src/pgmig/_api.py b/src/pgmig/_api.py index 01d4a6da..321d5a9c 100644 --- a/src/pgmig/_api.py +++ b/src/pgmig/_api.py @@ -1,6 +1,7 @@ import asyncio from collections.abc import Sequence +from pgmig._db import DEFAULT_DRIVER, Driver from pgmig._diff._engine import get_diff from pgmig._introspect._engine import introspect_db @@ -12,6 +13,7 @@ async def agenerate( index_concurrently: bool = False, ignore_extension_version: Sequence[str] = (), ignore_owner: bool = False, + driver: Driver = DEFAULT_DRIVER, ) -> str: """ Asynchronous equivalent of [`generate`][pgmig.generate]. @@ -25,9 +27,12 @@ async def agenerate( ignore_extension_version: Names of extensions whose version mismatch is ignored: no ALTER EXTENSION ... UPDATE TO is emitted for them. Empty (default) ignores none. ignore_owner: Suppress all ALTER ... OWNER TO statements. + driver: The database driver to use for introspection ("psycopg" or "asyncpg"). """ # Introspect both databases concurrently. - source_result, target_result = await asyncio.gather(introspect_db(source), introspect_db(target)) + source_result, target_result = await asyncio.gather( + introspect_db(source, driver), introspect_db(target, driver) + ) # Generate migration SQL. return get_diff( @@ -46,6 +51,7 @@ def generate( index_concurrently: bool = False, ignore_extension_version: Sequence[str] = (), ignore_owner: bool = False, + driver: Driver = DEFAULT_DRIVER, ) -> str: """ Generate the migration SQL between the given source and target databases. @@ -59,6 +65,7 @@ def generate( ignore_extension_version: Names of extensions whose version mismatch is ignored: no ALTER EXTENSION ... UPDATE TO is emitted for them. Empty (default) ignores none. ignore_owner: Suppress all ALTER ... OWNER TO statements. + driver: The database driver to use for introspection ("psycopg" or "asyncpg"). """ return asyncio.run( agenerate( @@ -67,5 +74,6 @@ def generate( index_concurrently=index_concurrently, ignore_extension_version=ignore_extension_version, ignore_owner=ignore_owner, + driver=driver, ) ) diff --git a/src/pgmig/_cli.py b/src/pgmig/_cli.py index 89f37cc0..6a164769 100644 --- a/src/pgmig/_cli.py +++ b/src/pgmig/_cli.py @@ -6,6 +6,7 @@ import typer from pgmig._api import generate as generate_migration +from pgmig._db import DEFAULT_DRIVER, Driver from pgmig._errors import _PgmigError app = typer.Typer( @@ -90,6 +91,14 @@ def generate( help="Suppress all ALTER ... OWNER TO statements.", ), ] = False, + driver: Annotated[ + Driver, + typer.Option( + "--driver", + envvar="PGMIG_DRIVER", + help="Database driver to use for introspection.", + ), + ] = DEFAULT_DRIVER, ) -> None: """ Generate the migration SQL that turns the source database into the target database. @@ -106,6 +115,7 @@ def generate( index_concurrently=index_concurrently, ignore_extension_version=ignore_extension_version or [], ignore_owner=ignore_owner, + driver=driver, ) except _PgmigError as error: # Expected error - print message without traceback. diff --git a/src/pgmig/_db.py b/src/pgmig/_db.py index 36e55186..9d098e84 100644 --- a/src/pgmig/_db.py +++ b/src/pgmig/_db.py @@ -1,16 +1,23 @@ +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any, TypeVar +import asyncpg import psycopg from psycopg.rows import class_row from pydantic import BaseModel -from typing_extensions import Self +from typing_extensions import Literal, Self from pgmig._errors import _PgmigError _RowT = TypeVar("_RowT", bound=BaseModel) +# The database driver to use. No default is committed yet: while we benchmark the two drivers the +# caller picks one explicitly (see DEFAULT_DRIVER, a temporary choice that is free to change). +Driver = Literal["psycopg", "asyncpg"] +DEFAULT_DRIVER: Driver = "psycopg" + class UniqueViolation(Exception): """ @@ -24,38 +31,77 @@ class DbConnection: All DB interaction is done through this class to avoid the DB driver leaking into other modules. """ - def __init__(self, *, dsn: str, conn: psycopg.AsyncConnection[Any]) -> None: + def __init__(self, *, dsn: str, driver: Driver, conn: Any) -> None: self.dsn = dsn + self.driver = driver self.driver_conn = conn @classmethod @asynccontextmanager - async def connect(cls, *, dsn: str, auto_commit: bool = True) -> AsyncIterator[Self]: + async def connect(cls, *, dsn: str, driver: Driver = DEFAULT_DRIVER) -> AsyncIterator[Self]: """ Connection context. """ - try: - conn = await psycopg.AsyncConnection.connect(dsn, autocommit=auto_commit) - except psycopg.Error as error: - raise _PgmigError(f"Could not connect to database: {error}") from error - - async with conn: - yield cls(dsn=dsn, conn=conn) + match driver: + case "psycopg": + try: + conn = await psycopg.AsyncConnection.connect(dsn, autocommit=True) + except psycopg.Error as error: + raise _PgmigError(f"Could not connect to database: {error}") from error + + async with conn: + yield cls(dsn=dsn, driver=driver, conn=conn) + + case "asyncpg": + try: + conn = await asyncpg.connect(dsn) + except (OSError, asyncpg.PostgresError, asyncpg.InterfaceError) as error: + raise _PgmigError(f"Could not connect to database: {error}") from error + + # asyncpg returns json/jsonb as raw text by default; decode so queries that build + # nested jsonb objects (domains, composite types, functions) parse into their models. + for type_name in ("json", "jsonb"): + await conn.set_type_codec( + type_name, encoder=json.dumps, decoder=json.loads, schema="pg_catalog" + ) + + try: + yield cls(dsn=dsn, driver=driver, conn=conn) + finally: + await conn.close() async def execute(self, statement: str) -> list[tuple[Any, ...]]: """ Execute a statement and return the statement results, if any. """ - # Execute the statement. - try: - result = await self.driver_conn.execute(statement) # ty: ignore[no-matching-overload] - except psycopg.errors.UniqueViolation as error: - raise UniqueViolation(str(error)) from error - - # Fetch and return the results, if any. - if result.description: - return await result.fetchall() - return [] + # An empty statement is a no-op. psycopg tolerates it; asyncpg raises on an empty query. + if not statement.strip(): + return [] + + match self.driver: + case "psycopg": + try: + result = await self.driver_conn.execute(statement) + except psycopg.errors.UniqueViolation as error: + raise UniqueViolation(str(error)) from error + + if result.description: + return await result.fetchall() + return [] + + case "asyncpg": + # asyncpg splits protocols: fetch() (extended) returns rows but forbids multiple + # statements and non-transactionable commands; execute() (simple) allows those but + # returns no rows. Reads are always single SELECT-likes; everything else is DDL. + is_read = statement.lstrip().upper().startswith(("SELECT", "WITH", "SHOW", "VALUES", "TABLE")) + try: + if is_read: + records = await self.driver_conn.fetch(statement) + return [tuple(record) for record in records] + await self.driver_conn.execute(statement) + return [] + except asyncpg.UniqueViolationError as error: + raise UniqueViolation(str(error)) from error class DbReadOnlyConnection(DbConnection): @@ -65,20 +111,26 @@ class DbReadOnlyConnection(DbConnection): @classmethod @asynccontextmanager - async def connect(cls, *, dsn: str, auto_commit: bool = True) -> AsyncIterator[Self]: + async def connect(cls, *, dsn: str, driver: Driver = DEFAULT_DRIVER) -> AsyncIterator[Self]: """ Read-only connection context. """ - async with super().connect(dsn=dsn, auto_commit=auto_commit) as conn: - # Force all subsequent transactions to be read-only. - await conn.driver_conn.set_read_only(True) + async with super().connect(dsn=dsn, driver=driver) as conn: + match driver: + case "psycopg": + # Force all subsequent transactions to be read-only. + await conn.driver_conn.set_read_only(True) + + # Use REPEATABLE READ so all introspection reads a single snapshot of the database. + await conn.driver_conn.set_isolation_level(psycopg.IsolationLevel.REPEATABLE_READ) - # Use REPEATABLE READ so that all introspection is done on a single snapshot of the database. - await conn.driver_conn.set_isolation_level(psycopg.IsolationLevel.REPEATABLE_READ) + case "asyncpg": + await conn.driver_conn.execute("SET default_transaction_read_only = on") + await conn.driver_conn.execute("SET default_transaction_isolation = 'repeatable read'") # Use an empty search path so introspection is independent of the database's own search # path: pg_get_*def()/format_type() then emit fully schema-qualified names. - await conn.driver_conn.execute("SET search_path = ''") + await conn.execute("SET search_path = ''") yield conn @@ -86,6 +138,12 @@ async def introspect(self, query: str, response_model: type[_RowT]) -> list[_Row """ Run an introspection query and parse each row into the given model. """ - async with self.driver_conn.cursor(row_factory=class_row(response_model)) as cur: - await cur.execute(query) # ty: ignore[no-matching-overload] - return await cur.fetchall() + match self.driver: + case "psycopg": + async with self.driver_conn.cursor(row_factory=class_row(response_model)) as cur: + await cur.execute(query) + return await cur.fetchall() + + case "asyncpg": + records = await self.driver_conn.fetch(query) + return [response_model.model_validate(dict(record)) for record in records] diff --git a/src/pgmig/_introspect/_engine.py b/src/pgmig/_introspect/_engine.py index 9fc98471..60db38bb 100644 --- a/src/pgmig/_introspect/_engine.py +++ b/src/pgmig/_introspect/_engine.py @@ -1,4 +1,4 @@ -from pgmig._db import DbReadOnlyConnection +from pgmig._db import DEFAULT_DRIVER, DbReadOnlyConnection, Driver from pgmig._errors import PgmigUnsupportedError from pgmig._introspect import ( composite_types, @@ -56,7 +56,7 @@ ) -async def introspect_db(dsn: str) -> DbIntrospectionResult: +async def introspect_db(dsn: str, driver: Driver = DEFAULT_DRIVER) -> DbIntrospectionResult: """ Build the full structure of the given database. """ @@ -67,15 +67,13 @@ async def introspect_db(dsn: str) -> DbIntrospectionResult: view_column_dependencies={}, ) - async with DbReadOnlyConnection.connect(dsn=dsn) as conn: - # Run within the introspection context. + async with DbReadOnlyConnection.connect(dsn=dsn, driver=driver) as conn: + # Run within the introspection context. The connection already runs read-only at REPEATABLE + # READ with an empty search path (set in DbReadOnlyConnection.connect). with context.context_scope( conn=conn, db_introspection_result=db_introspection_result, ): - # Use an empty search path to make introspection independent of the database's own search path. - await conn.execute("SET LOCAL search_path = ''") - # Get all the unsupported findings. all_findings = [finding for guard in _UNSUPPORTED_GUARDS for finding in await guard()] if all_findings: diff --git a/src/pgmig/_introspect/queries/matview_dependencies.sql b/src/pgmig/_introspect/queries/matview_dependencies.sql index e345ad3f..585ecd74 100644 --- a/src/pgmig/_introspect/queries/matview_dependencies.sql +++ b/src/pgmig/_introspect/queries/matview_dependencies.sql @@ -7,10 +7,10 @@ SELECT DISTINCT dependent_ns.nspname AS dependent_schema, dependent.relname AS dependent_view, - dependent.relkind AS dependent_kind, + dependent.relkind::text AS dependent_kind, referenced_ns.nspname AS referenced_schema, referenced.relname AS referenced_view, - referenced.relkind AS referenced_kind + referenced.relkind::text AS referenced_kind FROM pg_depend d JOIN pg_rewrite r ON r.oid = d.objid diff --git a/src/pgmig/_introspect/queries/tables.sql b/src/pgmig/_introspect/queries/tables.sql index 0697cc92..94975620 100644 --- a/src/pgmig/_introspect/queries/tables.sql +++ b/src/pgmig/_introspect/queries/tables.sql @@ -18,7 +18,7 @@ SELECT obj_description(c.oid, 'pg_class') AS table_comment, pg_get_userbyid(c.relowner) AS table_owner, a.attidentity::text AS column_identity, - a.attgenerated AS column_generated, + a.attgenerated::text AS column_generated, pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(c.relname), a.attname) AS column_serial_sequence, -- Partitioning metadata (per table, repeated on every column row; the loader reads -- it once when it first creates the table). diff --git a/tests/_api/generate_setup.py b/tests/_api/generate_setup.py index ca75248f..457555e1 100644 --- a/tests/_api/generate_setup.py +++ b/tests/_api/generate_setup.py @@ -1,7 +1,7 @@ import pytest from pgmig import PgmigUnsupportedError, agenerate -from pgmig._db import DbConnection +from pgmig._db import DbConnection, Driver class GenerateSetup: @@ -17,6 +17,7 @@ def __init__( src_conn: DbConnection, dst_conn: DbConnection, pg_major: int, + driver: Driver, unique_key: str, ) -> None: # Database names. @@ -30,6 +31,9 @@ def __init__( # Postgres major version. self.pg_major = pg_major + # Database driver under test. + self.driver = driver + # Unique key for the test session. self.unique_key = unique_key @@ -80,6 +84,7 @@ async def assert_diff( target=self.dst.dsn, index_concurrently=index_concurrently, ignore_owner=ignore_owner, + driver=self.driver, ) # Verify the result. @@ -94,6 +99,7 @@ async def assert_diff( target=self.dst.dsn, index_concurrently=index_concurrently, ignore_owner=ignore_owner, + driver=self.driver, ) assert residual == "", f"\nMigration did not make source match target.\nResidual diff:\n{residual}" diff --git a/tests/_introspect/test_pgbouncer.py b/tests/_introspect/test_pgbouncer.py index 510ab870..bc9d065d 100644 --- a/tests/_introspect/test_pgbouncer.py +++ b/tests/_introspect/test_pgbouncer.py @@ -12,7 +12,7 @@ async def test_introspection_through_pgbouncer(gen_setup: GenerateSetup) -> None # Introspect the database through pgbouncer. pgbouncer_dsn = get_dsn(gen_setup.src_db_name, pgbouncer=True) - info = await introspect_db(dsn=pgbouncer_dsn) + info = await introspect_db(pgbouncer_dsn, gen_setup.driver) # Verify the introspection result. assert "widget" in info.schema_by_name["public"].table_by_name diff --git a/tests/_introspect/test_view_dependency_filters.py b/tests/_introspect/test_view_dependency_filters.py index 19d6ee6d..7a379fa2 100644 --- a/tests/_introspect/test_view_dependency_filters.py +++ b/tests/_introspect/test_view_dependency_filters.py @@ -13,7 +13,7 @@ async def test_view_dependencies_exclude_system_and_extension_referenced_views(g await gen_setup.src.execute("CREATE VIEW v_sys AS SELECT pid FROM pg_stat_activity") await gen_setup.src.execute("CREATE VIEW v_ext AS SELECT userid FROM pg_stat_statements") - info = await introspect_db(gen_setup.src.dsn) + info = await introspect_db(gen_setup.src.dsn, gen_setup.driver) # Every referenced view across all recorded edges lives in a managed (non-system) schema. referenced = {ref for refs in info.view_dependencies.values() for ref in refs} diff --git a/tests/conftest.py b/tests/conftest.py index 435c74a8..306e0937 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,12 @@ import pytest import shpyx -from pgmig._db import DbConnection +from pgmig._db import DEFAULT_DRIVER, DbConnection, Driver from tests._api.generate_setup import GenerateSetup from tests.fixtures.db_utils import get_dsn, get_unique_postgres_name, recreate_database, wait_for_db_connection +_VALID_DRIVERS = ("psycopg", "asyncpg") + _COMPOSE_FILE_DIR = Path(__file__).parent @@ -31,6 +33,16 @@ def pytest_addoption(parser: pytest.Parser) -> None: "Falls back to the PGMIG_TEST_PG_VERSION env var, then 18." ), ) + parser.addoption( + "--driver", + action="store", + default=None, + choices=_VALID_DRIVERS, + help=( + f"Database driver to test against ({' or '.join(_VALID_DRIVERS)}). " + f"Falls back to the PGMIG_DRIVER env var, then {DEFAULT_DRIVER!r}." + ), + ) @pytest.fixture(scope="session") @@ -67,7 +79,24 @@ def _pg_major(request: pytest.FixtureRequest) -> int: @pytest.fixture(scope="session") -async def _admin_conn(request: pytest.FixtureRequest) -> AsyncIterator[DbConnection]: +def _driver(request: pytest.FixtureRequest) -> Driver: + """ + Get the database driver under test ("psycopg" or "asyncpg"). + """ + config_option = request.config.getoption("--driver") + env_var = os.environ.get("PGMIG_DRIVER") + + # Resolve the final driver: --driver, then PGMIG_DRIVER, then the default. + final_driver: Driver = config_option or env_var or DEFAULT_DRIVER + + # Export it so CLI invocations (which read PGMIG_DRIVER) use the same driver. + os.environ["PGMIG_DRIVER"] = final_driver + + return final_driver + + +@pytest.fixture(scope="session") +async def _admin_conn(request: pytest.FixtureRequest, _driver: Driver) -> AsyncIterator[DbConnection]: """ Session level database server plus a shared connection to the admin database, reused to (re)create the per-test databases. @@ -79,10 +108,10 @@ async def _admin_conn(request: pytest.FixtureRequest) -> AsyncIterator[DbConnect admin_db_dsn = get_dsn("postgres") # Wait for the database server to be ready. - await wait_for_db_connection(dsn=admin_db_dsn) + await wait_for_db_connection(dsn=admin_db_dsn, driver=_driver) # Open a single connection to the admin database for the whole session. - async with DbConnection.connect(dsn=admin_db_dsn) as admin_conn: + async with DbConnection.connect(dsn=admin_db_dsn, driver=_driver) as admin_conn: yield admin_conn # Stop the database server, unless asked to leave it running. @@ -95,6 +124,7 @@ async def _admin_conn(request: pytest.FixtureRequest) -> AsyncIterator[DbConnect async def gen_setup( _unique_key: str, _pg_major: int, + _driver: Driver, _admin_conn: DbConnection, ) -> AsyncIterator[GenerateSetup]: """ @@ -111,12 +141,16 @@ async def gen_setup( await recreate_database(_admin_conn, dst_db_name) # Create DB connections and yield for the test. - async with DbConnection.connect(dsn=src_db_dsn) as src_conn, DbConnection.connect(dsn=dst_db_dsn) as dst_conn: + async with ( + DbConnection.connect(dsn=src_db_dsn, driver=_driver) as src_conn, + DbConnection.connect(dsn=dst_db_dsn, driver=_driver) as dst_conn, + ): yield GenerateSetup( src_db_name=src_db_name, dst_db_name=dst_db_name, src_conn=src_conn, dst_conn=dst_conn, pg_major=_pg_major, + driver=_driver, unique_key=_unique_key, ) diff --git a/tests/fixtures/db_utils.py b/tests/fixtures/db_utils.py index 2ee1093a..504cfc71 100644 --- a/tests/fixtures/db_utils.py +++ b/tests/fixtures/db_utils.py @@ -3,7 +3,7 @@ import tenacity -from pgmig._db import DbConnection +from pgmig._db import DEFAULT_DRIVER, DbConnection, Driver _DSN_PREFIX = "postgresql://pgmig:pgmig@localhost:15432" _PGBOUNCER_DSN_PREFIX = "postgresql://pgmig:pgmig@localhost:16432" @@ -50,11 +50,11 @@ def get_dsn(db_name: str, *, pgbouncer: bool = False) -> str: stop=tenacity.stop_after_delay(10), reraise=True, ) -async def wait_for_db_connection(*, dsn: str) -> None: +async def wait_for_db_connection(*, dsn: str, driver: Driver = DEFAULT_DRIVER) -> None: """ Wait for a database to be ready to accept connections. """ - async with DbConnection.connect(dsn=dsn): + async with DbConnection.connect(dsn=dsn, driver=driver): pass diff --git a/tests/pytest.ini b/tests/pytest.ini index eeac3d59..8f2a6e1a 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -28,7 +28,10 @@ addopts = # Asyncio configuration. # https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html +# Share one event loop across fixtures and tests: asyncpg binds connections to the loop that +# created them, so session-scoped connection fixtures must run in the same loop as the tests. asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session asyncio_mode = auto # Configuration of the underlying coverage.py application diff --git a/uv.lock b/uv.lock index 6bdde569..373221c2 100644 --- a/uv.lock +++ b/uv.lock @@ -601,19 +601,13 @@ wheels = [ name = "pgmig" source = { editable = "." } dependencies = [ - { name = "asyncpg" }, - { name = "psycopg" }, { name = "pydantic" }, { name = "typer" }, ] -[package.optional-dependencies] -binary = [ - { name = "psycopg", extra = ["binary"] }, -] - [package.dev-dependencies] dev = [ + { name = "asyncpg" }, { name = "asyncpg-stubs" }, { name = "httpx" }, { name = "mypy" }, @@ -633,16 +627,13 @@ dev = [ [package.metadata] requires-dist = [ - { name = "asyncpg", specifier = ">=0.31.0" }, - { name = "psycopg", specifier = ">=3.3.4" }, - { name = "psycopg", extras = ["binary"], marker = "extra == 'binary'", specifier = ">=3.3.4" }, { name = "pydantic", specifier = ">=2" }, { name = "typer", specifier = ">=0.12" }, ] -provides-extras = ["binary"] [package.metadata.requires-dev] dev = [ + { name = "asyncpg", specifier = ">=0.31.0" }, { name = "asyncpg-stubs", specifier = ">=0.31.3" }, { name = "httpx", specifier = "==0.28.1" }, { name = "mypy", specifier = "==2.3.0" }, @@ -707,73 +698,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, ] -[package.optional-dependencies] -binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.4" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, - { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, - { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, - { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, - { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, - { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, - { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, - { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, - { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, - { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, - { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, - { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, - { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, - { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, - { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, - { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, - { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, - { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, - { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, - { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, - { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, - { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, - { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, - { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" From 201c904f9499e777c78de698fe278e1a2efa8e98 Mon Sep 17 00:00:00 2001 From: Yossi <54272821+Apakottur@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:19:12 +0000 Subject: [PATCH 3/6] feat: selectable database driver (psycopg default, asyncpg optional) Add a driver argument to generate/agenerate (and --driver / PGMIG_DRIVER on the CLI) selecting psycopg (default) or asyncpg behind the _db abstraction. Package psycopg as the base dependency with binary/c/asyncpg install extras. Cast char catalog columns to text so both drivers agree. Run one asyncpg cell (py3.14/pg18) in CI. Document the driver/build tradeoffs in the README FAQ. --- .github/workflows/ci.yml | 18 +++++++- README.md | 38 ++++++++++++++-- pyproject.toml | 23 +++++++--- src/pgmig/_db.py | 24 +++++------ tests/conftest.py | 6 ++- uv.lock | 93 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35b17e12..d6ac4cf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,28 +50,41 @@ jobs: fail-fast: false matrix: # Sparse cross: every Postgres major on the newest Python, plus every other - # Python on the default Postgres (18). The PG-18 x 3.14 cell is shared. + # Python on the default Postgres (18). The PG-18 x 3.14 cell is shared. Everything + # runs on the default psycopg driver; one extra cell covers the asyncpg driver. include: - python-version: "3.14" pg-version: "14" + driver: "psycopg" - python-version: "3.14" pg-version: "15" + driver: "psycopg" - python-version: "3.14" pg-version: "16" + driver: "psycopg" - python-version: "3.14" pg-version: "17" + driver: "psycopg" - python-version: "3.14" pg-version: "18" + driver: "psycopg" - python-version: "3.10" pg-version: "18" + driver: "psycopg" - python-version: "3.11" pg-version: "18" + driver: "psycopg" - python-version: "3.12" pg-version: "18" + driver: "psycopg" - python-version: "3.13" pg-version: "18" + driver: "psycopg" + - python-version: "3.14" + pg-version: "18" + driver: "asyncpg" - name: Unit Tests - Python ${{ matrix.python-version }} - Postgres ${{ matrix.pg-version }} + name: Unit Tests - Python ${{ matrix.python-version }} - Postgres ${{ matrix.pg-version }} - ${{ matrix.driver }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -83,6 +96,7 @@ jobs: - name: Run unit tests env: PGMIG_TEST_PG_VERSION: ${{ matrix.pg-version }} + PGMIG_DRIVER: ${{ matrix.driver }} run: pytest --showlocals -c tests/pytest.ini check: diff --git a/README.md b/README.md index a4f75ff2..ddf1c327 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ uv add --dev pgmig # Or add pgmig to your project. pip install pgmig ``` +The base install uses the pure-Python psycopg driver (needs a system libpq). For a faster build, +a bundled-libpq build, or the asyncpg driver, see [which driver should I install?](#which-driver-should-i-install). + ### Usage `pgmig` can be used directly in the command line or as a Python library. @@ -99,6 +102,7 @@ adds a few more (`—` in the library column): | `--index-concurrently`, `-C` | `index_concurrently` | Whether to emit `CREATE`/`DROP INDEX` (including `CREATE UNIQUE INDEX`) with `CONCURRENTLY`. Using `CONCURRENTLY` avoids blocking index read/write operations, but takes longer to execute and cannot be run inside a transaction block. | | `--ignore-extension-version` | `ignore_extension_version` | Names of extensions whose version mismatch is ignored: no `ALTER EXTENSION ... UPDATE TO` is emitted for them. Repeatable on the CLI; a list of names in the library. | | `--ignore-owner` | `ignore_owner` | Suppress all `ALTER ... OWNER TO` statements. | +| `--driver` | `driver` | Database driver to use for introspection: `psycopg` (default) or `asyncpg`. Falls back to the `PGMIG_DRIVER` environment variable. See [the FAQ](#which-driver-should-i-install). | | `--output`, `-o` | — | Write the migration SQL to this file instead of stdout. | | `--check`, `-c` | — | Exit non-zero if the databases differ (CI gate); the migration is still emitted. | @@ -127,15 +131,43 @@ e.g. `postgresql://user:pass@host:5432/dbname`. ## FAQ +### Which driver should I install? + +pgmig talks to Postgres through a driver. The base `pgmig` install uses **psycopg** (pure +Python), which needs a system-provided libpq. Extras swap in a faster build or a different +driver — the migration code is identical, only the connection layer changes. + +| Install | Driver / build | Needs at install | libpq | Notes | +| ----------------------- | ------------------------ | ---------------- | ------------- | ----- | +| `pip install pgmig` | psycopg (pure Python) | nothing | system libpq | Works everywhere; slowest. | +| `pip install 'pgmig[binary]'` | psycopg (prebuilt C) | nothing | **bundled** | Fast, no build tools or system libpq. Bundled libpq/OpenSSL only update with the wheel, so psycopg advises against it in production — ideal for CLI / standalone use. | +| `pip install 'pgmig[c]'` | psycopg (compiled C) | C compiler + libpq headers | system libpq | Fast **and** rides your OS's libpq security updates. Recommended for production. | +| `pip install 'pgmig[asyncpg]'` | asyncpg | nothing | **none** | No libpq at all. Select it with `--driver asyncpg` (or `driver="asyncpg"`). | + ### Do I need libpq installed? -By default, yes — pgmig requires the Postgres client library (libpq) on the machine. For -standalone / CLI use you can skip that by installing the `binary` extra, which bundles it: +Depends on the install. The base install and the `c` extra use the system libpq (install it via +your OS, e.g. `apt install libpq5`). The `binary` extra bundles its own, and the `asyncpg` extra +doesn't use libpq at all — so either of those needs nothing extra on the machine. + +### Can I use asyncpg instead of psycopg? + +Yes. Install the extra and select the driver: ```shell -pip install 'pgmig[binary]' +pip install 'pgmig[asyncpg]' +pgmig generate -s "$SRC" -t "$TGT" --driver asyncpg # or set PGMIG_DRIVER=asyncpg ``` +In the library, pass `driver="asyncpg"` to `generate` / `agenerate`. The default is `psycopg`. + +### Which driver is fastest? + +psycopg — especially the `c` / `binary` builds — is about 1.3× faster than asyncpg for pgmig's +workload. Introspection is a handful of one-shot catalog queries, where asyncpg's per-statement +prepare cost outweighs its throughput edge. That said, a generation runs once and finishes in +well under a second, so pick by install convenience rather than speed. + ## Contributing Contributions are welcome! diff --git a/pyproject.toml b/pyproject.toml index a4dd0659..de57c8b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling", "hatch-vcs"] [dependency-groups] dev = [ - # Both drivers live here while we benchmark them; the base-package driver + extras are TBD. + # asyncpg is an optional extra for users, but the dev env installs it to test that driver. "asyncpg>=0.31.0", "asyncpg-stubs>=0.31.3", "httpx==0.28.1", @@ -45,12 +45,21 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -# No database driver is a hard dependency yet: the default driver and the psycopg/asyncpg -# install extras are decided after benchmarking. Both drivers are in the dev group meanwhile. -dependencies = [ - "pydantic>=2", - "typer>=0.12", -] +# The default driver is psycopg (pure Python), which talks to a system-provided libpq. Faster +# and driver-alternative builds are opt-in extras below; the runtime `driver` argument selects +# which installed driver to use. +dependencies = ["psycopg>=3.3.4", "pydantic>=2", "typer>=0.12"] + +[project.optional-dependencies] +# psycopg's C speedups compiled against the system libpq. Fast; recommended for production. +# Needs a C compiler and libpq headers at install time. +c = ["psycopg[c]>=3.3.4"] +# psycopg's C speedups shipped as a prebuilt wheel that bundles libpq. Fast and needs no build +# tools or system libpq -- but the bundled libpq/OpenSSL only update when psycopg-binary does, +# so psycopg recommends against it for production. Great for CLI / standalone use. +binary = ["psycopg[binary]>=3.3.4"] +# The asyncpg driver instead of psycopg. Needs no libpq at all. Select it with driver="asyncpg". +asyncpg = ["asyncpg>=0.31.0"] [project.scripts] pgmig = "pgmig._cli:app" diff --git a/src/pgmig/_db.py b/src/pgmig/_db.py index 9d098e84..83c75073 100644 --- a/src/pgmig/_db.py +++ b/src/pgmig/_db.py @@ -1,20 +1,20 @@ import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Any, TypeVar +from typing import Any, Literal, TypeVar, cast import asyncpg import psycopg from psycopg.rows import class_row from pydantic import BaseModel -from typing_extensions import Literal, Self +from typing_extensions import Self from pgmig._errors import _PgmigError _RowT = TypeVar("_RowT", bound=BaseModel) -# The database driver to use. No default is committed yet: while we benchmark the two drivers the -# caller picks one explicitly (see DEFAULT_DRIVER, a temporary choice that is free to change). +# The database driver to use. psycopg is the default: it is the faster driver for pgmig's +# introspection workload and is what the base package installs. asyncpg is an opt-in extra. Driver = Literal["psycopg", "asyncpg"] DEFAULT_DRIVER: Driver = "psycopg" @@ -61,9 +61,7 @@ async def connect(cls, *, dsn: str, driver: Driver = DEFAULT_DRIVER) -> AsyncIte # asyncpg returns json/jsonb as raw text by default; decode so queries that build # nested jsonb objects (domains, composite types, functions) parse into their models. for type_name in ("json", "jsonb"): - await conn.set_type_codec( - type_name, encoder=json.dumps, decoder=json.loads, schema="pg_catalog" - ) + await conn.set_type_codec(type_name, encoder=json.dumps, decoder=json.loads, schema="pg_catalog") try: yield cls(dsn=dsn, driver=driver, conn=conn) @@ -86,22 +84,22 @@ async def execute(self, statement: str) -> list[tuple[Any, ...]]: raise UniqueViolation(str(error)) from error if result.description: - return await result.fetchall() + return cast("list[tuple[Any, ...]]", await result.fetchall()) return [] case "asyncpg": # asyncpg splits protocols: fetch() (extended) returns rows but forbids multiple - # statements and non-transactionable commands; execute() (simple) allows those but - # returns no rows. Reads are always single SELECT-likes; everything else is DDL. + # statements and commands that cannot run in a transaction; execute() (simple) allows + # those but returns no rows. Reads are always single SELECT-likes; everything else is DDL. is_read = statement.lstrip().upper().startswith(("SELECT", "WITH", "SHOW", "VALUES", "TABLE")) try: if is_read: records = await self.driver_conn.fetch(statement) return [tuple(record) for record in records] await self.driver_conn.execute(statement) - return [] except asyncpg.UniqueViolationError as error: raise UniqueViolation(str(error)) from error + return [] class DbReadOnlyConnection(DbConnection): @@ -142,8 +140,8 @@ async def introspect(self, query: str, response_model: type[_RowT]) -> list[_Row case "psycopg": async with self.driver_conn.cursor(row_factory=class_row(response_model)) as cur: await cur.execute(query) - return await cur.fetchall() + return cast("list[_RowT]", await cur.fetchall()) case "asyncpg": records = await self.driver_conn.fetch(query) - return [response_model.model_validate(dict(record)) for record in records] + return [response_model(**record) for record in records] diff --git a/tests/conftest.py b/tests/conftest.py index 306e0937..fc06e4b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import os from collections.abc import AsyncIterator from pathlib import Path +from typing import cast import pytest import shpyx @@ -86,8 +87,9 @@ def _driver(request: pytest.FixtureRequest) -> Driver: config_option = request.config.getoption("--driver") env_var = os.environ.get("PGMIG_DRIVER") - # Resolve the final driver: --driver, then PGMIG_DRIVER, then the default. - final_driver: Driver = config_option or env_var or DEFAULT_DRIVER + # Resolve the final driver: --driver, then PGMIG_DRIVER, then the default. Both sources are + # validated (option choices / a real install), so narrow the str back to Driver. + final_driver = cast("Driver", config_option or env_var or DEFAULT_DRIVER) # Export it so CLI invocations (which read PGMIG_DRIVER) use the same driver. os.environ["PGMIG_DRIVER"] = final_driver diff --git a/uv.lock b/uv.lock index 373221c2..0ba713f4 100644 --- a/uv.lock +++ b/uv.lock @@ -601,10 +601,22 @@ wheels = [ name = "pgmig" source = { editable = "." } dependencies = [ + { name = "psycopg" }, { name = "pydantic" }, { name = "typer" }, ] +[package.optional-dependencies] +asyncpg = [ + { name = "asyncpg" }, +] +binary = [ + { name = "psycopg", extra = ["binary"] }, +] +c = [ + { name = "psycopg", extra = ["c"] }, +] + [package.dev-dependencies] dev = [ { name = "asyncpg" }, @@ -627,9 +639,14 @@ dev = [ [package.metadata] requires-dist = [ + { name = "asyncpg", marker = "extra == 'asyncpg'", specifier = ">=0.31.0" }, + { name = "psycopg", specifier = ">=3.3.4" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'binary'", specifier = ">=3.3.4" }, + { name = "psycopg", extras = ["c"], marker = "extra == 'c'", specifier = ">=3.3.4" }, { name = "pydantic", specifier = ">=2" }, { name = "typer", specifier = ">=0.12" }, ] +provides-extras = ["asyncpg", "binary", "c"] [package.metadata.requires-dev] dev = [ @@ -698,6 +715,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, ] +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] +c = [ + { name = "psycopg-c", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "psycopg-c" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/7c/c08364f2eab2913e4068b3b955d963e7a3491986a85429990969525def30/psycopg_c-3.3.4.tar.gz", hash = "sha256:ed8106128b2d04359c185fc9641b4409abfce4d0b6fb1d1ff6800646e27f1a22", size = 647111, upload-time = "2026-05-01T23:31:58.032Z" } + [[package]] name = "pydantic" version = "2.13.4" From 8a34ef40a4499b1f6319fdeb0e7d02d05e1cbcfd Mon Sep 17 00:00:00 2001 From: Yossi <54272821+Apakottur@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:36:13 +0000 Subject: [PATCH 4/6] chore: add driver benchmark script Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014h6yDcdivduaRr31BviN1C --- scripts/bench_drivers.py | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 scripts/bench_drivers.py diff --git a/scripts/bench_drivers.py b/scripts/bench_drivers.py new file mode 100644 index 00000000..6cc5e363 --- /dev/null +++ b/scripts/bench_drivers.py @@ -0,0 +1,79 @@ +""" +Benchmark the psycopg and asyncpg drivers on a full introspection of a populated database. + +Run from the worktree: uv run python scripts/bench_drivers.py +""" +# ruff: noqa: INP001, S608 (throwaway benchmark script, hardcoded DDL) + +import asyncio +import statistics +import time + +from pgmig._db import DbConnection +from pgmig._introspect._engine import introspect_db + +_DSN_PREFIX = "postgresql://pgmig:pgmig@localhost:15432" +_ADMIN_DSN = f"{_DSN_PREFIX}/postgres" +_BENCH_DB = "pgmig_bench" +_BENCH_DSN = f"{_DSN_PREFIX}/{_BENCH_DB}" + +_TABLES = 40 +_VIEWS = 15 +_ITERATIONS = 25 + + +async def _recreate_bench_db() -> None: + async with DbConnection.connect(dsn=_ADMIN_DSN, driver="psycopg") as admin: + await admin.execute(f"DROP DATABASE IF EXISTS {_BENCH_DB} WITH (FORCE)") + await admin.execute(f"CREATE DATABASE {_BENCH_DB}") + + +async def _populate() -> None: + stmts: list[str] = ["CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')"] + for i in range(_TABLES): + stmts.append( + f"CREATE TABLE t{i} (" + f"id bigint PRIMARY KEY, name text NOT NULL, val numeric(10, 2), " + f"created timestamptz DEFAULT now(), feeling mood)" + ) + stmts.append(f"CREATE INDEX t{i}_name_idx ON t{i} (name)") + stmts.extend(f"CREATE VIEW v{i} AS SELECT id, name FROM t{i}" for i in range(_VIEWS)) + stmts.append("CREATE FUNCTION add(a integer, b integer) RETURNS integer LANGUAGE sql AS 'SELECT a + b'") + + async with DbConnection.connect(dsn=_BENCH_DSN, driver="psycopg") as conn: + await conn.execute(";\n".join(stmts)) + + +async def _time_driver(driver: str) -> list[float]: + # Warm up (connection setup, prepared-statement caches) before timing. + await introspect_db(_BENCH_DSN, driver) # type: ignore[arg-type] + + timings: list[float] = [] + for _ in range(_ITERATIONS): + start = time.perf_counter() + await introspect_db(_BENCH_DSN, driver) # type: ignore[arg-type] + timings.append(time.perf_counter() - start) + return timings + + +async def main() -> None: + await _recreate_bench_db() + await _populate() + + print(f"Introspecting {_TABLES} tables + {_VIEWS} views, {_ITERATIONS} iterations each.\n") + print(f"{'driver':<10}{'mean (ms)':>12}{'min (ms)':>12}{'median (ms)':>14}") + results: dict[str, float] = {} + for driver in ("psycopg", "asyncpg"): + timings = await _time_driver(driver) + mean_ms = statistics.mean(timings) * 1000 + results[driver] = mean_ms + print(f"{driver:<10}{mean_ms:>12.2f}{min(timings) * 1000:>12.2f}{statistics.median(timings) * 1000:>14.2f}") + + faster = min(results, key=lambda k: results[k]) + slower = max(results, key=lambda k: results[k]) + ratio = results[slower] / results[faster] + print(f"\n{faster} is {ratio:.2f}x faster than {slower} (mean).") + + +if __name__ == "__main__": + asyncio.run(main()) From faf1d2e954990a47d6e222c2dd1c1dac4033c989 Mon Sep 17 00:00:00 2001 From: Yossi <54272821+Apakottur@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:06:12 +0000 Subject: [PATCH 5/6] test: pass the driver enum itself as the --driver choices StrEnum members are strings, so argparse matches, prints and reports them exactly as the list comprehension did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3cSLw9HdmMVt5z638nBBW --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3eea2895..d79ba064 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: "--driver", action="store", default=None, - choices=[driver.value for driver in DbDriver], + choices=list(DbDriver), help=( f"Database driver to test against. Falls back to the PGMIG_DRIVER env var, then {DbDriver.AUTO.value!r}." ), From e42544b01de52b190501dcdf0f2dfda092b68aa4 Mon Sep 17 00:00:00 2001 From: Yossi <54272821+Apakottur@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:21:06 +0000 Subject: [PATCH 6/6] test: assert the connection report against the driver under test The CLI tests spelled psycopg's box title and its wording into the assertions, so they only passed on the default driver. Take the title from the driver fixture, and check the wrapped box by its geometry rather than by the sentence inside it, which is the driver's own. The fixture loses its underscore: tests read its value now, so it is no longer internal to conftest. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3cSLw9HdmMVt5z638nBBW --- tests/conftest.py | 16 ++++++++-------- tests/test_cli.py | 28 ++++++++++++++++------------ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d79ba064..2023e505 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,7 +83,7 @@ def _pg_major(request: pytest.FixtureRequest) -> int: @pytest.fixture(scope="session") -def _driver(request: pytest.FixtureRequest) -> DbDriver: +def driver(request: pytest.FixtureRequest) -> DbDriver: """ Get the database driver under test. """ @@ -97,7 +97,7 @@ def _driver(request: pytest.FixtureRequest) -> DbDriver: @pytest.fixture(scope="session") -async def _admin_conn(request: pytest.FixtureRequest, _driver: DbDriver) -> AsyncIterator[DbConnection]: +async def _admin_conn(request: pytest.FixtureRequest, driver: DbDriver) -> AsyncIterator[DbConnection]: """ Session level database server plus a shared connection to the admin database, reused to (re)create the per-test databases. @@ -106,7 +106,7 @@ async def _admin_conn(request: pytest.FixtureRequest, _driver: DbDriver) -> Asyn shpyx.run("docker compose up -d", exec_dir=_COMPOSE_FILE_DIR) # Get the admin DB conn info. - admin_db_conn_info = DbConnInfo(dsn=get_dsn("postgres"), label="admin", driver=_driver) + admin_db_conn_info = DbConnInfo(dsn=get_dsn("postgres"), label="admin", driver=driver) # Wait for the database server to be ready. await wait_for_db_connection(db_conn_info=admin_db_conn_info) @@ -124,7 +124,7 @@ async def _admin_conn(request: pytest.FixtureRequest, _driver: DbDriver) -> Asyn @pytest.fixture(scope="session") async def _persistent_dbs( _unique_key: str, - _driver: DbDriver, + driver: DbDriver, _admin_conn: DbConnection, ) -> AsyncIterator[tuple[str, str, DbConnection, DbConnection]]: """ @@ -139,10 +139,10 @@ async def _persistent_dbs( async with ( DbConnection.connect( - db_conn_info=DbConnInfo(dsn=get_dsn(src_db_name), label="source", driver=_driver) + db_conn_info=DbConnInfo(dsn=get_dsn(src_db_name), label="source", driver=driver) ) as src_conn, DbConnection.connect( - db_conn_info=DbConnInfo(dsn=get_dsn(dst_db_name), label="target", driver=_driver) + db_conn_info=DbConnInfo(dsn=get_dsn(dst_db_name), label="target", driver=driver) ) as dst_conn, ): yield src_db_name, dst_db_name, src_conn, dst_conn @@ -152,7 +152,7 @@ async def _persistent_dbs( async def gen_setup( _unique_key: str, _pg_major: int, - _driver: DbDriver, + driver: DbDriver, _persistent_dbs: tuple[str, str, DbConnection, DbConnection], ) -> GenerateSetup: """ @@ -171,6 +171,6 @@ async def gen_setup( src_conn=src_conn, dst_conn=dst_conn, pg_major=_pg_major, - driver=_driver, + driver=driver, unique_key=_unique_key, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2cce3a03..f3670549 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from typer.testing import CliRunner, Result from pgmig._cli._cli import app +from pgmig._drivers import DbDriver from tests._api.generate_setup import GenerateSetup _runner = CliRunner() @@ -59,7 +60,7 @@ async def test_generate_empty_diff_truncates_stale_output(gen_setup: GenerateSet assert out.read_text() == "" -async def test_generate_connection_error_is_clean() -> None: +async def test_generate_connection_error_is_clean(driver: DbDriver) -> None: # A bad connection string is an expected failure: clean message, no traceback, and the # driver's own text quoted in a box below it rather than run into pgmig's message. result = await _run_cli("generate -s not-a-dsn -t not-a-dsn") @@ -68,8 +69,7 @@ async def test_generate_connection_error_is_clean() -> None: assert "Failed to connect to one of the databases:" in result.output assert "Source - UNREACHABLE" in result.output assert "Target - UNREACHABLE" in result.output - assert "╭─ psycopg " in result.output - assert "│ Invalid connection string." in result.output + assert f"╭─ {driver.resolved} " in result.output assert "Traceback" not in result.output @@ -101,10 +101,9 @@ async def test_generate_does_not_echo_an_unparsable_connection_string() -> None: assert result.exit_code == 1 assert "swordfish" not in result.output assert "localhost" not in result.output - assert "Invalid connection string." in result.output -async def test_generate_wraps_the_driver_error_to_the_terminal_width(mocker: MockerFixture) -> None: +async def test_generate_wraps_the_driver_error_to_the_terminal_width(mocker: MockerFixture, driver: DbDriver) -> None: # The driver's text is quoted verbatim, wrapped to fit, with continuation lines indented # so a wrapped line cannot be mistaken for a new one. mocker.patch("pgmig._cli._error_format.shutil.get_terminal_size", return_value=os.terminal_size((60, 24))) @@ -113,13 +112,19 @@ async def test_generate_wraps_the_driver_error_to_the_terminal_width(mocker: Moc assert result.exit_code == 1 assert " Source - UNREACHABLE" in result.output - assert " ╭─ psycopg ─────────────────────────────────────────────" in result.output - assert " │ Invalid connection string. The driver's own message is" in result.output - assert " │ not shown here because it quotes the connection" in result.output - assert " ╰───────────────────────────────────────────────────────" in result.output + # Every box line is drawn to the same width, and a wrapped line is indented past the + # first so it cannot be mistaken for a new one. The text itself is the driver's. + lines = [line for line in result.output.splitlines() if line.startswith(" ")] + assert lines[0].startswith(f" ╭─ {driver.resolved} ─") + assert lines[-1].startswith(" ╰─") + assert {len(line) for line in (lines[0], lines[-1])} == {60} + assert any(line.startswith(" │ ") for line in lines) -async def test_generate_falls_back_to_an_ascii_box_when_stderr_cannot_encode(mocker: MockerFixture) -> None: + +async def test_generate_falls_back_to_an_ascii_box_when_stderr_cannot_encode( + mocker: MockerFixture, driver: DbDriver +) -> None: # Output redirected under a legacy code page: drawing the nicer box would raise. The # module's own `sys` is replaced rather than sys.stderr, which the CLI runner rebinds to # its capture buffer once the command is running. @@ -128,8 +133,7 @@ async def test_generate_falls_back_to_an_ascii_box_when_stderr_cannot_encode(moc result = await _run_cli("generate -s not-a-dsn -t not-a-dsn") assert result.exit_code == 1 - assert "+- psycopg " in result.output - assert "| Invalid connection string." in result.output + assert f"+- {driver.resolved} " in result.output assert "╭" not in result.output