Skip to main content

neva-faststream

FastStream integration for the Neva framework — the messaging counterpart to neva-fastapi.

It marries FastStream brokers and subscribers to neva's dishka-based dependency-injection container: a subscriber resolves injected services the same way a route does, each message gets its own DI scope, and broker lifecycle is driven by neva service providers.

Usage

# src/apps/worker.py
from faststream.rabbit import RabbitBroker
from neva.faststream import App, Inject

from src.settings import MainSettings

broker = RabbitBroker(MainSettings().rabbitmq.url_string)


@broker.subscriber("documents")
async def handle(body: dict, documents: Inject[DocumentService]) -> None:
    await documents.process(body)


app = App(broker, config_path="src/config")
faststream run src.apps.worker:app

Inject[T] resolves from a container scoped to the message being consumed, so a scoped binding yields one instance per message. The facades work inside a subscriber too — the App facade's make, DB, Log, Event all reach that same scope, not the application container.

The broker is bound into the container, so anything that publishes reaches it from there rather than from a global — Inject[T] in a subscriber, a constructor parameter in a service:

@broker.subscriber("documents")
async def handle(body: DocumentPayload, publisher: Inject[RabbitBroker]) -> None:
    await publisher.publish(body.receipt, "receipts")


class NotifyDocument:
    def __init__(self, broker: RabbitBroker) -> None:
        self._broker = broker

Inject only means anything where FastStream does the calling; in a service it is a missing positional argument.

A single-broker app also binds its broker as BrokerUsecase. With several brokers that interface would resolve to whichever was bound last, so it is left unbound and injection must name a concrete type.

Naming a broker

A type only says which broker is meant while there is one of each kind. Two RabbitBroker instances share a type, so neither is bound under it — name them instead, and anything choosing a broker from config chooses it from the BrokerRegistry:

app = App(main_broker, named={"analytics": analytics_broker}, config_path="src/config")

The first positional broker is also named default. Named brokers are consumed and wired like positional ones, so a broker is never passed twice.

registry = app.application.make(BrokerRegistry).unwrap()
registry.get("analytics")  # Ok(analytics_broker)
registry.get()  # Ok(main_broker) — the default
registry.get("reports")  # Err, naming the brokers there are

neva-queue reads this registry: a queue connection names the broker it publishes to, and two connections can reach two brokers.

Every broker goes to the constructor

The set of brokers is fixed when the App is built. app.add_broker(...) and the deprecated app.set_broker(...) raise RuntimeError rather than take a late one: the container has already been built, so the broker would get no type binding, no registry name, no container middleware and no injection. Left to run it fails at message time with a missing-argument error on the subscriber's injected parameter — a handler that looks correctly written.

One broker also belongs to one App. Building a second App over the same broker is refused, because both container middlewares would run and the inner one would open a message scope inside the outer one's.

Service providers

App.register takes a neva ServiceProvider, and providers declared in the providers config namespace are picked up as usual. A provider implementing lifespan() is entered on startup and exited on shutdown, around the broker's own lifecycle.

app = App(broker, config_path="src/config")
_ = app.register(DocumentServiceProvider)

Pass lifespan= to App for startup work that isn't a provider's; it runs inside the neva application's lifespan, so the container and facades are live.

app.lifespan() boots the application on its own — providers, listeners, the container and the facades — but neither connects nor starts the brokers, so publishing inside it raises IncorrectState. Enter the broker alongside it:

async with app.lifespan(), broker:
    await broker.publish(payload, "documents")

It is the inner layer of the composed lifespan, so a lifespan= passed to App does not run there; faststream run enters the composed one.

Application metadata

The AsyncAPI document's title, version and description come from the app config namespace rather than the constructor:

# src/config/app.py
config = {
    "title": "Billing worker",
    "version": "2.1.0",
    "description": "Consumes billing events.",
}

MessagingAppConfig declares those three keys. The app namespace is shared, and each package declares only what it reads — the core owns key, previous_keys and providers. A TypedDict is closed, so annotate a real config/app.py with a shape that inherits from each package's:

from neva.arch import AppConfig
from neva.faststream import MessagingAppConfig


class WorkerAppConfig(MessagingAppConfig, AppConfig): ...


config: WorkerAppConfig = {"title": "Billing worker", "providers": [...]}

Unset keys fall back to Neva Application and 0.1.0, and an unset description is left out of the document.

For what config does not carry — a license, a contact, tags — pass a specification of your own, which is then used as it stands:

from faststream.specification import AsyncAPI

app = App(broker, specification=AsyncAPI(title="Billing", license=license))

Injecting the message

Inject[StreamMessage] gives the subscriber the message being consumed. Broker-specific message classes are deliberately not declared here — the middleware puts one in the scope, but only a consumer knows which broker it runs on, so Inject[RabbitMessage] needs a from_context of its own:

class WorkerServiceProvider(ServiceProvider):
    @override
    def register(self) -> Result[Self, str]:
        self.from_context(RabbitMessage, scope=Scope.REQUEST)
        return Ok(self)

Turning auto-injection off

Every subscriber is wrapped so Inject parameters resolve without decorating each one. Pass auto_inject=False to opt out and decorate explicitly:

from neva.faststream import inject


@broker.subscriber("documents")
@inject
async def handle(body: dict, documents: Inject[DocumentService]) -> None: ...

Testing

MessagingTestCase is a neva.testing.TestCase whose application is the App, booted through its own lifespan — so self.app, the facade root and the container a subscriber resolves from are one object, rather than the two a hand-wired test ends up with.

from neva.faststream.testing import MessagingTestCase


class TestDocuments(MessagingTestCase):
    broker: ClassVar[RabbitBroker]

    @override
    @classmethod
    def create_app(cls, config_path: Path) -> App:
        cls.broker = RabbitBroker()
        _ = cls.broker.subscriber("documents")(handle)
        app = App(cls.broker, config_path=config_path)
        _ = app.register(DocumentServiceProvider)
        return app

    async def test_it_processes(self) -> None:
        async with TestRabbitBroker(self.broker):
            await self.broker.publish({"id": 1}, "documents")

        assert Document.processed

Import it from neva.faststream.testing, never from neva.faststream: the module pulls in pytest, which has no business in a production import.

Booting the application and connecting to a broker are separate things, and the class does only the first — so it needs no live broker and imports no driver. Start yours inside the test with TestRabbitBroker, or the equivalent for your driver. Build the broker in create_app rather than at module level: one broker belongs to one App, so a shared one fails in the second test class using it.

Register providers and declare subscribers in create_app, which runs before anything boots. App.register returns Err once booted, and the brokers an App owns are fixed at construction.

Install

uv add neva-faststream

Broker drivers are FastStream's own extras and are not pulled in: install the one you use, e.g. uv add "faststream[rabbit]". Nothing in this package imports a broker module, so it stays agnostic over which you pick.

MessagingTestCase lives behind the testing extra:

uv add --dev "neva-faststream[testing]"

Layout

neva is a namespace package (no top-level neva/__init__.py); this repo owns neva/faststream/ and shares the neva.* namespace with python-neva.

Develop

uv sync          # install/refresh deps
poe lint         # ruff check
poe fmt          # ruff format
poe tc           # pyrefly check
poe test         # pytest
poe test-cov     # pytest with coverage
poe guidelines   # hold the agent guideline fragments to their contract

asyncio_mode = "auto" is set, so async tests need no @pytest.mark.asyncio.

Agent guidelines

This package contributes fragments to neva-boost, which composes the Neva ecosystem's agent guidelines into a project. neva-boost install writes them alongside the core's, and poe guidelines holds this package's own to the frontmatter contract.

Every fragment declares the version it needs, so one documenting an API your installed version does not have is skipped rather than left to mislead an agent.

Contributing

This repo follows the same conventions as the rest of the Neva ecosystem.

Commits use Conventional Commits with gitmoji prefixes, enforced by cz_gitmoji. Commitizen is provided as a dev dependency — run cz commit for the guided wizard, or format manually as :gitmoji: type(scope): subject.

Releases are cut with commitizen from this repo's root:

cz bump                          # bump version in pyproject, write CHANGELOG, tag v<version>
git push --follow-tags origin main

cz bump derives the level (major/minor/patch) from the commits since the last tag, updates CHANGELOG.md, and runs scripts/retag-with-changelog.sh to rewrite the new tag with the rendered changelog as its annotation.

Metadata

Release files for neva-faststream 0.4.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for neva-faststream 0.4.1
File Size Uploaded
neva_faststream-0.4.1.tar.gz 147.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for neva-faststream 0.4.1
File Interpreter ABI Platform
neva_faststream-0.4.1-py3-none-any.whl Python 3 none any Details

Total release size: 172.1 kB

Release files / neva_faststream-0.4.1.tar.gz

Download URL neva_faststream-0.4.1.tar.gz
Size 147.7 kB
Tags Source
SHA-256 checksum
How to use checksums
c5a9b0de042cbef05110ec597b05e2661d9517938ff2af1ec8b8e8f62b403a79
BLAKE2b-256 checksum
How to use checksums
f9fb6a9fbfec156a936c472a57e9d35b65a3022aee3f62372ac523a3fb636a65
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.22

Release files / neva_faststream-0.4.1-py3-none-any.whl

Download URL neva_faststream-0.4.1-py3-none-any.whl
Size 24.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
58c8b4cde43aa46d9546e9489982a8039c5ef46584a06c2ffa99c21feb5f2a07
BLAKE2b-256 checksum
How to use checksums
87c1c56dcdb086b43b54cc5887e73894c9c431db36d0165daef8039fd3d29065
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.22

Release history Release notifications | RSS feed

This release

0.4.1 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page