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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,28 @@ 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
# by its job id, and only those may end skipped.
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
Expand Down
12 changes: 7 additions & 5 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions sqlalchemy_foundation_kit/base/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

from typing import TYPE_CHECKING, ClassVar, Literal

import sqlalchemy.pool
from sqlalchemy.pool import (
AsyncAdaptedQueuePool,
FallbackAsyncAdaptedQueuePool,
NullPool,
QueuePool,
SingletonThreadPool,
Expand Down Expand Up @@ -52,7 +52,6 @@ class PoolRegistry:
"queue": QueuePool,
"singleton_thread": SingletonThreadPool,
"async_adapted_queue": AsyncAdaptedQueuePool,
"fallback_async_adapted_queue": FallbackAsyncAdaptedQueuePool,
"static": StaticPool,
}

Expand Down Expand Up @@ -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.

Expand Down
9 changes: 5 additions & 4 deletions sqlalchemy_foundation_kit/contrib/settings/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 50 additions & 4 deletions tests/unit/base/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
# ============================================================================
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion tests/unit/session/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
],
Expand Down
Loading