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
12 changes: 11 additions & 1 deletion .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,19 @@ jobs:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install 3.13
- run: uv python pin 3.13
- run: just install lint-ci

pytest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
services:
postgres:
image: postgres:latest
Expand All @@ -34,7 +43,8 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
- run: uv python install 3.13
- run: uv python install ${{ matrix.python-version }}
- run: uv python pin ${{ matrix.python-version }}
- run: |
uv sync --all-extras --no-install-project
uv run --no-sync pytest . --cov=. --cov-report xml
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export DB_RETRY_RETRIES_NUMBER=5

## Requirements

- Python 3.13+
- Python 3.11+
- SQLAlchemy with asyncio support
- asyncpg PostgreSQL driver
- tenacity for retry logic
Expand Down
4 changes: 3 additions & 1 deletion architecture/retry.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ async def handler(...) -> ...: ...

Two `typing.overload`s back the dual form: called with a function it returns the
wrapped function; called with `func=None` (i.e. `@postgres_retry(...)`) it
returns a decorator. The wrapped function keeps its signature via
returns a decorator. The generics are expressed with module-level `typing.ParamSpec`/`TypeVar`
and `TypeAlias` aliases (`_Func`, `_Decorator`) rather than PEP 695 syntax,
so the module parses on Python 3.11+. The wrapped function keeps its signature via
`functools.wraps`. `retries` defaults to `None`, which defers to
[`settings.get_retries_number()`](settings.md) **at call time** — so the env var
is read per invocation, not frozen at decoration.
Expand Down
17 changes: 10 additions & 7 deletions db_retry/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,26 @@ def _log_and_decide(exception: BaseException) -> bool:
return False


type _Func[**P, T] = typing.Callable[P, typing.Coroutine[None, None, T]]
type _Decorator[**P, T] = typing.Callable[[_Func[P, T]], _Func[P, T]]
P = typing.ParamSpec("P")
T = typing.TypeVar("T")

_Func: typing.TypeAlias = typing.Callable[P, typing.Coroutine[None, None, T]]
_Decorator: typing.TypeAlias = typing.Callable[[_Func], _Func]


@typing.overload
def postgres_retry[**P, T](func: _Func[P, T], *, retries: int | None = ...) -> _Func[P, T]: ...
def postgres_retry(func: _Func, *, retries: int | None = ...) -> _Func: ...


@typing.overload
def postgres_retry[**P, T](func: None = ..., *, retries: int | None = ...) -> _Decorator[P, T]: ...
def postgres_retry(func: None = ..., *, retries: int | None = ...) -> _Decorator: ...


def postgres_retry[**P, T](
func: _Func[P, T] | None = None,
def postgres_retry(
func: _Func | None = None,
*,
retries: int | None = None,
) -> _Func[P, T] | _Decorator[P, T]:
) -> _Func | _Decorator:
def decorator(f: _Func[P, T]) -> _Func[P, T]:
@functools.wraps(f)
async def wrapped_method(*args: P.args, **kwargs: P.kwargs) -> T:
Expand Down
135 changes: 135 additions & 0 deletions planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
summary: Lower the supported Python floor from 3.13 to 3.11, rewriting retry.py off PEP 695 and proving 3.11-3.14 in a CI matrix.
---

# Design: Add Python 3.11 / 3.12 support

## Summary

Lower the package's supported Python floor from 3.13 to 3.11. The runtime
dependencies (tenacity, sqlalchemy, asyncpg) already support 3.11+, so the only
source change is `retry.py`, which uses PEP 695 generic syntax that is a syntax
error before 3.12. We rewrite those declarations to the pre-695 stdlib
`ParamSpec`/`TypeVar`/`TypeAlias` form (no new dependency), widen
`requires-python` and the classifiers, lower the ruff target, and add a CI
pytest matrix over 3.11-3.14 that proves every supported version at runtime.
This mirrors the established `faststream-redis-timers` convention in the same
org.

## Motivation

`postgres_retry` and friends have no runtime feature that requires 3.13 — the
floor is incidental, set by the PEP 695 syntax in `retry.py`. Widening to 3.11
broadens adoption (3.11 and 3.12 are still widely deployed) at the cost of one
mechanical typing rewrite. The sibling `faststream-redis-timers` package already
ships `requires-python = ">=3.11,<4"` with a 3.11-3.14 CI matrix; aligning
`db-retry` keeps the org's packages consistent.

## Non-goals

- No runtime behavior change: the retry/failover/transaction semantics are
identical on every version.
- No new dependency: `typing_extensions` is not needed — `ParamSpec`,
`TypeVar`, and `TypeAlias` are stdlib since 3.10.
- No new tests and no version-gated code branches: the existing suite runs
unchanged across the matrix.

## Design

### 1. `retry.py` — rewrite off PEP 695

The current module uses two 3.12+ constructs that are hard syntax errors on
3.11: the `type` alias statement and generic-function syntax (`def f[**P, T]`).

Today (3.12+ only):

```python
type _Func[**P, T] = typing.Callable[P, typing.Coroutine[None, None, T]]
type _Decorator[**P, T] = typing.Callable[[_Func[P, T]], _Func[P, T]]

@typing.overload
def postgres_retry[**P, T](func: _Func[P, T], *, retries: int | None = ...) -> _Func[P, T]: ...
```

Rewritten (3.11-compatible), keeping the named aliases:

```python
P = typing.ParamSpec("P")
T = typing.TypeVar("T")

_Func: typing.TypeAlias = typing.Callable[P, typing.Coroutine[None, None, T]]
_Decorator: typing.TypeAlias = typing.Callable[[_Func], _Func]

@typing.overload
def postgres_retry(func: _Func, *, retries: int | None = ...) -> _Func: ...
@typing.overload
def postgres_retry(func: None = ..., *, retries: int | None = ...) -> _Decorator: ...
def postgres_retry(func: _Func | None = None, *, retries: int | None = None) -> _Func | _Decorator:
... # body unchanged (lines 39-55)
```

`P` and `T` move to module scope; the bare aliases re-bind them per signature,
which is exactly how pre-695 generic aliases work. The function body — the
`tenacity.AsyncRetrying` construction and the `decorator`/`wrapped_method`
nesting — is untouched. The two public call forms (`@postgres_retry` and
`@postgres_retry(retries=N)`) and their inferred types are preserved.

### 2. `pyproject.toml`

- `requires-python = ">=3.13,<4"` -> `">=3.11,<4"`.
- Add classifiers `Programming Language :: Python :: 3.11` and `:: 3.12` (the
3.13 / 3.14 entries stay).
- `[tool.ruff] target-version = "py313"` -> `"py311"`, so lint catches any
3.11-incompatible syntax introduced later (and stops suggesting 3.12+-only
upgrades).

### 3. CI — `.github/workflows/_checks.yml`

The `pytest` job gains a version matrix mirroring `faststream-redis-timers`:

```yaml
pytest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
services:
postgres:
... # unchanged
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
- run: uv python install ${{ matrix.python-version }}
- run: |
uv sync --all-extras --no-install-project
uv run --no-sync pytest . --cov=. --cov-report xml
env:
... # unchanged
```

The `lint` job stays pinned to `uv python install 3.13` (single-version gate).

### 4. Docs

- `README.md:216`: "Python 3.13+" -> "Python 3.11+". The pyversions badge is
driven by PyPI metadata and updates automatically.
- Promote into `architecture/retry.md` in the implementing PR (note the
pre-695 typing form so the capability page stays code-current).

## Testing

- `just lint-ci` (ruff `--check` + `ty check`) passes with `target-version =
py311`; `ty check` confirms the rewritten overloads still type-check.
- `uv run pytest` passes locally against `DB_DSN`.
- CI proves runtime correctness on 3.11, 3.12, 3.13, and 3.14, each holding the
existing `--cov-fail-under=100` gate.

## Risk

- **Low: the rewritten generic aliases type-check differently than PEP 695.**
Mitigation: `ty check` in the lint gate plus the unchanged overload tests
catch any inference regression before merge.
- **Low: a transitive dependency drops 3.11 wheels.** Mitigation: the 3.11 CI
leg fails loudly at `uv sync` if so; deps are unpinned and currently all
publish 3.11 wheels.
Loading