Skip to content

Commit 82e3661

Browse files
lesnik512claude
andauthored
feat: support Python 3.11 and 3.12 (#26)
* docs: design for Python 3.11/3.12 support Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * docs: implementation plan for Python 3.11/3.12 support Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * refactor: express postgres_retry generics pre-PEP-695 for 3.11 * chore: lower supported Python floor to 3.11 Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * ci: run pytest across Python 3.11-3.14 * ci: pin matrix interpreter with uv python pin uv python install only downloads the interpreter; uv python pin writes .python-version so uv sync/run actually selects it. --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 4d045e0 commit 82e3661

7 files changed

Lines changed: 501 additions & 12 deletions

File tree

.github/workflows/_checks.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,19 @@ jobs:
1313
enable-cache: true
1414
cache-dependency-glob: "**/pyproject.toml"
1515
- run: uv python install 3.13
16+
- run: uv python pin 3.13
1617
- run: just install lint-ci
1718

1819
pytest:
1920
runs-on: ubuntu-latest
21+
strategy:
22+
fail-fast: false
23+
matrix:
24+
python-version:
25+
- "3.11"
26+
- "3.12"
27+
- "3.13"
28+
- "3.14"
2029
services:
2130
postgres:
2231
image: postgres:latest
@@ -34,7 +43,8 @@ jobs:
3443
steps:
3544
- uses: actions/checkout@v6
3645
- uses: astral-sh/[email protected]
37-
- run: uv python install 3.13
46+
- run: uv python install ${{ matrix.python-version }}
47+
- run: uv python pin ${{ matrix.python-version }}
3848
- run: |
3949
uv sync --all-extras --no-install-project
4050
uv run --no-sync pytest . --cov=. --cov-report xml

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export DB_RETRY_RETRIES_NUMBER=5
213213

214214
## Requirements
215215

216-
- Python 3.13+
216+
- Python 3.11+
217217
- SQLAlchemy with asyncio support
218218
- asyncpg PostgreSQL driver
219219
- tenacity for retry logic

architecture/retry.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ async def handler(...) -> ...: ...
1616

1717
Two `typing.overload`s back the dual form: called with a function it returns the
1818
wrapped function; called with `func=None` (i.e. `@postgres_retry(...)`) it
19-
returns a decorator. The wrapped function keeps its signature via
19+
returns a decorator. The generics are expressed with module-level `typing.ParamSpec`/`TypeVar`
20+
and `TypeAlias` aliases (`_Func`, `_Decorator`) rather than PEP 695 syntax,
21+
so the module parses on Python 3.11+. The wrapped function keeps its signature via
2022
`functools.wraps`. `retries` defaults to `None`, which defers to
2123
[`settings.get_retries_number()`](settings.md) **at call time** — so the env var
2224
is read per invocation, not frozen at decoration.

db_retry/retry.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,26 @@ def _log_and_decide(exception: BaseException) -> bool:
1919
return False
2020

2121

22-
type _Func[**P, T] = typing.Callable[P, typing.Coroutine[None, None, T]]
23-
type _Decorator[**P, T] = typing.Callable[[_Func[P, T]], _Func[P, T]]
22+
P = typing.ParamSpec("P")
23+
T = typing.TypeVar("T")
24+
25+
_Func: typing.TypeAlias = typing.Callable[P, typing.Coroutine[None, None, T]]
26+
_Decorator: typing.TypeAlias = typing.Callable[[_Func], _Func]
2427

2528

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

2932

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

3336

34-
def postgres_retry[**P, T](
35-
func: _Func[P, T] | None = None,
37+
def postgres_retry(
38+
func: _Func | None = None,
3639
*,
3740
retries: int | None = None,
38-
) -> _Func[P, T] | _Decorator[P, T]:
41+
) -> _Func | _Decorator:
3942
def decorator(f: _Func[P, T]) -> _Func[P, T]:
4043
@functools.wraps(f)
4144
async def wrapped_method(*args: P.args, **kwargs: P.kwargs) -> T:
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
---
2+
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.
3+
---
4+
5+
# Design: Add Python 3.11 / 3.12 support
6+
7+
## Summary
8+
9+
Lower the package's supported Python floor from 3.13 to 3.11. The runtime
10+
dependencies (tenacity, sqlalchemy, asyncpg) already support 3.11+, so the only
11+
source change is `retry.py`, which uses PEP 695 generic syntax that is a syntax
12+
error before 3.12. We rewrite those declarations to the pre-695 stdlib
13+
`ParamSpec`/`TypeVar`/`TypeAlias` form (no new dependency), widen
14+
`requires-python` and the classifiers, lower the ruff target, and add a CI
15+
pytest matrix over 3.11-3.14 that proves every supported version at runtime.
16+
This mirrors the established `faststream-redis-timers` convention in the same
17+
org.
18+
19+
## Motivation
20+
21+
`postgres_retry` and friends have no runtime feature that requires 3.13 — the
22+
floor is incidental, set by the PEP 695 syntax in `retry.py`. Widening to 3.11
23+
broadens adoption (3.11 and 3.12 are still widely deployed) at the cost of one
24+
mechanical typing rewrite. The sibling `faststream-redis-timers` package already
25+
ships `requires-python = ">=3.11,<4"` with a 3.11-3.14 CI matrix; aligning
26+
`db-retry` keeps the org's packages consistent.
27+
28+
## Non-goals
29+
30+
- No runtime behavior change: the retry/failover/transaction semantics are
31+
identical on every version.
32+
- No new dependency: `typing_extensions` is not needed — `ParamSpec`,
33+
`TypeVar`, and `TypeAlias` are stdlib since 3.10.
34+
- No new tests and no version-gated code branches: the existing suite runs
35+
unchanged across the matrix.
36+
37+
## Design
38+
39+
### 1. `retry.py` — rewrite off PEP 695
40+
41+
The current module uses two 3.12+ constructs that are hard syntax errors on
42+
3.11: the `type` alias statement and generic-function syntax (`def f[**P, T]`).
43+
44+
Today (3.12+ only):
45+
46+
```python
47+
type _Func[**P, T] = typing.Callable[P, typing.Coroutine[None, None, T]]
48+
type _Decorator[**P, T] = typing.Callable[[_Func[P, T]], _Func[P, T]]
49+
50+
@typing.overload
51+
def postgres_retry[**P, T](func: _Func[P, T], *, retries: int | None = ...) -> _Func[P, T]: ...
52+
```
53+
54+
Rewritten (3.11-compatible), keeping the named aliases:
55+
56+
```python
57+
P = typing.ParamSpec("P")
58+
T = typing.TypeVar("T")
59+
60+
_Func: typing.TypeAlias = typing.Callable[P, typing.Coroutine[None, None, T]]
61+
_Decorator: typing.TypeAlias = typing.Callable[[_Func], _Func]
62+
63+
@typing.overload
64+
def postgres_retry(func: _Func, *, retries: int | None = ...) -> _Func: ...
65+
@typing.overload
66+
def postgres_retry(func: None = ..., *, retries: int | None = ...) -> _Decorator: ...
67+
def postgres_retry(func: _Func | None = None, *, retries: int | None = None) -> _Func | _Decorator:
68+
... # body unchanged (lines 39-55)
69+
```
70+
71+
`P` and `T` move to module scope; the bare aliases re-bind them per signature,
72+
which is exactly how pre-695 generic aliases work. The function body — the
73+
`tenacity.AsyncRetrying` construction and the `decorator`/`wrapped_method`
74+
nesting — is untouched. The two public call forms (`@postgres_retry` and
75+
`@postgres_retry(retries=N)`) and their inferred types are preserved.
76+
77+
### 2. `pyproject.toml`
78+
79+
- `requires-python = ">=3.13,<4"` -> `">=3.11,<4"`.
80+
- Add classifiers `Programming Language :: Python :: 3.11` and `:: 3.12` (the
81+
3.13 / 3.14 entries stay).
82+
- `[tool.ruff] target-version = "py313"` -> `"py311"`, so lint catches any
83+
3.11-incompatible syntax introduced later (and stops suggesting 3.12+-only
84+
upgrades).
85+
86+
### 3. CI — `.github/workflows/_checks.yml`
87+
88+
The `pytest` job gains a version matrix mirroring `faststream-redis-timers`:
89+
90+
```yaml
91+
pytest:
92+
runs-on: ubuntu-latest
93+
strategy:
94+
fail-fast: false
95+
matrix:
96+
python-version: ["3.11", "3.12", "3.13", "3.14"]
97+
services:
98+
postgres:
99+
... # unchanged
100+
steps:
101+
- uses: actions/checkout@v6
102+
- uses: astral-sh/[email protected]
103+
- run: uv python install ${{ matrix.python-version }}
104+
- run: |
105+
uv sync --all-extras --no-install-project
106+
uv run --no-sync pytest . --cov=. --cov-report xml
107+
env:
108+
... # unchanged
109+
```
110+
111+
The `lint` job stays pinned to `uv python install 3.13` (single-version gate).
112+
113+
### 4. Docs
114+
115+
- `README.md:216`: "Python 3.13+" -> "Python 3.11+". The pyversions badge is
116+
driven by PyPI metadata and updates automatically.
117+
- Promote into `architecture/retry.md` in the implementing PR (note the
118+
pre-695 typing form so the capability page stays code-current).
119+
120+
## Testing
121+
122+
- `just lint-ci` (ruff `--check` + `ty check`) passes with `target-version =
123+
py311`; `ty check` confirms the rewritten overloads still type-check.
124+
- `uv run pytest` passes locally against `DB_DSN`.
125+
- CI proves runtime correctness on 3.11, 3.12, 3.13, and 3.14, each holding the
126+
existing `--cov-fail-under=100` gate.
127+
128+
## Risk
129+
130+
- **Low: the rewritten generic aliases type-check differently than PEP 695.**
131+
Mitigation: `ty check` in the lint gate plus the unchanged overload tests
132+
catch any inference regression before merge.
133+
- **Low: a transitive dependency drops 3.11 wheels.** Mitigation: the 3.11 CI
134+
leg fails loudly at `uv sync` if so; deps are unpinned and currently all
135+
publish 3.11 wheels.

0 commit comments

Comments
 (0)