diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index a87943b..e961e7c 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -14,6 +14,7 @@ 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: @@ -22,6 +23,8 @@ jobs: fail-fast: false matrix: python-version: + - "3.11" + - "3.12" - "3.13" - "3.14" services: @@ -45,6 +48,7 @@ jobs: enable-cache: true cache-dependency-glob: "**/pyproject.toml" - 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 diff --git a/faststream_outbox/broker.py b/faststream_outbox/broker.py index 3640544..4578537 100644 --- a/faststream_outbox/broker.py +++ b/faststream_outbox/broker.py @@ -28,6 +28,7 @@ from faststream.specification.schema.extra import Tag, TagDict from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession +from typing_extensions import override from faststream_outbox.client import AbstractOutboxClient, OutboxClient, _row_to_message from faststream_outbox.configs import LastExceptionRenderer, OutboxBrokerConfig @@ -201,7 +202,7 @@ def client(self) -> AbstractOutboxClient: raise RuntimeError(msg) return client - @typing.override + @override async def _connect(self) -> "AsyncEngine": engine = self.config.broker_config.engine if engine is None: @@ -209,7 +210,7 @@ async def _connect(self) -> "AsyncEngine": raise IncorrectState(msg) return engine - @typing.override + @override async def __aenter__(self) -> typing.Self: # Upstream equivalent (replaced): # BrokerUsecase.__aenter__ -> faststream/_internal/broker/broker.py @@ -219,7 +220,7 @@ 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() @@ -290,7 +291,7 @@ def _warn_on_unstarted_foreign_publishers(self) -> None: queues, ) - @typing.override + @override async def stop(self, *_args: object, **_kwargs: object) -> None: # Concurrent subscriber stop. Sequential parent stop (BrokerUsecase.stop's # ``for sub in subscribers: await sub.stop()``) would give a total bound of @@ -332,7 +333,7 @@ def _log_subscriber_stop_error(self, sub: object, exc: BaseException) -> None: exc_info=exc, ) - @typing.override + @override async def ping(self, timeout: float | None = None) -> bool: # ``move_on_after(None)`` is an unbounded scope, so threading the caller's # timeout keeps the historical "wait forever" default while honoring a bound diff --git a/faststream_outbox/publisher/usecase.py b/faststream_outbox/publisher/usecase.py index dba50c2..a43ed0f 100644 --- a/faststream_outbox/publisher/usecase.py +++ b/faststream_outbox/publisher/usecase.py @@ -10,10 +10,10 @@ import datetime as _dt import typing -from typing import override from faststream._internal.endpoint.publisher import PublisherUsecase from sqlalchemy.ext.asyncio import AsyncSession +from typing_extensions import override from faststream_outbox.publisher.config import OutboxPublisherConfig from faststream_outbox.publisher.specification import OutboxPublisherSpecification diff --git a/faststream_outbox/registrator.py b/faststream_outbox/registrator.py index 9bc4bbd..42eb991 100644 --- a/faststream_outbox/registrator.py +++ b/faststream_outbox/registrator.py @@ -1,10 +1,11 @@ import warnings from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any from faststream._internal.broker.registrator import Registrator from faststream._internal.types import CustomCallable from faststream.middlewares import AckPolicy +from typing_extensions import override from faststream_outbox.message import OutboxInnerMessage from faststream_outbox.publisher.factory import create_publisher diff --git a/faststream_outbox/subscriber/usecase.py b/faststream_outbox/subscriber/usecase.py index 46053eb..57e116b 100644 --- a/faststream_outbox/subscriber/usecase.py +++ b/faststream_outbox/subscriber/usecase.py @@ -36,6 +36,7 @@ from faststream.response.utils import ensure_response from faststream.specification.asyncapi.utils import resolve_payloads from faststream.specification.schema import Message, Operation, SubscriberSpec +from typing_extensions import override from faststream_outbox.message import OutboxInnerMessage from faststream_outbox.parser.parser import OutboxParser @@ -229,7 +230,7 @@ def _emit_metric(self, event: str, tags: Mapping[str, typing.Any]) -> None: exc_info=exc, ) - @typing.override + @override async def start(self) -> None: await super().start() # Clear the drain flag so a stop()->start() cycle fetches again. Without @@ -244,7 +245,7 @@ async def start(self) -> None: self.add_task(self._worker_loop) self.add_task(self._fetch_loop) - @typing.override + @override async def stop(self) -> None: # Strict-bound drain. We intentionally DON'T call super().stop() because # SubscriberUsecase.stop's MultiLock.wait_release(graceful_timeout) would @@ -801,11 +802,11 @@ async def _flush_retry( return False return True - @typing.override + @override async def get_one(self, *, timeout: float = 5.0) -> typing.NoReturn: raise NotImplementedError(_UNSUPPORTED_PEEK_MSG) - @typing.override + @override async def __aiter__(self) -> AsyncIterator["StreamMessage[OutboxInnerMessage]"]: # Native FakeStream subscribers (e.g. redis ListSubscriber.__aiter__) implement # this against a blocking pop; for the outbox, a true peek would acquire a lease @@ -815,7 +816,7 @@ async def __aiter__(self) -> AsyncIterator["StreamMessage[OutboxInnerMessage]"]: # the override stays a coroutine returning AsyncIterator (not an async generator). raise NotImplementedError(_UNSUPPORTED_PEEK_MSG) - @typing.override + @override async def consume(self, msg: OutboxInnerMessage) -> typing.Any: """Override to propagate ``_OutboxConfigError`` from programming guards. @@ -865,7 +866,7 @@ def _make_response_publisher( # Safe to ignore — those methods are unreachable for response publishers. return (OutboxFakePublisher(producer=self._outer_config.producer),) # ty: ignore[invalid-return-type] - @typing.override + @override async def process_message(self, msg: OutboxInnerMessage) -> "Response": # noqa: C901 """Outbox-specific process_message — header propagation (G3) hook. diff --git a/planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md b/planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md new file mode 100644 index 0000000..59905a4 --- /dev/null +++ b/planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md @@ -0,0 +1,114 @@ +--- +summary: Lower the supported-Python floor from 3.13 to 3.11 by backporting typing.override via typing_extensions; widen the CI matrix to 3.11/3.12. +--- + +# Design: Support Python 3.11 and 3.12 + +## Summary + +`faststream-outbox` pins `requires-python = ">=3.13,<4"`, but its source is +already source-compatible with Python 3.11 save for one construct. This change +lowers the floor to 3.11 so the package installs and runs on 3.11, 3.12, 3.13, +and 3.14. It is a pure-Python library with no compiled extensions, so the work +is a source-compatibility + metadata change plus a wider CI matrix. It mirrors +the change already shipped in the sibling repo `faststream-redis-timers` (#49), +minus a PEP 695 type-alias edit that outbox does not need. + +## Motivation + +The 3.13 floor is stricter than the code requires and excludes the large share +of users still on 3.11/3.12. Empirical scan of the source against a real +CPython 3.11.9 interpreter shows the gap is two backportable `typing` symbols, +both reachable via `typing_extensions`: + +| Symbol | Since | Locations | 3.11 | +|--------|-------|-----------|------| +| `override` | 3.12 | `broker.py` (`@typing.override` ×5), `subscriber/usecase.py` (`@typing.override` ×6), `registrator.py:3` (`from typing import ..., override`), `publisher/usecase.py:13` (`from typing import override`) | `ImportError` / `AttributeError` | +| `get_protocol_members` | 3.13 | `tests/test_fake.py`, `tests/test_unit.py` (protocol-completeness assertions) | `AttributeError` | + +(The `get_protocol_members` use lives in the test suite, which also runs under +the floor on the CI matrix; `ty` surfaced it once the floor dropped. Both +symbols are rerouted through `typing_extensions` by the same unconditional +import; neither requires `sys.version_info` gating.) + +Confirmed non-issues (verified, not assumed): + +- Every source file under `faststream_outbox/` `py_compile`s cleanly on + CPython 3.11.9 — there is **no PEP 695 syntax** (`type X = ...` aliases or + `class Foo[T]` generics) anywhere. +- `asyncio.timeout` (`client.py:433`) and `datetime.UTC` (`_time.py:8`) were + both added in Python 3.11, so they are valid at exactly the new floor. They + are why the floor is **3.11 and not lower** — going below 3.11 would break + them. +- `typing.Self`, `typing.Any`, `typing.TYPE_CHECKING` all exist in 3.11. +- Upstream deps support 3.11: faststream, redis, sqlalchemy, anyio all declare + `requires-python >=3.10` or lower. +- `typing_extensions` is already present transitively (faststream pins + `>=4.12.0`); `override` has lived there since 4.4.0, so a `>=4.12.0` floor + amply covers it. + +## Non-goals + +- No new runtime features or behavior change — same public API, same semantics. +- No change to the dev/test `Dockerfile` Python version (it is a build image, + not a supported-version gate). +- No change to `architecture/` capability pages — none reference the Python + floor. + +## Design + +### 1. Backport `override` via `typing_extensions` + +Declare `typing-extensions>=4.12.0` as a direct runtime dependency and import +`override` from it unconditionally (no `sys.version_info` gating). + +- `broker.py` and `subscriber/usecase.py`: add `from typing_extensions import + override` and replace each `@typing.override` with `@override`. The + `import typing` line stays (still used for `typing.Self`, `typing.Any`, + etc.). +- `registrator.py:3` and `publisher/usecase.py:13`: remove `override` from the + `from typing import ...` line and add `from typing_extensions import + override`. + +The existing `# ty: ignore[invalid-method-override]` comments on the decorated +methods are unrelated to this change and stay as-is. + +**Alternatives rejected:** +- *Version-gated stdlib imports* (`if sys.version_info >= (3, 12): from typing + import override else: ...`) — more code, still needs typing_extensions on + 3.11, no benefit. +- *Drop `@override`* — loses the override-mismatch checking `ty` relies on. + +### 2. `pyproject.toml` metadata + +- `requires-python`: `>=3.13,<4` → `>=3.11,<4` +- `dependencies`: add `typing-extensions>=4.12.0` +- `classifiers`: add `Programming Language :: Python :: 3.11` and `:: 3.12` +- `[tool.ruff] target-version`: `py313` → `py311` (lint against the floor so + 3.12+-only syntax cannot silently reappear) + +### 3. CI matrix + +`.github/workflows/_checks.yml`: add `"3.11"` and `"3.12"` to the pytest +`python-version` matrix (currently `["3.13","3.14"]`). The lint job stays on +3.13. + +## Testing + +No new tests. This is a compatibility-surface change; verification is the +existing suite (100% coverage gate, `--cov-fail-under=100`) running green +across the widened CI matrix on 3.11, 3.12, 3.13, 3.14. Locally, an +import/runtime smoke check on the uv-managed 3.11 interpreter +(`uv run --python 3.11 --no-sync python -c "import faststream_outbox"` after a +3.11 sync, or a `--no-cov` subset of the no-Postgres suites) confirms the +`override` backport resolves before CI. + +## Risk + +- **Low.** The only code change is the import source of `override`; behavior is + identical on 3.13/3.14 (typing_extensions re-exports the stdlib object). +- A 3.11/3.12-only runtime difference in a dependency could surface in CI — but + all direct deps already advertise 3.10+ support, so this is unlikely. The + widened matrix is exactly what would catch it. +- `uv.lock` must be regenerated locally so resolution succeeds on the lowered + floor; it is git-ignored and not committed. diff --git a/planning/changes/2026-06-30.01-python-3.11-3.12-support/plan.md b/planning/changes/2026-06-30.01-python-3.11-3.12-support/plan.md new file mode 100644 index 0000000..cba3c83 --- /dev/null +++ b/planning/changes/2026-06-30.01-python-3.11-3.12-support/plan.md @@ -0,0 +1,373 @@ +# 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. + +**Spec:** [`design.md`](./design.md) + +**Architecture:** Pure-Python library, no compiled extensions. One source +construct (`override`, added to `typing` in 3.12) is backported via +`typing_extensions` declared as a direct runtime dependency, with no +`sys.version_info` gating. Everything else is metadata (`requires-python`, +classifiers, ruff target) plus a wider CI matrix. + +**Tech Stack:** Python, faststream 0.7, sqlalchemy 2 (asyncio), anyio, +`typing_extensions`, uv (package manager), just (task runner), ruff + ty +(lint/type-check), pytest, docker compose (integration suite + Postgres 17). + +**Branch:** `feat/python-3.11-3.12-support` + +**Commit strategy:** Per-task commits. + +## Global Constraints + +- New Python floor: `requires-python = ">=3.11,<4"`. Every edit must stay valid + on CPython 3.11 through 3.14. +- `typing-extensions>=4.12.0` (matches faststream's existing transitive pin; + `override` has existed in `typing_extensions` since 4.4.0). +- All `import` statements at module top — never inside function bodies. Tests + included. No `# noqa: PLC0415` to keep an import inline; hoist instead. +- Type-checker suppressions use `# ty: ignore[...]`, never `# type: ignore`. +- `uv.lock` is git-ignored — regenerate locally so resolution succeeds, but do + **not** commit it. +- Coverage gate is 100% (`--cov-fail-under=100`) and only the full docker suite + enforces it. Local 3.11 subset runs use `--no-cov` (import/runtime smoke + check, not the coverage gate). +- Leave the existing `# ty: ignore[invalid-method-override]` comments on the + decorated methods untouched — unrelated to this change. +- The uv-managed 3.11 interpreter for local verification: + `/Users/kevinsmith/.local/share/uv/python/cpython-3.11.9-macos-aarch64-none/bin/python3.11` + +--- + +### Task 1: Make the package compatible with Python 3.11/3.12 + +Lower the floor, add the `typing_extensions` dependency, and reroute the +`override` import in the four affected source files. 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 ruff `target-version` must move to `py311` alongside the source +edits so the lint pass validates against the floor. + +**Files:** +- Modify: `pyproject.toml` (dependencies, `requires-python`, classifiers, + `[tool.ruff] target-version`) +- Modify: `faststream_outbox/registrator.py:3` +- Modify: `faststream_outbox/publisher/usecase.py:13` +- Modify: `faststream_outbox/broker.py` (import block + `@typing.override` at + 204/212/222/293/335) +- Modify: `faststream_outbox/subscriber/usecase.py` (import block + + `@typing.override` at 232/247/804/808/818/868) +- Regenerate (do **not** commit): `uv.lock` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: a package importable on 3.11. No public API names change; `override` + keeps its meaning (re-exported stdlib object on 3.12+, backport on 3.11). + +- [ ] **Step 1: Confirm the break on 3.11 (RED)** + + Show that the stdlib `override` import — used directly in two files and as + `typing.override` in two others — does not exist on 3.11: + + ```bash + PY311=/Users/kevinsmith/.local/share/uv/python/cpython-3.11.9-macos-aarch64-none/bin/python3.11 + $PY311 -c "from typing import override" + ``` + + Expected: FAIL with `ImportError: cannot import name 'override' from 'typing'`. + This is the failing state the task fixes. + +- [ ] **Step 2: Edit `pyproject.toml` — floor, dependency, classifiers, ruff target (all together)** + + Change the floor at line 9 from: + + ```toml + requires-python = ">=3.13,<4" + ``` + + to: + + ```toml + requires-python = ">=3.11,<4" + ``` + + In `classifiers` (lines 15-16), add 3.11 and 3.12 above 3.13 so the block reads: + + ```toml + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + ``` + + Change the `dependencies` block (lines 20-23) from: + + ```toml + dependencies = [ + "faststream>=0.7.1,<0.8", + "sqlalchemy[asyncio]>=2.0", + ] + ``` + + to: + + ```toml + dependencies = [ + "faststream>=0.7.1,<0.8", + "sqlalchemy[asyncio]>=2.0", + "typing-extensions>=4.12.0", + ] + ``` + + In `[tool.ruff]` (line 65), change: + + ```toml + target-version = "py313" + ``` + + to: + + ```toml + target-version = "py311" + ``` + +- [ ] **Step 3: Edit `faststream_outbox/registrator.py`** + + Line 3 is currently: + + ```python + from typing import TYPE_CHECKING, Any, override + ``` + + Remove `override` from it and add a `typing_extensions` import. The result + (ruff will normalize ordering in Step 7): + + ```python + from typing import TYPE_CHECKING, Any + + from typing_extensions import override + ``` + + The `@override` decorators at lines 41 and 99 are unchanged. + +- [ ] **Step 4: Edit `faststream_outbox/publisher/usecase.py`** + + Line 13 is currently: + + ```python + from typing import override + ``` + + Replace it with: + + ```python + from typing_extensions import override + ``` + + The `@override` decorators at lines 60, 104, 116 are unchanged. + +- [ ] **Step 5: Edit `faststream_outbox/broker.py`** + + This file uses `import typing` (line 11) and the qualified form + `@typing.override`. Add a direct import near the other third-party imports + (e.g. after the `from sqlalchemy...` lines around line 30): + + ```python + from typing_extensions import override + ``` + + Then replace the decorator at lines 204, 212, 222, 293, 335 — each currently: + + ```python + @typing.override + ``` + + with: + + ```python + @override + ``` + + Leave `import typing` in place (still used for `typing.Self`, `typing.Any`, + etc.). + +- [ ] **Step 6: Edit `faststream_outbox/subscriber/usecase.py`** + + This file uses `import typing` (line 27) and `@typing.override`. Add a direct + import near the other third-party imports (after the `from faststream...` + block, around line 38): + + ```python + from typing_extensions import override + ``` + + Then replace the decorator at lines 232, 247, 804, 808, 818, 868 — each + currently: + + ```python + @typing.override + ``` + + with: + + ```python + @override + ``` + + Leave `import typing` in place (still used elsewhere in the file). + +- [ ] **Step 7: Lint (sorts the new imports) and type-check** + + ```bash + just lint + ``` + + Expected: passes. `ruff --fix` sorts each new `from typing_extensions import + override` into the third-party group; `ty` resolves `override` from + `typing_extensions` cleanly. If `ty` reports anything, do not add new + suppressions — fix the import. + +- [ ] **Step 8: Regenerate the lockfile for the lowered floor (do not commit it)** + + ```bash + uv lock + ``` + + Expected: resolves `typing-extensions` and re-pins for `>=3.11`. `uv.lock` is + git-ignored; it will not appear in `git status`. + +- [ ] **Step 9: Prove the fix on real 3.11 (GREEN)** + + Sync deps for 3.11 and run the two no-Postgres suites (which exercise the + full import graph) without the coverage gate: + + ```bash + uv sync --python 3.11 --all-extras + uv run --python 3.11 --no-sync pytest tests/test_unit.py tests/test_fake.py --no-cov -q + ``` + + Expected: PASS. The package and all four edited modules import and run on + CPython 3.11. (Re-sync the default interpreter afterward with `uv sync + --all-extras` if you want the local venv back on 3.14.) + +- [ ] **Step 10: Commit** + + ```bash + git add pyproject.toml faststream_outbox/registrator.py \ + faststream_outbox/publisher/usecase.py faststream_outbox/broker.py \ + faststream_outbox/subscriber/usecase.py + git commit -m "feat: support Python 3.11 and 3.12 + +Backport typing.override via typing_extensions, lower requires-python to +>=3.11, add 3.11/3.12 classifiers and ruff target." + ``` + +--- + +### Task 2: Widen the CI matrix and finalize the bundle + +Add 3.11 and 3.12 to the pytest matrix so the 100%-coverage suite runs on every +supported interpreter, and close out the planning bundle. No architecture +promotion is required — the design verified no `architecture/.md` +references the Python floor. + +**Files:** +- Modify: `.github/workflows/_checks.yml` (pytest matrix) +- Modify: `planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md` + (finalize `summary:`) + +**Interfaces:** +- Consumes: the lowered floor from Task 1 (CI installs each matrix interpreter + via `uv python install ${{ matrix.python-version }}` and runs `pytest`). +- Produces: nothing later tasks rely on. + +- [ ] **Step 1: Confirm no architecture page references the floor (guards the "no promotion" claim)** + + ```bash + grep -rn "3\.13\|requires-python\|Python 3" architecture/ || echo "no floor references — no promotion needed" + ``` + + Expected: no matches (or only matches unrelated to the supported-version + floor). If a real floor reference appears, update that page in this PR and + note it here. + +- [ ] **Step 2: Edit `.github/workflows/_checks.yml` — widen the pytest matrix** + + The matrix block (lines 24-26) is currently: + + ```yaml + matrix: + python-version: + - "3.13" + - "3.14" + ``` + + Change it to: + + ```yaml + matrix: + python-version: + - "3.11" + - "3.12" + - "3.13" + - "3.14" + ``` + + Leave the lint job's `uv python install 3.13` (line 16) unchanged — lint stays + on 3.13. + +- [ ] **Step 3: Finalize the bundle summary** + + In `planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md`, + confirm the front-matter `summary:` states the realized result (it already + reads as the shipped outcome — adjust only if the implementation deviated from + the spec). + +- [ ] **Step 4: Validate the planning bundle** + + ```bash + just check-planning + ``` + + Expected: `planning: OK`. + +- [ ] **Step 5: Commit** + + ```bash + git add .github/workflows/_checks.yml \ + planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md + git commit -m "ci: run pytest matrix on Python 3.11 and 3.12" + ``` + +--- + +### Task 3: Open the PR and watch CI + +Ship via PR (never local-merge). The widened matrix on real CI is the +authoritative verification — local runs only smoke-tested 3.11. + +**Files:** none. + +- [ ] **Step 1: Push the branch and open the PR** + + ```bash + git push -u origin feat/python-3.11-3.12-support + gh pr create --fill --base main + ``` + +- [ ] **Step 2: Watch CI to green** + + ```bash + gh pr checks --watch + ``` + + Expected: all matrix legs (3.11, 3.12, 3.13, 3.14) plus lint pass. The 100% + coverage gate holds on each interpreter. If a 3.11/3.12-only failure surfaces + (e.g. a dependency runtime difference), fix it on the branch and re-push — + catching exactly that is why the matrix was widened. diff --git a/pyproject.toml b/pyproject.toml index 6a82f77..a8bff85 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", "outbox", "transactional-outbox", "postgresql", "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", "sqlalchemy[asyncio]>=2.0", + "typing-extensions>=4.12.0", ] [project.optional-dependencies] @@ -62,7 +65,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_fake.py b/tests/test_fake.py index 8054a98..0bdf426 100644 --- a/tests/test_fake.py +++ b/tests/test_fake.py @@ -14,6 +14,7 @@ from faststream.middlewares import AckPolicy from sqlalchemy import MetaData from sqlalchemy.ext.asyncio import AsyncSession +from typing_extensions import get_protocol_members from faststream_outbox import ( ConstantRetry, @@ -79,7 +80,7 @@ def test_fake_outbox_producer_satisfies_producer_proto() -> None: broker = _make_broker() fc = FakeOutboxClient() fp = FakeOutboxProducer(fc, broker, serializer=None, run_loops=False) - missing = typing.get_protocol_members(ProducerProto) - set(dir(fp)) + missing = get_protocol_members(ProducerProto) - set(dir(fp)) assert not missing, f"FakeOutboxProducer missing ProducerProto attrs: {missing}" assert fp.codec is None diff --git a/tests/test_unit.py b/tests/test_unit.py index c6676e7..c28a4c8 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -23,6 +23,7 @@ from sqlalchemy import MetaData, Table from sqlalchemy.dialects import postgresql from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from typing_extensions import get_protocol_members from faststream_outbox import ( ConstantRetry, @@ -1198,7 +1199,7 @@ def test_outbox_producer_satisfies_producer_proto() -> None: producer = OutboxProducer(table=table, parser=None, decoder=None) # Verify all ProducerProto structural members are present (Protocol is not # @runtime_checkable so isinstance() raises TypeError; check attrs directly). - missing = typing.get_protocol_members(ProducerProto) - set(dir(producer)) + missing = get_protocol_members(ProducerProto) - set(dir(producer)) assert not missing, f"OutboxProducer missing ProducerProto attrs: {missing}" assert isinstance(producer.codec, DefaultCodec) @@ -1518,7 +1519,12 @@ async def _hang() -> bool: return True # pragma: no cover - move_on_after cancels the sleep before this returns broker.config.broker_config.client.ping = _hang # type: ignore[union-attr] - result = await broker.ping(timeout=0.05) + # Run the probe in a child task so move_on_after's cancellation unwinds through + # the child frame, not this one. On Python 3.11 a cancellation that unwinds + # through the test frame makes coverage.py's C tracer drop the frame's trace + # function, leaving the assert below reported as uncovered (99% < 100% gate) + # even though it runs and passes. 3.12+ (sys.monitoring) is unaffected. + result = await asyncio.create_task(broker.ping(timeout=0.05)) assert result is False