diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9293d326..66371342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,26 +50,38 @@ 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.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 @@ -81,6 +93,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 6514a267..91ac5f5e 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. @@ -101,7 +104,7 @@ adds a few more (`—` in the library column): | `--ignore-schema` | `ignore_schemas` | Schema names to exclude from the diff entirely: their tables and every other object, and the `CREATE`/`DROP` of the schema itself, are ignored (even object kinds pgmig cannot otherwise process). The schema must be isolated — if it shares any dependency with a kept schema (a foreign key, a view read, a cross-schema type, …), pgmig errors rather than emit a migration that would fail at apply. Repeatable on the CLI; a list of names in the library. | | `--include-owner` | `include_owner` | Emit `ALTER ... OWNER TO` statements to reconcile ownership. Off by default: ownership references cluster-level roles that routinely differ across environments, so it is not part of the default convergence. | | `--include-grants` | `include_grants` | Also emit named-role `GRANT`/`REVOKE`. `PUBLIC` grants are always diffed (portable and apply-safe); named-role grants reference cluster-level roles that diverge across environments and fail on apply when the role is absent on the target, so they are opt-in. | -| `--driver` | `driver` | Database driver to connect with: `auto` (default) or `psycopg`. `auto` picks among the supported drivers; naming one pins it. On the CLI it falls back to the `PGMIG_DRIVER` environment variable. | +| `--driver` | `driver` | Database driver to connect with: `auto` (default), `psycopg` or `asyncpg`. `auto` picks among the installed drivers; naming one pins it. On the CLI it 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. | @@ -130,15 +133,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/linters/cspell/words.txt b/linters/cspell/words.txt index 70d1d4d0..748ef765 100644 --- a/linters/cspell/words.txt +++ b/linters/cspell/words.txt @@ -28,6 +28,7 @@ pycache pyproject softprops psycopg +asyncpg dataclass dataclasses conftest diff --git a/pyproject.toml b/pyproject.toml index bf4188c6..faaac202 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,9 @@ requires = ["hatchling", "hatch-vcs"] [dependency-groups] dev = [ + # 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", "mypy==2.3.1", "prek==0.5.2", @@ -41,18 +44,21 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = [ - # - "psycopg>=3.3.5", - "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.5", "pydantic>=2", "typer>=0.12"] [project.optional-dependencies] -binary = [ - # - "psycopg[binary]>=3.3.5", -] +# 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.5"] +# 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.5"] +# 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._cli:app" diff --git a/scripts/bench_drivers.py b/scripts/bench_drivers.py new file mode 100644 index 00000000..950a2ecb --- /dev/null +++ b/scripts/bench_drivers.py @@ -0,0 +1,88 @@ +""" +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, DbConnInfo +from pgmig._drivers import DbDriver +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( + db_conn_info=DbConnInfo(dsn=_ADMIN_DSN, label="admin", driver=DbDriver.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( + db_conn_info=DbConnInfo(dsn=_BENCH_DSN, label="bench", driver=DbDriver.PSYCOPG) + ) as conn: + await conn.execute(";\n".join(stmts)) + + +async def _time_driver(driver: DbDriver) -> list[float]: + db_conn_info = DbConnInfo(dsn=_BENCH_DSN, label="bench", driver=driver) + + # Warm up (connection setup, prepared-statement caches) before timing. + await introspect_db(db_conn_info=db_conn_info) + + timings: list[float] = [] + for _ in range(_ITERATIONS): + start = time.perf_counter() + await introspect_db(db_conn_info=db_conn_info) + 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 (DbDriver.PSYCOPG, DbDriver.ASYNCPG): + timings = await _time_driver(driver) + mean_ms = statistics.mean(timings) * 1000 + results[driver.value] = mean_ms + print( + f"{driver.value:<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()) diff --git a/src/pgmig/_db.py b/src/pgmig/_db.py index d57275b7..166b8958 100644 --- a/src/pgmig/_db.py +++ b/src/pgmig/_db.py @@ -1,8 +1,10 @@ +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Any, Self, TypeVar +from typing import Any, Self, TypeVar, cast +import asyncpg import psycopg from psycopg.rows import class_row from pydantic import BaseModel @@ -41,7 +43,7 @@ class DbConnection: All DB interaction is done through this class to avoid the DB driver leaking into other modules. """ - def __init__(self, *, db_conn_info: DbConnInfo, conn: psycopg.AsyncConnection[Any]) -> None: + def __init__(self, *, db_conn_info: DbConnInfo, conn: Any) -> None: self.db_conn_info = db_conn_info self.driver_conn = conn @@ -49,39 +51,85 @@ def __init__(self, *, db_conn_info: DbConnInfo, conn: psycopg.AsyncConnection[An def dsn(self) -> str: return self.db_conn_info.dsn + @property + def driver(self) -> DbDriver: + return self.db_conn_info.driver.resolved + @classmethod @asynccontextmanager async def connect(cls, *, db_conn_info: DbConnInfo) -> AsyncIterator[Self]: """ Connection context. """ - try: - conn = await psycopg.AsyncConnection.connect(db_conn_info.dsn, autocommit=True) - except psycopg.Error as error: - raise PgmigDbDriverError( - label=db_conn_info.label, - driver=db_conn_info.driver, - # Wrap with a generic exception in case of a DSN parsing error to avoid leaking the DSN. - driver_error=PgmigInvalidDbDsnError() if isinstance(error, psycopg.ProgrammingError) else error, - ) from error - - async with conn: - yield cls(db_conn_info=db_conn_info, conn=conn) + match db_conn_info.driver.resolved: + case DbDriver.PSYCOPG: + try: + conn = await psycopg.AsyncConnection.connect(db_conn_info.dsn, autocommit=True) + except psycopg.Error as error: + raise PgmigDbDriverError( + label=db_conn_info.label, + driver=db_conn_info.driver, + # Wrap with a generic exception in case of a DSN parsing error to avoid leaking the DSN. + driver_error=PgmigInvalidDbDsnError() if isinstance(error, psycopg.ProgrammingError) else error, + ) from error + + async with conn: + yield cls(db_conn_info=db_conn_info, conn=conn) + + # asyncpg. Spelled as the fallback so that the match cannot fall through. + case _: + try: + asyncpg_conn = await asyncpg.connect(db_conn_info.dsn) + except (OSError, asyncpg.PostgresError, asyncpg.InterfaceError) as error: + raise PgmigDbDriverError( + label=db_conn_info.label, driver=db_conn_info.driver, driver_error=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 asyncpg_conn.set_type_codec( + type_name, encoder=json.dumps, decoder=json.loads, schema="pg_catalog" + ) + + try: + yield cls(db_conn_info=db_conn_info, conn=asyncpg_conn) + finally: + await asyncpg_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 DbDriver.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 cast("list[tuple[Any, ...]]", await result.fetchall()) + return [] + + # asyncpg. + case _: + # asyncpg splits protocols: fetch() (extended) returns rows but forbids multiple + # 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) + except asyncpg.UniqueViolationError as error: + raise UniqueViolation(str(error)) from error + return [] class DbReadOnlyConnection(DbConnection): @@ -96,14 +144,22 @@ async def connect(cls, *, db_conn_info: DbConnInfo) -> AsyncIterator[Self]: Read-only connection context. """ async with super().connect(db_conn_info=db_conn_info) as conn: - # Force all subsequent transactions to be read-only. - await conn.driver_conn.set_read_only(True) + match conn.driver: + case DbDriver.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 the enclosed reads are done on a single consistent snapshot of the database. - await conn.driver_conn.set_isolation_level(psycopg.IsolationLevel.REPEATABLE_READ) + # asyncpg, which has no setters for either and takes them as statements. + case _: + 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. - await conn.driver_conn.execute("SET search_path = ''") + # 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.execute("SET search_path = ''") # Run the enclosed reads inside a single transaction to guarantee a consistent snapshot of the database. async with conn.driver_conn.transaction(): @@ -113,6 +169,13 @@ 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 DbDriver.PSYCOPG: + async with self.driver_conn.cursor(row_factory=class_row(response_model)) as cur: + await cur.execute(query) + return cast("list[_RowT]", await cur.fetchall()) + + # asyncpg. + case _: + records = await self.driver_conn.fetch(query) + return [response_model(**record) for record in records] diff --git a/src/pgmig/_drivers.py b/src/pgmig/_drivers.py index 031b053d..4348127f 100644 --- a/src/pgmig/_drivers.py +++ b/src/pgmig/_drivers.py @@ -12,6 +12,9 @@ class DbDriver(StrEnum): # https://github.com/psycopg/psycopg PSYCOPG = "psycopg" + # https://github.com/MagicStack/asyncpg + ASYNCPG = "asyncpg" + @property def resolved(self) -> "DbDriver": match self: 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/tests/test_cli.py b/tests/test_cli.py index 4ea65fea..70a14d66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -70,7 +70,6 @@ async def test_generate_connection_error_is_clean(driver: DbDriver) -> None: assert "Source - UNREACHABLE" in result.output assert "Target - UNREACHABLE" in result.output assert f"╭─ {driver.resolved} " in result.output - assert "│ Invalid connection string." in result.output assert "Traceback" not in result.output @@ -102,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))) @@ -114,10 +112,14 @@ 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( @@ -132,7 +134,6 @@ async def test_generate_falls_back_to_an_ascii_box_when_stderr_cannot_encode( assert result.exit_code == 1 assert f"+- {driver.resolved} " in result.output - assert "| Invalid connection string." in result.output assert "╭" not in result.output diff --git a/tests/test_drivers.py b/tests/test_drivers.py index 0e1334b8..96d5b4c7 100644 --- a/tests/test_drivers.py +++ b/tests/test_drivers.py @@ -10,3 +10,5 @@ def test_db_driver_resolve() -> None: assert resolved is DbDriver.PSYCOPG case DbDriver.PSYCOPG: assert resolved is DbDriver.PSYCOPG + case DbDriver.ASYNCPG: + assert resolved is DbDriver.ASYNCPG diff --git a/uv.lock b/uv.lock index 3b6842e8..6fe12236 100644 --- a/uv.lock +++ b/uv.lock @@ -101,6 +101,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/45/c7cd8d36d3b506bbd02db5066fae3340284781168f0d08dac25deef5f69d/ast_serialize-0.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:74473258a5c55855d5306c864a5c799fbff03a0f0ea1197346b2b5cc5b4ea48a", size = 1128237, upload-time = "2026-09-02T15:50:43.496Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +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/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.7.22" @@ -527,12 +588,20 @@ dependencies = [ ] [package.optional-dependencies] +asyncpg = [ + { name = "asyncpg" }, +] binary = [ { name = "psycopg", extra = ["binary"] }, ] +c = [ + { name = "psycopg", extra = ["c"] }, +] [package.dev-dependencies] dev = [ + { name = "asyncpg" }, + { name = "asyncpg-stubs" }, { name = "httpx" }, { name = "mypy" }, { name = "prek" }, @@ -551,15 +620,19 @@ dev = [ [package.metadata] requires-dist = [ + { name = "asyncpg", marker = "extra == 'asyncpg'", specifier = ">=0.31.0" }, { name = "psycopg", specifier = ">=3.3.5" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'binary'", specifier = ">=3.3.5" }, + { name = "psycopg", extras = ["c"], marker = "extra == 'c'", specifier = ">=3.3.5" }, { name = "pydantic", specifier = ">=2" }, { name = "typer", specifier = ">=0.12" }, ] -provides-extras = ["binary"] +provides-extras = ["asyncpg", "binary", "c"] [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.1" }, { name = "prek", specifier = "==0.5.2" }, @@ -618,6 +691,9 @@ wheels = [ binary = [ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] +c = [ + { name = "psycopg-c", marker = "implementation_name != 'pypy'" }, +] [[package]] name = "psycopg-binary" @@ -670,6 +746,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" }, ] +[[package]] +name = "psycopg-c" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/7b/5da2274bdbfed6b02aea463e2b803b8d4406bbc9d6c6f6bfd7122492254f/psycopg_c-3.3.5.tar.gz", hash = "sha256:5e11edaa0040a64d0af351a8fd3e276a824cd92fe34abc350cd67aa07ad36c55", size = 694569, upload-time = "2026-08-31T22:45:45.337Z" } + [[package]] name = "pydantic" version = "2.13.5"