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
4 changes: 4 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -22,6 +23,8 @@ jobs:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
services:
Expand All @@ -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
Expand Down
11 changes: 6 additions & 5 deletions faststream_outbox/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -201,15 +202,15 @@ 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:
msg = "Engine not available. Pass an AsyncEngine to OutboxBroker(...)."
raise IncorrectState(msg)
return engine

@typing.override
@override
async def __aenter__(self) -> typing.Self:
# Upstream equivalent (replaced):
# BrokerUsecase.__aenter__ -> faststream/_internal/broker/broker.py
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion faststream_outbox/publisher/usecase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion faststream_outbox/registrator.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 7 additions & 6 deletions faststream_outbox/subscriber/usecase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

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

Expand Down
114 changes: 114 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,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.
Loading