Skip to content

feat (#3065): name the context annotation when a handler is hinted with a driver class - #3068

Open
MannXo wants to merge 30 commits into
ag2ai:mainfrom
MannXo:feat/3065-context-annotation-hints
Open

MannXo wants to merge 30 commits into
ag2ai:mainfrom
MannXo:feat/3065-context-annotation-hints

Conversation

@MannXo

@MannXo MannXo commented Aug 27, 2026 •

Copy link
Copy Markdown

Description

Annotating a handler argument with a broker's own client class produced a pydantic validation error about the message schema, once per message, with nothing in it pointing at the real cause.

from redis.asyncio import Redis

@broker.subscriber(stream=StreamSub("stream", group="group", consumer="consumer"))
async def handler(msg: RedisStreamMessage, redis: Redis) -> None: ...
pydantic_core._pydantic_core.ValidationError: 1 validation error for handler
redis
  Input should be an instance of Redis [type=is_instance_of, input_value={'a': 1}, input_type=dict]

Now the subscriber refuses it when it is set up, from broker.start(), a TestBroker or schema generation, and names the import that works:

faststream.exceptions.SetupError: `redis` is annotated with `redis.asyncio.client.Redis`, which FastStream cannot inject.
Use the context annotation instead:

    from faststream.redis.annotations import Redis

Fixes #3065

Changes

The check reads the handler's built CallModel. SubscriberUsecase._build_fastdepends_model calls check_context_annotations (faststream/_internal/endpoint/subscriber/hints.py) right after HandlerItem._setup. fast_depends has already put Context annotations in custom_fields and Depends in dependencies, so any driver class left in the model's params is one that would be validated as message data. No heuristic about Annotated is needed, and the handler's hints are never resolved a second time.

The walk covers the handler's own arguments and those of every dependency, including broker- and subscriber-level dependencies. An argument found inside a dependency names it:

faststream.exceptions.SetupError: `r` of dependency `dep` is annotated with `redis.asyncio.client.Redis`, which FastStream cannot inject.

Optional[X], X | None and Annotated[X, ...] with plain metadata are unwrapped before the lookup. One bad argument raises a bare SetupError; several raise an ExceptionGroup of them headed with the handler name.

Nothing is checked when FastDepends is off (apply_types=False), since nothing is injected then and the suggested annotations would not work either. The FastAPI integration builds its broker that way and supplies its own model through get_dependent, so it is skipped too.

The table lives on the broker config. BrokerConfig has two fields, default_driver_annotations, which each broker's config fills from a _context_annotations_factory beside it, and underlying_driver_annotations, whatever the user passed. resolved_underlying_driver_annotations merges them with the user's rows winning. 30 rows ship, covering every entry in the issue's table plus the broker, message and producer classes the annotations modules already wrap. The factories import inside the function because faststream.<broker>.annotations reaches the config module through the broker, and each of those imports carries # noqa: PLC0415.

A row's value is an UnderlyingDriverAnnotation(type_hint=..., module=..., name=...), a keyword-only, slotted dataclass in faststream._internal.configs, or a bare annotation. The value decides the message: an UnderlyingDriverAnnotation names the import, a bare one says to use the context annotation FastStream provides.

Users can add rows. Every broker and router takes underlying_driver_annotations, merged over the shipped rows:

RedisBroker(
    underlying_driver_annotations={
        MyDriver: UnderlyingDriverAnnotation(
            type_hint=MyThing, module="my.app", name="MyThing"
        ),
    },
)

ConfigComposition merges both tables across its configs the way it merges extra_context, so a subscriber on an included router sees the broker's rows and the router's own, with the router's winning.

Testing

tests/brokers/base/driver_annotations.py is a base testcase inherited by every broker, which supplies only its driver class and expected import. It goes through the public decorator and the in-memory TestBroker and matches the full message. Cases: a plain driver class, X | None, plain Annotated and Annotated | None, a driver class inside Depends, a handler on a router joined with include_router, the context annotation accepted, apply_types=False not checked, and a TYPE_CHECKING-only hint accepted.

tests/brokers/redis/test_misconfigure.py holds the table behaviour that is not per broker: custom rows with and without an import, custom rows not replacing the defaults, a router's rows merging with the broker's, and several bad arguments reported together.

The 68 added tests are the whole difference.

$ pytest -n auto -m "(slow and not connected) or not connected"
main         4506 passed, 39 skipped, 7 xfailed
this branch  4574 passed, 39 skipped, 7 xfailed

$ diff-cover coverage.xml --compare-branch upstream/main --include 'faststream/**' --fail-under 90
Total:   85 lines
Missing: 4 lines
Coverage: 95%

Diff coverage is measured without the connected jobs, which need brokers that are not available here, so CI should read it no lower. connected tests were not run.

Static analysis, all clean:

ruff format, ruff check --exit-non-zero-on-fix, codespell
mypy                 Success: no issues found in 1886 source files
pyright              0 errors, 0 warnings, 0 informations
pyrefly check        0 errors (8 suppressed)
bandit               0 issues
semgrep              Ran 290 rules on 515 files: 0 findings.
slotscheck -m faststream, lint-imports (8 kept, 0 broken), zizmor, actionlint, uv lock --check

Docker is not available here, so the just recipes were not used; these are the commands they wrap, run against the pinned versions.

Notes for reviewers

The MQTTBroker and MQTTRouter constructors have no docstring, and ruff's D417 rejects an Args: section that leaves parameters out, so underlying_driver_annotations is undocumented there. Documenting it means documenting all 37 and 10 parameters, which I would rather do separately.

underlying_driver_annotations is not in redis's NON_CONNECTION_PARAMS, so it also reaches redis-py's connection kwargs, which tolerate it. That is a one-line fix I am holding for a follow-up rather than growing this PR.

faststream/asgi/annotations.py and faststream/opentelemetry/annotations.py ship Context annotations too and have the same failure mode. Left out to keep this to the brokers.

Two corrections to the issue's table. The NATS object store annotation is faststream.nats.annotations.ObjectStorage, not ObjectStore, and the MQTT client is zmqtt.client.MQTTClient. The rows use the real names.

Type of change

  • New feature (a non-breaking change that adds functionality)

Checklist

  • My code adheres to the style guidelines of this project
  • I have conducted a self-review of my own code
  • I have made the necessary changes to the documentation. The new argument is documented in the broker and router docstrings, except MQTT (see above). Happy to add a note to getting-started/context.md about the name collision if you want one.
  • My changes do not generate any new warnings
  • I have added tests to validate the functionality of my new feature
  • Both new and existing unit tests pass successfully on my local environment
  • I have ensured that static analysis tests are passing
  • I have included code examples to illustrate the modifications

…ed with a driver class

A handler argument annotated with a broker's own client class is treated
as a message field, so the failure arrives once per message as a pydantic
validation error about a missing field. Nothing in it points at the real
cause, which is that the working annotation lives in
faststream.<broker>.annotations under the same name as the driver class.

Each broker's annotations module now registers the driver classes it
wraps, and the shared call-model construction consults that registry when
the handler's model is built. A hit raises SetupError naming the import to
use instead.
@CLAassistant

CLAassistant commented Aug 27, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added Confluent Issues related to `faststream.confluent` module AioKafka Issues related to `faststream.kafka` module NATS Issues related to `faststream.nats` module and NATS broker features Redis Issues related to `faststream.redis` module and Redis features MQTT Issues related to `faststream.mqtt` module labels Aug 27, 2026
Comment thread tests/utils/test_context_annotation_hints.py Outdated

@borisalekseev borisalekseev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests are very implicit and know too much about the implementation details. Implement the tests through public behavior, checking pytest.raises with full help message matching when calling broker.start.

Comment thread faststream/_internal/di/hints.py Outdated

@IvanKirpichnikov IvanKirpichnikov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi. I don’t like your solution because of the use of mutable variables.

Let’s do it this way.
Let’s move this function to a separate method, which we’ll override in each subscriber, and there we’ll perform the annotation check.

…a global registry

Addresses review. The import-time registry is gone, along with the hook
in the shared FastDepends config.

SubscriberUsecase.__call__ hands its closure body to a new _create_call
method. Each broker's root subscriber overrides it, checks the decorated
handler against its own immutable table of driver classes, and delegates
to super(). The tables sit in each broker's annotations module, beside
the annotations they name.

That also moves the failure to decoration time, which is what the issue
asked for. Building the call model resolves the same hints later, so a
handler whose annotations cannot be resolved is reported there as before
rather than at decoration.

Tests go through the public decorator and match the full message, one
per broker in its misconfiguration module.
@MannXo

MannXo commented Aug 29, 2026

Copy link
Copy Markdown
Author

Reworked as asked. The registry and its import-time population are gone, along with the hook in FastDependsConfig.

@IvanKirpichnikov SubscriberUsecase.__call__ now hands the body of its closure to _create_call, and each broker's root subscriber overrides it to check the handler before delegating to super(). Six overrides. Each reads a MappingProxyType constant that lives in that broker's annotations module, next to the annotations it names, so nothing is mutable and nothing is registered at import. The table cannot be imported at subscriber module scope, since faststream/redis/subscriber/usecases/basic.py importing RedisBroker gives cannot import name 'RedisBroker' from partially initialized module, so the override imports it in the method body.

That also moves the failure to decoration time, which is what the issue title asked for and what I had argued against. You were right. Building the call model resolves the same hints later, so reading them at decoration adds no failure that would not have happened at startup anyway, and the get_type_hints call is wrapped so a handler whose annotations cannot be resolved is still reported where it is today.

@borisalekseev tests now go through the public decorator with the full message matched, one per broker in its misconfiguration module, plus one that the correct annotations are accepted. Nothing touches internals. tests/utils/test_context_annotation_hints.py is deleted and the docstring is gone. One heads-up on that last point: .agents/skills/testing-patterns/SKILL.md currently tells contributors the opposite, that a regression test names the issue by full URL on the docstring's first line. Might be worth changing, since it will keep producing this.

Since the check is at decoration time, broker.start() never gets reached to assert on, so the tests assert on the decorator instead. Same public surface, one step earlier.

One behaviour change I did not gate. A broker built with apply_types=False now raises too. Nothing that works today starts failing, since such a handler is already broken on every message, but the import the message suggests will not be injected either when types are off. Gating on use_fastdepends is a one-line addition if you would prefer it.

$ pytest -n auto -m "(slow and not connected) or not connected"
main         4062 passed, 32 skipped, 7 xfailed
this branch  4069 passed, 32 skipped, 7 xfailed

mypy, ruff, codespell, bandit and semgrep are clean. pyright is unchanged against main, same 350 findings line for line.

@borisalekseev
borisalekseev self-requested a review August 29, 2026 12:32
@borisalekseev

borisalekseev commented Aug 29, 2026 •

Copy link
Copy Markdown
Collaborator

I think we are able to implement this logic internally. What about extending the broker configuration with a field called underlying driver annotations, where this dictionary would be stored. This way, we can access it from the original _create call with self.outer config.underlying driver annotation. @Lancetnik @IvanKirpichnikov what do you think?

@IvanKirpichnikov

IvanKirpichnikov commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

I think we are able to implement this logic internally. What about extending the broker configuration with a field called underlying driver annotations, where this dictionary would be stored. This way, we can access it from the original _create call with self.outer config.underlying driver annotation. @Lancetnik @IvanKirpichnikov what do you think?

In my opinion, it’s a good idea.

e.g

NatsBroker(
    underlying_driver_annotation={
        NatsObjectStore: FastStreamObjectStore,
    },
)

However, in this case, you will have to abandon the module from which the required type hint needs to be imported.

Comment thread faststream/_internal/endpoint/subscriber/hints.py Outdated
Comment on lines +39 to +41
# Building the call model resolves the same hints and reports the same
# failure, so decoration stays silent about it.
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn’t understand what kind of error might occur.

Can you provide an example of code with an error?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from __future__ import annotations

from typing import TYPE_CHECKING

from faststream.redis import RedisBroker

if TYPE_CHECKING:
    from decimal import Decimal

broker = RedisBroker()


@broker.subscriber("channel")
async def handler(price: Decimal) -> None: ...

Without the guard, get_type_hints fails while the module is still being imported:

NameError: name 'Decimal' is not defined

With it, that handler behaves exactly as it does on main, failing later with the message it already gives today:

pydantic.errors.PydanticUserError: `handler` is not fully defined; you should define `Decimal`,
then call `handler.model_rebuild()`.

So the guard is there to keep a handler that FastStream already rejects from being rejected earlier, and with a worse message. I have made the comment say that rather than leaving it vague.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s not ignore the error after all.

The error message contains at least one contradiction. It asks to call handler.model_rebuild(), although such a method doesn’t exist for handler.

…ceptionGroup

Review request. Each offending argument becomes its own SetupError, and
the group message names the handler.

Note that an ExceptionGroup is not a SetupError, so anything catching
SetupError around subscriber registration no longer sees this.
@MannXo
MannXo requested a review from powersemmi as a code owner September 1, 2026 20:41
…nnotation-hints

# Conflicts:
#	tests/brokers/redis/test_misconfigure.py

@IvanKirpichnikov IvanKirpichnikov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…nfig

Addresses review.

The table moves onto BrokerConfig as underlying_driver_annotations, so
the check happens once in the original _create_call and the six
per-broker overrides are gone. Each broker's config supplies its own
table as the field default.

Values are the context annotations themselves rather than an import
path, so the message names the context key instead of a module and a
name. A row whose annotation is a Depends rather than a Context has no
key to name, and says only that FastStream provides one.

The get_type_hints guard is gone as well. Removing it surfaced a real
defect it had been hiding: a publisher decorator applied before the
subscriber hands _create_call its HandlerCallWrapper, and resolving that
object's hints reads the wrapper class's own annotations with an empty
namespace. Unwrap to the original call first.
@MannXo

MannXo commented Sep 3, 2026

Copy link
Copy Markdown
Author

Fix it

  1. #3068 (comment)
  2. #3068 (comment)

reflected

@IvanKirpichnikov IvanKirpichnikov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not put CONTEXT_ANNOTATIONS next to the broker’s config?

And I initially liked the idea of specifying the required import from faststream.redis.annotations import Redis, but now it’s specified as Annotated[Redis, Context("broker._connection")]. Can we bring this functionality back?

…me the import again

Addresses review.

CONTEXT_ANNOTATIONS moves into each broker's config module. Both sides
of a row are import paths, so the table needs no imports of its own and
the lazy import it used to need is gone. The six annotations modules are
untouched by this branch again.

The message names the import to use rather than reconstructing an
Annotated form, which also restores it for the one row whose annotation
is a Depends and had no context key to name.

@IvanKirpichnikov IvanKirpichnikov left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about doing it this way?
Let

underlying_driver_annotations: Mapping[Any, Any | UnderlyingDriverAnnotation]

@dataclass
class UnderlyingDriverAnnotation:
    type_hint: Any
    module: str
    name: str

Then you’ll get:

underlying_driver_annotations = {
    aiopika.KafkaBroker: faststream.KafkaBroker,
    redis.asyncio.Redis: UnderlyingDriverAnnotation(faststream.RedisBroker, faststream, "RedisBroker"),
}

If UnderlyingDriverAnnotation is specified, the metadata for the error is taken from there: the module and the name, and we get

from faststream import RedisBroker

If a regular type hint is specified, there will be no import hint.


We also need to add the implementation of underlying_driver_annotations via the brokers’ __init__. For example
NatsBroker(..., underlying_driver_annotations={...})

I think that in this case, we will need to merge the default and custom ones.

Addresses review.

The per-broker defaults become a dataclass field that each broker
overrides with its own factory, rather than a _default_driver_annotations()
hook, so the defaults sit on the config's public surface.

The merge itself moves to a resolved_underlying_driver_annotations
property. underlying_driver_annotations now keeps whatever the caller
passed instead of being overwritten in __post_init__, and the resolved
view is built per read. A custom row still adds to a broker's defaults
rather than replacing them.

The confluent and MQTT configs no longer chain into a base __post_init__,
since the base no longer defines one.
Upstream moved broker_dependencies to Sequence, restyled the Redis params
TypedDict from Annotated to docstrings, made include_routers variadic, and
enabled PLC0415.

The driver annotation rows keep their deferred imports, exempted in ruff.toml
the way upstream exempts its own, since the objects only exist once the
package is built. The bare driver generics in the Redis misconfigure tests
are the mistake under test, so they take a type-arg ignore rather than a
parameter that would change the asserted message.

@IvanKirpichnikov IvanKirpichnikov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi. Overall, everything’s fine.

Let’s just change the function names from _context_annotations to _context_annotations_factory and do a git pull main.

Keeps both members added after BrokerConfig.extra_context: upstream's
__post_init__, which tuple-ifies broker_dependencies, and this branch's
resolved_underlying_driver_annotations property.

Upstream tightened the subscriber dependencies parameter from Iterable to
Sequence in the same range; _create_call is typed to match.
…_annotations_factory

Review request: the module-level helper each broker config passes to
default_factory reads as the annotations themselves rather than as the
factory producing them.
@MannXo

MannXo commented Sep 21, 2026

Copy link
Copy Markdown
Author

Both done.

_context_annotations is now _context_annotations_factory in all six broker
config modules, and main is merged.

One thing worth flagging from the merge. BrokerConfig gained a __post_init__
upstream and this branch had added a resolved_underlying_driver_annotations
property in the same place, so that file conflicted; both are kept, they do not
interact. The same range tightened the subscriber dependencies parameter from
Iterable to Sequence, which left _create_call behind, so it is typed to
match now.

Gates on the merged head: ruff, codespell, mypy, pyright, pyrefly, bandit,
zizmor, slotscheck, import-linter, actionlint and uv lock --check all clean,
and 4410 passed on the non-connected suite. The new diff coverage gate reads 95%
on the changed lines of faststream/ against a 90% floor, measured without the
connected jobs, so CI should read it no lower.

Comment thread ruff.toml Outdated
Comment on lines +155 to +162
"faststream/{confluent,kafka,nats,rabbit,redis}/configs/broker.py" = [
"PLC0415",
]

"faststream/mqtt/broker/config.py" = [
"PLC0415",
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s better explicitly set the noqa: PLC0415.

@Lancetnik Lancetnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for sticking with this through all the rounds. The error message is exactly what we want, and the table next to the broker config reads well. I think the check runs at the wrong step, though, and moving it removes most of the remaining problems.

Validate the built CallModel, not the raw signature

By setup time fast_depends has already built a CallModel for the handler, and it holds everything this check needs. Here is a handler run through build_call on main:

from __future__ import annotations

def dep(r: Redis) -> int: ...

@broker.subscriber("ch")
async def h(msg: str, direct: Redis, opt: Optional[Redis], ok: RedisCtx, d: int = Depends(dep)): ...
params:        msg: str, direct: Redis, opt: Optional[Redis]
flat_params:   msg, direct, opt, r: Redis     <- `r` comes from Depends
custom_fields: ['ok']                         <- context annotations never reach params
dependencies:  ['d']

Iterating dependent.flat_params in HandlerItem._setup, right after set_wrapped, fixes these:

  • Arguments of Depends are caught. Today def dep(r: Redis) passes, because only the handler's own hints are read. flat_params also includes the broker- and subscriber-level dependencies.
  • No heuristic is needed. fast_depends already puts Context annotations in custom_fields, so a driver class found in flat_params is always one that would be validated as message data. We don't need the "Annotated means OK" rule.
  • It fixes a regression. get_type_hints at hints.py:40 has no guard. A handler that imports a type under TYPE_CHECKING with from __future__ import annotations now fails at decoration with a bare NameError, and on main it works. build_call_model already resolves the hints and reports failures the same way it does today.
  • Less code. _create_call, reading _declared_call, and the unwrap handling for stacked decorators can all go.
  • The error comes from broker.start(), which is where @borisalekseev asked for it.

The config side (resolved_underlying_driver_annotations, UnderlyingDriverAnnotation) can stay as it is. Only the thing being checked changes.

Required

  1. Move the check to the CallModel as described above.
  2. Unwrap Optional[X] / X | None. field_type holds the whole union, and the lookup is an exact match, so redis: Redis | None = None is not caught. That case is worse than the one in the issue: the handler silently gets None.
  3. Skip the check entirely when FastDepends is off (apply_types=False). Nothing is injected in that mode, so Context and the suggested annotations don't work either, and the check has nothing to validate. Note that build_call_model runs before the use_fastdepends branch in FastDependsConfig.build_call, so a model always exists. The guard has to be explicit.
  4. Tests:
    • The same test is copied into six test_misconfigure.py files. Please move it into a shared base testcase under tests/brokers/base/, with each broker providing only its driver class and expected import.
    • Add cases for a driver class inside Depends, for Optional, and for apply_types=False (that one must not raise).
    • test_annotation_behind_depends_names_its_import has no Depends in it. Please rename it or make it match its name.

Should fix

  1. ruff.toml: replace the file-level PLC0415 ignores with an inline # noqa: PLC0415, as @IvanKirpichnikov asked.
  2. Top-level export: UnderlyingDriverAnnotation is exported from the top-level faststream package. Nobody asked for that export. Please keep it next to the configs and make it kw_only.
  3. Typing: narrow Mapping[Any, Any] on the new config fields and on the broker/router params, e.g. to Mapping[Any, UnderlyingDriverAnnotation | Any].
  4. MQTT docstrings: the MQTT broker and router take underlying_driver_annotations without a docstring entry.
  5. Router tables: please check how the broker's and a router's tables combine with include_router. I've only read the code, not run it. ConfigComposition.__getattr__ returns configs[0], so a router's own rows might apply only to subscribers declared before include_router, or might not merge with the broker's. Once the check reads the table at setup time, a test for this would settle it.
  6. FastAPI integration: it builds its own Dependant through get_dependent, not a CallModel. That integration is deprecated, so please don't handle it, just guard so the check doesn't break on it.

Minor

  • One argument, one exception: a single bad argument still raises an ExceptionGroup, so except SetupError misses it. A bare SetupError for a single offender would be friendlier.
  • Stale description: the PR description still describes MappingProxyType constants in the annotations modules, six subscriber overrides, a try/except around get_type_hints and a bare SetupError. None of that matches the code any more.

Upstream renamed BrokerConfigType to BrokerConfigType_co, so the configs
package exports the new name beside this branch's UnderlyingDriverAnnotation.

The same range turned on slotscheck's require-subclass (ag2ai#3220), which flags
UnderlyingDriverAnnotation for having no slots. The next commit slots it.
@MannXo

MannXo commented Sep 24, 2026

Copy link
Copy Markdown
Author

All in except the MQTT docstrings (8), which need a decision from you, see the end. main is merged in 4a7a255.

1. The check reads the built CallModel (da5ecf1). check_context_annotations takes the handler's model and runs in _build_fastdepends_model right after call._setup (usecase.py:238-239). So it fires from broker.start(), from a TestBroker on enter and from schema(), which all build the model there. _create_call is gone and real_wrapper is upstream's body again, and so is everything in hints.py that read the raw signature (HandlerCallWrapper, _declared_call, unwrap, get_type_hints).

It walks params, then every dependency, handler-level and extra_dependencies alike, which is the traversal flat_params does (fast_depends/core/model.py:56-65). It does not call flat_params itself for two reasons. The message has to name the dependency an argument came from, and flat_params drops a dependency argument whose name the handler already uses (model.py:62), so def dep(redis: Redis) under a handler with its own redis field would never be checked.

faststream.exceptions.SetupError: `r` of dependency `dep` is annotated with `redis.asyncio.client.Redis`, which FastStream cannot inject.
Use the context annotation instead:

    from faststream.redis.annotations import Redis

The TYPE_CHECKING regression goes with it, since build_call_model leaves an unresolvable forward reference as it is instead of raising (fast_depends/utils.py:164-167, _compat.py:30-34). I called it from _build_fastdepends_model rather than HandlerItem._setup because the table sits on the subscriber's config, which HandlerItem never sees.

2. Optional[X] and X | None are unwrapped, and so is every other union member (hints.py:58-74, be2a6ff). The same commit covers a case the review did not name. Annotated[Redis, "doc"] has no fast_depends marker, so fast_depends keeps the whole Annotated[...] as field_type (fast_depends/core/builder.py:120-121) and the exact lookup missed it. Plain metadata is unwrapped now too, inside an Optional as well.

3. apply_types=False is skipped explicitly. No table is read unless fd_config.use_fastdepends and not fd_config.get_dependent (usecase.py:221).

4. Tests. The six copies are one base testcase, tests/brokers/base/driver_annotations.py, and each broker's misconfigure module sets only driver_class, driver_path, context_annotation and annotation_import. New cases in it:

  • a driver class inside Depends (:62)
  • X | None, plain Annotated and Annotated | None, as parameters of the main test (:30)
  • a router joined with include_router (:85)
  • apply_types=False, which must not raise (:120)
  • logger: "Logger" = Context() with Logger imported under TYPE_CHECKING, which must not raise (:136). A quoted annotation leaves the same unresolved string that from __future__ import annotations does.

test_annotation_behind_depends_names_its_import is replaced by the base Depends test, which is what its name promised. test_a_row_can_be_a_union_hint is deleted, since a union key is never looked up once unions are unwrapped.

Against the source of the merge commit, before any of these fixes, every new case goes red. Depends, Optional and Annotated with DID NOT RAISE, apply_types=False and the router case with the old decoration-time ExceptionGroup, and the TYPE_CHECKING case with NameError: name 'Logger' is not defined.

5. ruff.toml. Both per-file blocks are deleted and all 30 deferred imports carry # noqa: PLC0415 (22d3240). The comment pushed 11 of them past 90 columns, so the import sorter wrapped those and the noqa sits on the opening line, as on the three that were already multi-line.

6. Export. Dropped from faststream/__init__.py (27d102f). UnderlyingDriverAnnotation is @dataclass(kw_only=True, slots=True) at faststream/_internal/configs/broker.py:23, and every shipped row passes keywords. slots also answers the merge, because require-subclass from #3220 flagged the class. A custom row now imports it from faststream._internal.configs, as the tests do.

7. Typing. Mapping[Any, UnderlyingDriverAnnotation | Any] on both config fields, the ConfigComposition properties, the six factories, the twelve broker and router params and RedisBrokerParams (cd1853a).

9. Router tables. You read it right. Once a router was included, ConfigComposition.__getattr__ sent the lookup to configs[0], the broker, so the router's own rows were dropped. ConfigComposition now merges both tables across its configs the way extra_context does, defaults first, user rows over them, the inner config winning (configs/broker.py:205-230, f797540). test_included_router_merges_its_rows_with_the_broker_rows gives the broker one custom row and the router another and expects both reported. Before the fix only the broker's row came out.

10. FastAPI. Covered by the guard in 3. The integration already builds its broker with apply_types=False (fastapi/router.py:144), so use_fastdepends alone skips it, and the get_dependent half keeps the check off a model that is not a CallModel if that ever changes.

Minor. A single bad argument raises a bare SetupError; two or more still raise an ExceptionGroup headed with the handler name (hints.py:38-43). The description is rewritten for the new design.

@IvanKirpichnikov the noqa is inline now, see 5.

Gates, on the merge commit before any fix and on the new head:

                      4a7a25516 (merge)          cd1853a1f (head)
pytest, non-connected 4519 passed, 39 skipped,   4574 passed, 39 skipped,
                      7 xfailed                  7 xfailed
slotscheck            1 error                    All OK
diff-cover            n/a                        95%, 4 of 85 lines missed

The slotscheck error is the UnderlyingDriverAnnotation one above. ruff format and check, codespell, mypy, pyright, pyrefly, bandit, import-linter, zizmor, actionlint, uv lock --check, unused-fixtures and misplaced-marks are clean on both, and semgrep on the head. Diff coverage was measured without the connected jobs, so CI should read it no lower.

8. MQTT docstrings, not done. MQTTBroker.__init__ and MQTTRouter.__init__ have no docstring today. Under the google convention ruff's D417 rejects an Args: section that leaves parameters out, so documenting this one means documenting all 37 of the broker's and all 10 of the router's.

  1. Should that go in a separate PR, or here? I would rather keep it separate, since almost all of it is unrelated to this change.

Comment thread faststream/_internal/configs/broker.py
Comment thread faststream/_internal/endpoint/subscriber/usecase.py Outdated
Comment thread faststream/nats/broker/broker.py Outdated
Comment thread tests/brokers/redis/test_misconfigure.py Outdated
@IvanKirpichnikov

Copy link
Copy Markdown
Collaborator

and to pull main

@IvanKirpichnikov IvanKirpichnikov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And add documentation

Comment thread faststream/_internal/configs/broker.py Outdated
Comment on lines +38 to +40
UnderlyingDriverAnnotations: TypeAlias = (
Mapping[Any, UnderlyingDriverAnnotation | Any] | None
)

@IvanKirpichnikov IvanKirpichnikov Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I apologize, i meant to do it like this

Suggested change
UnderlyingDriverAnnotations: TypeAlias = (
Mapping[Any, UnderlyingDriverAnnotation | Any] | None
)
UnderlyingDriverAnnotations: TypeAlias = Mapping[Any, UnderlyingDriverAnnotation | Any]

And just use

var: UnderlyingDriverAnnotations: = ...
var: UnderlyingDriverAnnotations | None = ...

@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Oct 9, 2026
…ms and _find_mapped

RedisBrokerParams now takes UnderlyingDriverAnnotations | None, like the
other brokers' constructor parameter, and _find_mapped reads the alias
instead of a bare Mapping.
@MannXo

MannXo commented Oct 9, 2026

Copy link
Copy Markdown
Author

@IvanKirpichnikov everything from your 10-05 review and the 10-07 follow-up is in, and main is merged up to 0ba23f2 (4ef82f1, then 8ab6962 with no conflicts).

  1. Alias. UnderlyingDriverAnnotations: TypeAlias = Mapping[Any, UnderlyingDriverAnnotation | Any], exactly as you wrote it (b12c86c). Every broker and router parameter is UnderlyingDriverAnnotations | None = None, RedisBrokerParams included (f3f48c0), and the config fields, the per-broker default tables, the ConfigComposition properties and the check itself use the plain alias.
  2. Documentation. A "Driver Type Hints" section in getting-started/context.md, right after "Annotated Aliases" (58850bd). It shows the startup error for a bare driver class and how to add your own rows with underlying_driver_annotations, with one snippet per broker, tested in tests/docs/getting_started/context/test_driver_annotations.py.
  3. Re-export. UnderlyingDriverAnnotation is exported from faststream (947fc61).
  4. config=fd_config applied (89158a3).
  5. Generic tests. The custom row, bare row, broker defaults and router merge tests moved into DriverAnnotationTestcase and run on all six brokers (f22511e). The Redis plus Pipeline test stays in redis, since it needs two driver classes from one broker.

The four older threads that are still open are done too and only need resolving. The get_type_hints try/except is gone (58cadaa, and since da5ecf1 the check reads the built call model instead of the hints). default_driver_annotations is a dataclass field and the merge is the resolved_underlying_driver_annotations property (8e738a3). The ruff.toml ignores are inline # noqa: PLC0415 (22d3240).

@Lancetnik item 3 reverses item 6 of your 09-23 review. I kept it because the new docs import UnderlyingDriverAnnotation in user code, and .agents/skills/documentation-writing/SKILL.md asks examples to import only from public packages. If you'd still rather keep it internal, say so and I'll move it back.

One question from my 09-24 comment is still open. The MQTT broker and router are the only constructors without a docstring for the parameter, because D417 wants the whole Args: section once one entry exists (37 and 10 parameters). I'd do that in a separate PR unless you want it here.

Locally on f3f48c0, 4687 passed, 43 skipped, 7 xfailed on the non-connected suite, diff coverage is 95% (4 of 86 lines), and ruff, codespell, typos, mypy, pyright, pyrefly, bandit, slotscheck, import-linter, semgrep, uv lock --check and the strict docs build are clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AioKafka Issues related to `faststream.kafka` module Confluent Issues related to `faststream.confluent` module documentation Improvements or additions to documentation MQTT Issues related to `faststream.mqtt` module NATS Issues related to `faststream.nats` module and NATS broker features Redis Issues related to `faststream.redis` module and Redis features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: detect broker client type hints at decoration time and name the annotation to use

5 participants