From 2ccc17b296d8b5db315137d25d87dc673fef2f08 Mon Sep 17 00:00:00 2001 From: AlexeyShalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:54:36 +0400 Subject: [PATCH] fix: import on SQLAlchemy 2.1, which removed FallbackAsyncAdaptedQueuePool Register fallback_async_adapted_queue only when sqlalchemy.pool still has the class (SQLAlchemy 2.0). On 2.1 the kit imports, the other built-in pools resolve as before, and the removed name raises the registry's ValueError. A CI job now runs the whole suite on the newest SQLAlchemy that pyproject.toml allows. Fixes #78 --- .github/workflows/ci.yml | 16 +++++- docs/agents.md | 12 +++-- sqlalchemy_foundation_kit/base/engine.py | 11 ++-- .../contrib/settings/postgres.py | 9 ++-- tests/unit/base/test_engine.py | 54 +++++++++++++++++-- tests/unit/session/test_manager.py | 11 +++- 6 files changed, 95 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8217132..0fed996 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,20 @@ jobs: files: coverage.xml continue-on-error: true + # uv.lock pins SQLAlchemy for development. This job resolves the newest release that + # pyproject.toml allows, so a new SQLAlchemy cannot break installs unnoticed (#78). + test-newest-sqlalchemy: + name: All tests (newest SQLAlchemy) + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + - run: uv lock --upgrade-package sqlalchemy + - run: uv sync --group dev --all-extras + - run: uv run python -c "import sqlalchemy; print('SQLAlchemy', sqlalchemy.__version__)" + - run: uv run pytest + # Sentinel job — the single required status check in the master ruleset. A job # counts only when it succeeded: "skipped" means the work did not happen. A job # skipped by design (one that runs on pull requests only, say) goes in MAY_SKIP @@ -56,7 +70,7 @@ jobs: all-checks-passed: name: All checks passed if: always() - needs: [lint, test-unit, test-integration] + needs: [lint, test-unit, test-integration, test-newest-sqlalchemy] runs-on: ubuntu-latest steps: - name: Check all jobs succeeded diff --git a/docs/agents.md b/docs/agents.md index 424b6a9..087a999 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -291,11 +291,13 @@ and the free function take `str | int`. ### Engine utilities `PoolRegistry` maps a name to a pool class: `null`, `queue`, `singleton_thread`, -`async_adapted_queue`, `fallback_async_adapted_queue`, `static`. `PoolRegistry.register(name, -cls, *, override=False)` (or `register_pool_class(...)`) adds one and raises `ValueError` -on a duplicate without `override=True`; `resolve_pool_class(name_or_class)` resolves one -and raises `ValueError` on an unknown name; `PoolRegistry.list_available()` lists them. -`PoolClassStr` is the `Literal` of the six built-ins. +`async_adapted_queue`, `static`, and on SQLAlchemy 2.0 also `fallback_async_adapted_queue` +(SQLAlchemy 2.1 removed `FallbackAsyncAdaptedQueuePool`; resolving the name there raises +`ValueError`). `PoolRegistry.register(name, cls, *, override=False)` (or +`register_pool_class(...)`) adds one and raises `ValueError` on a duplicate without +`override=True`; `resolve_pool_class(name_or_class)` resolves one and raises `ValueError` +on an unknown name; `PoolRegistry.list_available()` lists them. +`PoolClassStr` is the `Literal` of those six names. `build_engine_kwargs(echo, poolclass, isolation_level, pool_settings, connect_args, extra_kwargs, use_orjson=False)` assembles the `create_async_engine` keywords: it always diff --git a/sqlalchemy_foundation_kit/base/engine.py b/sqlalchemy_foundation_kit/base/engine.py index 3b2c9f1..baa0455 100644 --- a/sqlalchemy_foundation_kit/base/engine.py +++ b/sqlalchemy_foundation_kit/base/engine.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, ClassVar, Literal +import sqlalchemy.pool from sqlalchemy.pool import ( AsyncAdaptedQueuePool, - FallbackAsyncAdaptedQueuePool, NullPool, QueuePool, SingletonThreadPool, @@ -52,7 +52,6 @@ class PoolRegistry: "queue": QueuePool, "singleton_thread": SingletonThreadPool, "async_adapted_queue": AsyncAdaptedQueuePool, - "fallback_async_adapted_queue": FallbackAsyncAdaptedQueuePool, "static": StaticPool, } @@ -111,11 +110,17 @@ def list_available(cls) -> list[str]: Examples: >>> PoolRegistry.list_available() - ['async_adapted_queue', 'fallback_async_adapted_queue', 'null', 'queue', ...] + ['async_adapted_queue', 'null', 'queue', ...] """ return sorted(cls._pools.keys()) +# SQLAlchemy 2.1 removed this pool together with the async_fallback mode it served, +# so it is a built-in only where SQLAlchemy still has it (2.0). +if (_fallback_pool := getattr(sqlalchemy.pool, "FallbackAsyncAdaptedQueuePool", None)) is not None: + PoolRegistry.register("fallback_async_adapted_queue", _fallback_pool) + + def resolve_pool_class(poolclass: PoolClassStr | str | type) -> type: """Resolve pool class from string name or return the class directly. diff --git a/sqlalchemy_foundation_kit/contrib/settings/postgres.py b/sqlalchemy_foundation_kit/contrib/settings/postgres.py index 2dad6b0..418fb70 100644 --- a/sqlalchemy_foundation_kit/contrib/settings/postgres.py +++ b/sqlalchemy_foundation_kit/contrib/settings/postgres.py @@ -48,10 +48,11 @@ class PoolSettings(BaseModel): Groups all connection pool-related parameters for SQLAlchemy engine. ``size``, ``max_overflow`` and ``timeout`` apply to the queue pools - (``async_adapted_queue``, ``fallback_async_adapted_queue``) and are not passed to any - other pool. ``static`` is SQLAlchemy's ``StaticPool``: one connection shared by every - session, which concurrent sessions cannot use safely -- for tests, never as a - fixed-size pool. A fixed-size pool is ``async_adapted_queue`` with ``max_overflow=0``. + (``async_adapted_queue``, and ``fallback_async_adapted_queue`` on SQLAlchemy 2.0) + and are not passed to any other pool. ``static`` is SQLAlchemy's ``StaticPool``: one + connection shared by every session, which concurrent sessions cannot use safely -- for + tests, never as a fixed-size pool. A fixed-size pool is ``async_adapted_queue`` with + ``max_overflow=0``. Examples: >>> pool = PoolSettings(size=10, max_overflow=20) diff --git a/tests/unit/base/test_engine.py b/tests/unit/base/test_engine.py index 2c86944..b16debe 100644 --- a/tests/unit/base/test_engine.py +++ b/tests/unit/base/test_engine.py @@ -2,12 +2,14 @@ from __future__ import annotations +import subprocess +import sys from unittest.mock import Mock, patch import pytest +import sqlalchemy.pool from sqlalchemy.pool import ( AsyncAdaptedQueuePool, - FallbackAsyncAdaptedQueuePool, NullPool, QueuePool, SingletonThreadPool, @@ -21,6 +23,27 @@ resolve_pool_class, ) +# SQLAlchemy 2.1 removed FallbackAsyncAdaptedQueuePool together with the async_fallback mode. +FALLBACK_POOL = getattr(sqlalchemy.pool, "FallbackAsyncAdaptedQueuePool", None) +requires_fallback_pool = pytest.mark.skipif( + FALLBACK_POOL is None, reason="SQLAlchemy 2.1 removed FallbackAsyncAdaptedQueuePool" +) + +_IMPORT_WITHOUT_FALLBACK_POOL = """ +import sqlalchemy.pool + +# What SQLAlchemy 2.1 looks like to the kit: the pool is gone from sqlalchemy.pool. +vars(sqlalchemy.pool).pop("FallbackAsyncAdaptedQueuePool", None) + +from sqlalchemy_foundation_kit.base.engine import PoolRegistry + +print(",".join(PoolRegistry.list_available())) +try: + PoolRegistry.resolve("fallback_async_adapted_queue") +except ValueError as exc: + print(type(exc).__name__) +""" + # ============================================================================ # PoolRegistry.resolve Tests # ============================================================================ @@ -58,12 +81,29 @@ def test__pool_registry_resolve__async_adapted_queue__returns_class() -> None: assert pool_class == AsyncAdaptedQueuePool +@requires_fallback_pool def test__pool_registry_resolve__fallback_async_adapted__returns_class() -> None: # Arrange & Act pool_class = PoolRegistry.resolve("fallback_async_adapted_queue") # Assert - assert pool_class == FallbackAsyncAdaptedQueuePool + assert pool_class == FALLBACK_POOL + + +def test__engine_import__sqlalchemy_without_the_fallback_pool__registers_the_other_pools() -> None: + # Arrange & Act: a fresh interpreter, because the failure happens at import time + completed = subprocess.run( # noqa: S603 + [sys.executable, "-c", _IMPORT_WITHOUT_FALLBACK_POOL], + capture_output=True, + check=False, + text=True, + ) + + # Assert + assert completed.returncode == 0, completed.stderr + pools, error = completed.stdout.split() + assert pools.split(",") == ["async_adapted_queue", "null", "queue", "singleton_thread", "static"] + assert error == "ValueError" def test__pool_registry_resolve__static__returns_class() -> None: @@ -102,7 +142,7 @@ def test__pool_registry_list_available__returns_sorted_list() -> None: # Assert assert isinstance(available, list) - assert len(available) >= 6 + assert len(available) >= 5 assert available == sorted(available) @@ -367,7 +407,13 @@ def test__build_engine_kwargs__pool_settings_for_a_pool_without_a_queue__passes_ assert kwargs["pool_pre_ping"] is True -@pytest.mark.parametrize("poolclass", [AsyncAdaptedQueuePool, FallbackAsyncAdaptedQueuePool]) +@pytest.mark.parametrize( + "poolclass", + [ + AsyncAdaptedQueuePool, + pytest.param(FALLBACK_POOL, marks=requires_fallback_pool, id="FallbackAsyncAdaptedQueuePool"), + ], +) def test__build_engine_kwargs__pool_settings_for_an_async_queue_pool__passes_every_setting(poolclass: type) -> None: # Arrange pool_settings = Mock(pre_ping=True, size=10, max_overflow=0, recycle=1800, timeout=30.0) diff --git a/tests/unit/session/test_manager.py b/tests/unit/session/test_manager.py index 8fda316..5043069 100644 --- a/tests/unit/session/test_manager.py +++ b/tests/unit/session/test_manager.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest +import sqlalchemy.pool from sqlalchemy.exc import TimeoutError as SATimeoutError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.pool import QueuePool @@ -884,7 +885,15 @@ def test__async_session_manager__init__resolves_poolclass() -> None: ("kind", "size", "pool_class_name"), [ ("async_adapted_queue", 10, "AsyncAdaptedQueuePool"), - ("fallback_async_adapted_queue", 10, "FallbackAsyncAdaptedQueuePool"), + pytest.param( + "fallback_async_adapted_queue", + 10, + "FallbackAsyncAdaptedQueuePool", + marks=pytest.mark.skipif( + not hasattr(sqlalchemy.pool, "FallbackAsyncAdaptedQueuePool"), + reason="SQLAlchemy 2.1 removed FallbackAsyncAdaptedQueuePool", + ), + ), ("null", 10, "NullPool"), ("static", 1, "StaticPool"), ],