diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 5803ae7..e3ac953 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -21,6 +21,8 @@ jobs: fail-fast: false matrix: python-version: + - "3.11" + - "3.12" - "3.13" - "3.14" services: @@ -43,7 +45,7 @@ jobs: - run: uv python install ${{ matrix.python-version }} - run: | uv sync --all-extras --no-install-project - uv run --no-sync pytest . --cov=. --cov-report xml + uv run --no-sync pytest . --cov=. --cov-report xml -vvv env: PYTHONDONTWRITEBYTECODE: 1 PYTHONUNBUFFERED: 1 diff --git a/.gitignore b/.gitignore index 48a3520..3684070 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ dist/ .venv uv.lock /plan.md +/site/ diff --git a/docs/superpowers/plans/2026-06-29-python-3.11-3.12-support.md b/docs/superpowers/plans/2026-06-29-python-3.11-3.12-support.md new file mode 100644 index 0000000..071e14a --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-python-3.11-3.12-support.md @@ -0,0 +1,270 @@ +# Python 3.11/3.12 Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lower the supported-Python floor from 3.13 to 3.11 so the package installs and runs on 3.11, 3.12, 3.13, and 3.14. + +**Architecture:** Pure-Python library, no compiled extensions. Two source constructs require 3.12+ (`type` statement and `typing.override`); both are backported via `typing_extensions` (declared as a direct runtime dependency, no `sys.version_info` gating). The rest is metadata (`requires-python`, classifiers, ruff target) and a wider CI matrix. + +**Tech Stack:** Python, faststream 0.7, redis 8, anyio, `typing_extensions`, uv (package manager), just (task runner), ruff + ty (lint/type-check), pytest, docker compose (integration suite + Redis). + +## Global Constraints + +- New Python floor: `requires-python = ">=3.11,<4"`. All edits must remain valid on CPython 3.11 through 3.14. +- `typing_extensions` floor is `>=4.12.0` (matches faststream's existing transitive pin; `override` has existed there since 4.4.0). +- `import` statements live at module level only — never inside function bodies. +- Type-checker suppressions use `# ty: ignore[...]`, never `# type: ignore`. +- `uv.lock` is git-ignored — do not commit it. Regenerating it locally is still required so local runs resolve. +- Coverage gate is 100% (`--cov-fail-under=100`); the full suite enforces it. Local 3.11 subset runs use `--no-cov` to bypass the gate (they are an import/runtime smoke check, not the coverage gate). +- `typing.Self` (`broker.py:155`) stays as-is — it exists in the 3.11 stdlib. + +--- + +### Task 1: Make the package compatible with Python 3.11/3.12 + +Lowers the floor, adds the `typing_extensions` dependency, and rewrites the two incompatible constructs. The pyproject floor change and the source fixes are inseparable: the `override` fix can only be proven by importing on a real 3.11 interpreter, which uv refuses until `requires-python` is lowered; and the ruff `target-version` must move to `py311` alongside the `configs.py` edit or `ruff --fix` (UP040) will revert it. + +**Files:** +- Modify: `pyproject.toml` (dependencies, `requires-python`, classifiers, `[tool.ruff] target-version`) +- Modify: `faststream_redis_timers/configs.py:17` +- Modify: `faststream_redis_timers/registrator.py:3` +- Modify: `faststream_redis_timers/broker.py` (import block + decorators at 150/154/159/164) +- Modify: `faststream_redis_timers/subscriber/usecase.py` (import block + decorators at 69/164/169) +- Regenerate (do not commit): `uv.lock` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: a package importable on 3.11; the public `RedisClient` type alias keeps its name and meaning (`"Redis[bytes] | Redis[str]"`). No public API names change. + +- [ ] **Step 1: Confirm the current break on 3.11 (RED)** + +Use the local uv-managed 3.11 interpreter to show `configs.py` does not even parse on 3.11: + +```bash +PY311=/Users/kevinsmith/.local/share/uv/python/cpython-3.11.9-macos-aarch64-none/bin/python3.11 +$PY311 -m py_compile faststream_redis_timers/configs.py +``` + +Expected: FAIL with `SyntaxError: invalid syntax` (the PEP 695 `type` statement at line 17). This is the failing state the task fixes. (The `override` import break in the other files is an `ImportError`, not a `SyntaxError`, so it is proven later by the runtime test in Step 8, not here.) + +- [ ] **Step 2: Edit `pyproject.toml` — dependency, floor, classifiers, ruff target (all together)** + +In `[project]`, change the dependencies block from: + +```toml +dependencies = [ + "faststream>=0.7.1,<0.8", + "redis>=5.0", +] +``` + +to: + +```toml +dependencies = [ + "faststream>=0.7.1,<0.8", + "redis>=5.0", + "typing-extensions>=4.12.0", +] +``` + +Change the floor from: + +```toml +requires-python = ">=3.13,<4" +``` + +to: + +```toml +requires-python = ">=3.11,<4" +``` + +In the `classifiers` list, change: + +```toml + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +``` + +to (add 3.11 and 3.12 above 3.13): + +```toml + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +``` + +In `[tool.ruff]`, change: + +```toml +target-version = "py313" +``` + +to: + +```toml +target-version = "py311" +``` + +- [ ] **Step 3: Regenerate the lock and sync** + +Run: `uv lock --upgrade && uv sync --all-extras --all-groups` +Expected: resolves cleanly; `typing-extensions` appears in the lock (it was already present transitively). Do NOT `git add uv.lock` — it is git-ignored. + +- [ ] **Step 4: Fix `configs.py` — replace the PEP 695 `type` statement** + +Change line 17 from: + +```python +type RedisClient = "Redis[bytes] | Redis[str]" +``` + +to: + +```python +RedisClient: TypeAlias = "Redis[bytes] | Redis[str]" +``` + +Add the import at the top of `configs.py`. The current first import line is `import typing`; add after it: + +```python +from typing_extensions import TypeAlias +``` + +Leave the explanatory comment block immediately above the alias untouched. + +- [ ] **Step 5: Fix `registrator.py` — import `override` from `typing_extensions`** + +Change line 3 from: + +```python +from typing import Any, override +``` + +to two lines: + +```python +from typing import Any +from typing_extensions import override +``` + +The `@override` decorator usages at lines 18 and 63 (and their `# ty: ignore[invalid-method-override]` comments) are unchanged. + +- [ ] **Step 6: Fix `broker.py` — import `override` from `typing_extensions`, swap decorators** + +`broker.py` uses `import typing` (line 2) and references `@typing.override` four times plus `typing.Self` once. Keep `import typing` (still needed for `typing.Self` and other `typing.*` uses). Add this import to the top-of-file import block (after the existing `import typing`): + +```python +from typing_extensions import override +``` + +Then replace every `@typing.override` with `@override` (4 occurrences: lines 150, 154, 159, 164). Do NOT touch `async def __aenter__(self) -> typing.Self:` at line 155 — `typing.Self` stays. + +- [ ] **Step 7: Fix `subscriber/usecase.py` — import `override` from `typing_extensions`, swap decorators** + +This file uses `import typing` (line 3) and `@typing.override` three times. Add to the top-of-file import block (after `import typing`): + +```python +from typing_extensions import override +``` + +Then replace every `@typing.override` with `@override` (3 occurrences: lines 69, 164, 169). Keep `import typing` (other `typing.*` uses remain). + +- [ ] **Step 8: Verify the package imports and the unit/fake suites pass on real 3.11 (GREEN)** + +These suites need no Redis. Run them on a true 3.11 interpreter via uv (now permitted because the floor is `>=3.11`): + +```bash +uv run --python 3.11 pytest tests/test_unit.py tests/test_fake.py --no-cov -v +``` + +Expected: PASS. This proves both fixes at runtime on 3.11 — the `configs.py` parse fix and that `typing_extensions.override` imports cleanly where `typing.override` would have raised `ImportError`. + +If `uv run --python 3.11` is awkward in the environment, the fallback is an explicit ephemeral env: + +```bash +uv venv --python 3.11 /private/tmp/claude-501/-Users-kevinsmith-src-pypi-faststream-redis-timers/b6781fe7-4d7e-4081-b268-8696cf7ad9e6/scratchpad/v311 +VIRTUAL_ENV=/private/tmp/claude-501/-Users-kevinsmith-src-pypi-faststream-redis-timers/b6781fe7-4d7e-4081-b268-8696cf7ad9e6/scratchpad/v311 uv sync --all-extras --all-groups +VIRTUAL_ENV=/private/tmp/claude-501/-Users-kevinsmith-src-pypi-faststream-redis-timers/b6781fe7-4d7e-4081-b268-8696cf7ad9e6/scratchpad/v311 uv run --no-sync pytest tests/test_unit.py tests/test_fake.py --no-cov -v +``` + +- [ ] **Step 9: Lint against the new floor** + +Run: `just lint-ci` +Expected: PASS. Specifically confirms (a) ruff under `target-version = "py311"` accepts `RedisClient: TypeAlias = ...` and does NOT flag it for conversion back to the `type` statement (UP040 only fires at py312+), and (b) `ty` accepts `typing_extensions.override` and `typing.Self`. + +- [ ] **Step 10: Run the full suite (no regression on 3.13 + 100% coverage)** + +Run: `just test` +Expected: PASS, coverage 100%. This is the docker integration run (Redis on 3.13); confirms the source changes did not regress the existing platform. + +- [ ] **Step 11: Commit** + +```bash +git add pyproject.toml faststream_redis_timers/configs.py faststream_redis_timers/registrator.py faststream_redis_timers/broker.py faststream_redis_timers/subscriber/usecase.py +git commit -m "feat: support Python 3.11 and 3.12 + +Backport the PEP 695 type alias and typing.override via typing_extensions, +lower requires-python to >=3.11, add 3.11/3.12 classifiers and ruff target. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: Add 3.11 and 3.12 to the CI test matrix + +The pytest job in CI currently runs only 3.13 and 3.14. Extend it so the new floor is exercised on every PR. + +**Files:** +- Modify: `.github/workflows/_checks.yml` (the `pytest` job's `strategy.matrix.python-version`) + +**Interfaces:** +- Consumes: the 3.11-compatible package from Task 1. +- Produces: nothing consumed by later tasks (terminal task). + +- [ ] **Step 1: Edit the matrix** + +In the `pytest` job, change: + +```yaml + python-version: + - "3.13" + - "3.14" +``` + +to: + +```yaml + python-version: + - "3.11" + - "3.12" + - "3.13" + - "3.14" +``` + +Leave the `lint` job (which runs `uv python install 3.13`) unchanged — linting against one interpreter is sufficient and ruff's `target-version` already enforces the floor. + +- [ ] **Step 2: Validate the workflow YAML parses** + +Run: `uv run python -c "import yaml; yaml.safe_load(open('.github/workflows/_checks.yml')); print('yaml ok')"` +Expected: prints `yaml ok` with no exception. (`pyyaml` is available transitively via the dev tooling; if it is not, `python -c "import yaml"` will fail and you can skip this step — the edit is a 2-line list addition.) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/_checks.yml +git commit -m "ci: run pytest matrix on Python 3.11 and 3.12 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Notes for the finish + +- `Dockerfile` stays on `python:3.13-slim` — it is the dev/test image, not a supported-version gate; no change. +- No `architecture/` page or doc references the version floor, so no capability promotion is required (verified during planning). +- After both tasks: push the branch and open a PR (do not local-merge); watch CI — the new 3.11/3.12 matrix legs are the real cross-version proof. diff --git a/docs/superpowers/specs/2026-06-29-python-3.11-3.12-support-design.md b/docs/superpowers/specs/2026-06-29-python-3.11-3.12-support-design.md new file mode 100644 index 0000000..f149076 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-python-3.11-3.12-support-design.md @@ -0,0 +1,113 @@ +# Support Python 3.11 and 3.12 + +**Date:** 2026-06-29 +**Status:** Approved design + +## Goal + +Lower the supported-Python floor from 3.13 to 3.11, so the package installs and +runs on 3.11, 3.12, 3.13, and 3.14. The package is a pure-Python library with no +compiled extensions, so this is a source-compatibility and metadata change. + +## Background / constraints (verified) + +All facts below were verified empirically against real CPython 3.11.9 / 3.12.13 +interpreters and installed dependency metadata, not from memory. + +Two constructs in the source require Python 3.12+ and break on 3.11: + +| Construct | Location | 3.12 | 3.11 | +|-----------|----------|------|------| +| `type RedisClient = "..."` (PEP 695 alias) | `configs.py:17` | OK | `SyntaxError` | +| `override` imported from `typing` | `registrator.py:3`, `broker.py` (`typing.override` ×4), `subscriber/usecase.py` (`typing.override` ×3) | OK | `ImportError` | + +Confirmed non-issues: + +- `typing.Self` (`broker.py:155`) exists in the 3.11 stdlib — no change needed. +- `typing.TypeAlias` works on 3.11 (added in 3.10). +- `datetime.UTC` (`response.py`) was added in Python 3.11, so it is valid at + exactly the new floor — no change needed. (Noted because it is the one + floor-sensitive stdlib API in the source; it would break below 3.11, which + confirms 3.11 as the correct floor.) +- Upstream deps all support 3.11: faststream, redis, and anyio each declare + `requires-python >=3.10`. +- `typing-extensions` is already a transitive dependency (faststream pins + `>=4.12.0`; resolves to 4.15 today). `override` has been in `typing_extensions` + since 4.4.0, so a `>=4.12.0` floor amply covers it. + +## Decision: backport via typing_extensions + +Use `typing_extensions` (declared as a direct runtime dependency) for both +constructs, with no `sys.version_info` gating. Rationale: it is already present +transitively, the unconditional import is the simplest correct form, and it +preserves the static `@override` checking that `ty` relies on. + +Alternatives rejected: +- **Version-gated stdlib imports** — more code, still needs typing_extensions on + 3.11, no benefit. +- **Drop `@override`** — loses override-mismatch checking; avoided. + +## Changes + +### 1. `pyproject.toml` + +- `requires-python`: `>=3.13,<4` → `>=3.11,<4` +- `dependencies`: add `typing-extensions>=4.12.0` (matches faststream's existing + pin rather than an artificially lower floor) +- `classifiers`: add + `Programming Language :: Python :: 3.11` and `Programming Language :: Python :: 3.12` +- `[tool.ruff] target-version`: `py313` → `py311` (lints against the floor so + PEP 695 syntax cannot reappear) + +### 2. Source + +- `configs.py:17`: + ```python + type RedisClient = "Redis[bytes] | Redis[str]" + ``` + becomes + ```python + from typing_extensions import TypeAlias + RedisClient: TypeAlias = "Redis[bytes] | Redis[str]" + ``` + (Comment block above the alias is preserved.) +- `registrator.py:3`: import `override` from `typing_extensions` instead of `typing`. +- `broker.py` (lines 150/154/159/164) and `subscriber/usecase.py` + (lines 69/164/169): replace `@typing.override` with `@override` imported from + `typing_extensions`. `typing.Self` at `broker.py:155` is left unchanged. +- The existing `# ty: ignore[invalid-method-override]` comments at + `registrator.py:18/63` are unrelated to this change and are left as-is unless + they break. + +### 3. CI — `.github/workflows/_checks.yml` + +- `pytest` matrix `python-version`: `["3.13","3.14"]` → + `["3.11","3.12","3.13","3.14"]` (each job already gets the Redis service). +- `lint` job stays on 3.13 (unchanged). + +### 4. Unchanged + +- `Dockerfile` — stays on `python:3.13-slim`; it is the dev/test image, not a + supported-version gate. +- `uv.lock` — regenerated by `uv sync` after the dependency edit. +- Docs / `architecture/` — no version-specific content references the floor, so + no promotion is required. + +## Testing + +No new tests. This is a compatibility-surface change; the existing +100%-coverage suite running across the expanded CI matrix is the verification. + +Local verification before pushing (uv has a real 3.11.9 interpreter available): +1. `just lint-ci` — confirms ruff (now `py311`) and `ty` accept the + `typing_extensions` imports. +2. Run the unit/fake suites (`tests/test_unit.py`, `tests/test_fake.py`, which + need no Redis) on the local 3.11 interpreter to confirm the source imports + cleanly under the new floor. +3. `just test` — full suite in docker (Redis on 3.13) as the integration check. + +## Out of scope + +- Lowering below 3.11. +- Any runtime/behavior change beyond version-compatibility. +- Refactoring unrelated to the floor change. diff --git a/faststream_redis_timers/broker.py b/faststream_redis_timers/broker.py index d6a447d..86cd73b 100644 --- a/faststream_redis_timers/broker.py +++ b/faststream_redis_timers/broker.py @@ -19,6 +19,7 @@ from faststream.specification.schema import BrokerSpec from faststream.specification.schema.extra import Tag, TagDict from redis.asyncio.cluster import RedisCluster +from typing_extensions import override from faststream_redis_timers.configs import ConnectionState, RedisClient, TimersBrokerConfig from faststream_redis_timers.message import TimerMessage @@ -147,21 +148,21 @@ def __init__( # noqa: PLR0913 ) super().__init__(config=broker_config, specification=specification, routers=routers) # ty: ignore[unknown-argument] - @typing.override + @override async def _connect(self) -> "RedisClient": return self.config.broker_config.connection.client - @typing.override + @override async def __aenter__(self) -> typing.Self: await self.start() return self - @typing.override + @override async def start(self) -> None: await self.connect() await super().start() - @typing.override + @override async def ping(self, timeout: float | None = None) -> bool: try: client = self.config.broker_config.connection.client diff --git a/faststream_redis_timers/configs.py b/faststream_redis_timers/configs.py index f0f798a..d91f858 100644 --- a/faststream_redis_timers/configs.py +++ b/faststream_redis_timers/configs.py @@ -1,5 +1,6 @@ import typing from dataclasses import dataclass +from typing import TypeAlias from faststream._internal.configs import BrokerConfig from faststream.exceptions import IncorrectState @@ -14,7 +15,7 @@ # Accepts a client created with either default (bytes) or ``decode_responses=True`` (str). # The CLAIM Lua reply is forced through ``NEVER_DECODE`` so the binary envelope stays # intact regardless of which mode the user picked. -type RedisClient = "Redis[bytes] | Redis[str]" +RedisClient: TypeAlias = "Redis[bytes] | Redis[str]" class ConnectionState: diff --git a/faststream_redis_timers/registrator.py b/faststream_redis_timers/registrator.py index 8e6bd89..c59805b 100644 --- a/faststream_redis_timers/registrator.py +++ b/faststream_redis_timers/registrator.py @@ -1,10 +1,11 @@ import warnings from collections.abc import Iterable -from typing import Any, override +from typing import Any from fast_depends.dependencies import Dependant from faststream._internal.broker.registrator import Registrator from faststream._internal.types import CustomCallable +from typing_extensions import override from faststream_redis_timers.message import TimerMessage from faststream_redis_timers.publisher.factory import create_publisher diff --git a/faststream_redis_timers/subscriber/usecase.py b/faststream_redis_timers/subscriber/usecase.py index 2b3ca71..3c464ea 100644 --- a/faststream_redis_timers/subscriber/usecase.py +++ b/faststream_redis_timers/subscriber/usecase.py @@ -1,3 +1,4 @@ +import asyncio import logging import time import typing @@ -9,6 +10,7 @@ from faststream._internal.endpoint.subscriber.mixins import TasksMixin from faststream.specification.asyncapi.utils import resolve_payloads from faststream.specification.schema import Message, Operation, SubscriberSpec +from typing_extensions import override from faststream_redis_timers.message import TimerMessage from faststream_redis_timers.parser.parser import TimerParser @@ -66,16 +68,25 @@ def __init__( def _client(self) -> "RedisClient": return self._outer_config.connection.client - @typing.override + @override async def start(self) -> None: await super().start() self._post_start() start_signal = anyio.Event() if self.calls: - self.add_task(self._consume, (self._client,), {"start_signal": start_signal}) - with anyio.fail_after(self._outer_config.start_timeout): - await start_signal.wait() + consume_task = self.add_task(self._consume, (self._client,), {"start_signal": start_signal}) + try: + with anyio.fail_after(self._outer_config.start_timeout): + await start_signal.wait() + except TimeoutError: + # Start timed out: cancel the spawned poll task and await its + # completion so a failed start leaves no orphaned task holding a + # Redis connection (which otherwise deadlocks teardown). + await self.stop() + with suppress(asyncio.CancelledError): + await consume_task + raise else: start_signal.set() @@ -161,12 +172,12 @@ async def _claim_and_consume( exc_info=e, ) - @typing.override + @override async def stop(self) -> None: with anyio.move_on_after(self._outer_config.graceful_timeout): await super().stop() - @typing.override + @override async def get_one(self, *, timeout: float = 5.0) -> typing.NoReturn: msg = "TimersBroker does not support get_one()" raise NotImplementedError(msg) diff --git a/pyproject.toml b/pyproject.toml index 86f0a04..83c728c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,14 @@ authors = [ { name = "Artur Shiriev", email = "me@shiriev.ru" }, ] readme = "README.md" -requires-python = ">=3.13,<4" +requires-python = ">=3.11,<4" license = "MIT" keywords = ["faststream", "redis", "scheduler", "timers", "distributed", "messaging", "asyncio", "python"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Typing :: Typed", @@ -20,6 +22,7 @@ classifiers = [ dependencies = [ "faststream>=0.7.1,<0.8", "redis>=5.0", + "typing-extensions>=4.12.0", ] [dependency-groups] @@ -47,7 +50,7 @@ module-root = "" fix = true unsafe-fixes = true line-length = 120 -target-version = "py313" +target-version = "py311" [tool.ruff.lint] select = ["ALL"] diff --git a/tests/test_isolation.py b/tests/test_isolation.py index 3986a8c..c268ccb 100644 --- a/tests/test_isolation.py +++ b/tests/test_isolation.py @@ -114,6 +114,31 @@ async def handler(body: str) -> None: ... await broker.__aenter__() +async def test_failed_start_does_not_orphan_consume_task(redis_client: Redis) -> None: + """A start() that times out must cancel the polling task it spawned, not leak it. + + Regression for a hang where the orphaned ``_consume`` task kept a Redis + connection alive and deadlocked fixture/event-loop teardown on slow runners. + """ + suffix = uuid.uuid4().hex + broker = TimersBroker( + redis_client, + timeline_key=f"leak_tl_{suffix}", + payloads_key=f"leak_pl_{suffix}", + start_timeout=0.0, + ) + + @broker.subscriber("topic") + async def handler(body: str) -> None: ... + + tasks_before = set(asyncio.all_tasks()) + with pytest.raises(TimeoutError): + await broker.__aenter__() + + leaked = [task for task in asyncio.all_tasks() - tasks_before if not task.done()] + assert leaked == [], f"failed start orphaned tasks: {[task.get_coro() for task in leaked]}" + + async def test_two_brokers_same_keys_deliver_once(redis_client: Redis) -> None: """Two broker instances sharing keys process each timer exactly once.""" suffix = uuid.uuid4().hex