From 0b4527250d8610c2bd3470df2f1c039f7c977037 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 14 Feb 2026 14:49:05 +0300 Subject: [PATCH 1/2] move faststream integration to separate repo --- .github/workflows/ci.yml | 53 ++++++++++++++++ .github/workflows/publish.yml | 17 ++++++ .gitignore | 22 +++++++ Justfile | 29 +++++++++ LICENSE | 21 +++++++ modern_di_faststream/__init__.py | 9 +++ modern_di_faststream/main.py | 102 +++++++++++++++++++++++++++++++ modern_di_faststream/py.typed | 0 pyproject.toml | 85 ++++++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 14 +++++ tests/dependencies.py | 26 ++++++++ tests/test_faststream_di.py | 57 +++++++++++++++++ 13 files changed, 435 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 Justfile create mode 100644 LICENSE create mode 100644 modern_di_faststream/__init__.py create mode 100644 modern_di_faststream/main.py create mode 100644 modern_di_faststream/py.typed create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/dependencies.py create mode 100644 tests/test_faststream_di.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0f58873 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: main + +on: + push: + branches: + - main + pull_request: {} + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: extractions/setup-just@v2 + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install 3.10 + - run: just install lint-ci + + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + steps: + - uses: actions/checkout@v4 + - uses: extractions/setup-just@v2 + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install ${{ matrix.python-version }} + - run: just install + - run: just test . --cov=. --cov-report xml + - uses: codecov/codecov-action@v4.0.1 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./coverage.xml + flags: unittests + name: codecov-${{ matrix.python-version }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b637272 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,17 @@ +name: Publish Package + +on: + release: + types: + - published + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: extractions/setup-just@v2 + - uses: astral-sh/setup-uv@v3 + - run: just publish + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..068012f --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Generic things +*.pyc +*~ +__pycache__/* +*.swp +*.sqlite3 +*.map +.vscode +.idea +.DS_Store +.env +.mypy_cache +.pytest_cache +.ruff_cache +.coverage +htmlcov/ +coverage.xml +pytest.xml +dist/ +.python-version +.venv +uv.lock diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..fabf8c0 --- /dev/null +++ b/Justfile @@ -0,0 +1,29 @@ +default: install lint test + +install: + uv lock --upgrade + uv sync --all-extras --frozen --group lint + +lint: + uv run eof-fixer . + uv run ruff format + uv run ruff check --fix + uv run mypy . + +lint-ci: + uv run eof-fixer . --check + uv run ruff format --check + uv run ruff check --no-fix + uv run mypy . + +test *args: + uv run --no-sync pytest {{ args }} + +test-branch: + @just test --cov-branch + +publish: + rm -rf dist + uv version $GITHUB_REF_NAME + uv build + uv publish --token $PYPI_TOKEN diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a176c1b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 modern-python + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modern_di_faststream/__init__.py b/modern_di_faststream/__init__.py new file mode 100644 index 0000000..778557d --- /dev/null +++ b/modern_di_faststream/__init__.py @@ -0,0 +1,9 @@ +from modern_di_faststream.main import FromDI, faststream_message_provider, fetch_di_container, setup_di + + +__all__ = [ + "FromDI", + "faststream_message_provider", + "fetch_di_container", + "setup_di", +] diff --git a/modern_di_faststream/main.py b/modern_di_faststream/main.py new file mode 100644 index 0000000..7c9a62d --- /dev/null +++ b/modern_di_faststream/main.py @@ -0,0 +1,102 @@ +import dataclasses +import typing +from collections.abc import Awaitable, Callable +from importlib.metadata import version + +import faststream +import modern_di +from faststream.asgi import AsgiFastStream +from faststream.types import DecodedMessage +from modern_di import Container, Scope, providers + + +T_co = typing.TypeVar("T_co", covariant=True) +P = typing.ParamSpec("P") + + +faststream_message_provider = providers.ContextProvider(scope=Scope.REQUEST, context_type=faststream.StreamMessage) + + +_major, _minor, *_ = version("faststream").split(".") +_OLD_MIDDLEWARES = int(_major) == 0 and int(_minor) < 6 # noqa: PLR2004 + + +class _DIMiddlewareFactory: + __slots__ = ("di_container",) + + def __init__(self, di_container: Container) -> None: + self.di_container = di_container + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "_DiMiddleware[P]": + return _DiMiddleware(self.di_container, *args, **kwargs) + + +class _DiMiddleware(faststream.BaseMiddleware, typing.Generic[P]): + def __init__(self, di_container: Container, *args: P.args, **kwargs: P.kwargs) -> None: + self.di_container = di_container + super().__init__(*args, **kwargs) # type: ignore[arg-type] + + async def consume_scope( + self, + call_next: Callable[[typing.Any], Awaitable[typing.Any]], + msg: faststream.StreamMessage[typing.Any], + ) -> typing.AsyncIterator[DecodedMessage]: + request_container = self.di_container.build_child_container( + scope=modern_di.Scope.REQUEST, context={faststream.StreamMessage: msg} + ) + try: + with self.faststream_context.scope("request_container", request_container): + return typing.cast( + typing.AsyncIterator[DecodedMessage], + await call_next(msg), + ) + finally: + await request_container.close_async() + + if _OLD_MIDDLEWARES: # pragma: no cover + + @property + def faststream_context(self) -> faststream.ContextRepo: + return typing.cast(faststream.ContextRepo, faststream.context) # type: ignore[attr-defined] + + else: + + @property + def faststream_context(self) -> faststream.ContextRepo: + return self.context + + +def fetch_di_container(app_: faststream.FastStream | AsgiFastStream) -> Container: + return typing.cast(Container, app_.context.get("di_container")) + + +def setup_di( + app: faststream.FastStream | AsgiFastStream, + container: Container, +) -> Container: + if not app.broker: + msg = "Broker must be defined to setup DI" + raise RuntimeError(msg) + + container.providers_registry.add_providers(faststream_message_provider) + app.context.set_global("di_container", container) + app.after_shutdown(container.close_async) + app.broker.add_middleware(_DIMiddlewareFactory(container)) + return container + + +@dataclasses.dataclass(slots=True, frozen=True) +class Dependency(typing.Generic[T_co]): + dependency: providers.AbstractProvider[T_co] | type[T_co] + + async def __call__(self, context: faststream.ContextRepo) -> T_co: + request_container: Container = context.get("request_container") + if isinstance(self.dependency, providers.AbstractProvider): + return request_container.resolve_provider(self.dependency) + return request_container.resolve(dependency_type=self.dependency) + + +def FromDI( # noqa: N802 + dependency: providers.AbstractProvider[T_co] | type[T_co], *, use_cache: bool = True, cast: bool = False +) -> T_co: + return typing.cast(T_co, faststream.Depends(dependency=Dependency(dependency), use_cache=use_cache, cast=cast)) diff --git a/modern_di_faststream/py.typed b/modern_di_faststream/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7629f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,85 @@ +[project] +name = "modern-di-faststream" +description = "Modern-DI integration for FastStream" +authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }] +requires-python = ">=3.10,<4" +license = "MIT" +readme = "README.md" +keywords = ["DI", "dependency injector", "ioc-container", "FastStream", "python"] +classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", + "Topic :: Software Development :: Libraries", +] +dependencies = ["faststream>=0.5,<1", "modern-di>=2,<3"] +version = "0" + +[project.urls] +repository = "https://github.com/modern-python/modern-di-faststream" +docs = "https://modern-di.readthedocs.io" + +[dependency-groups] +dev = [ + "faststream[nats]", + "pytest", + "pytest-cov", + "pytest-asyncio", + "httpx", + "asgi-lifespan", +] +lint = [ + "mypy", + "ruff", + "eof-fixer", + "typing-extensions", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = ["modern_di_faststream"] + +[tool.mypy] +python_version = "3.10" +strict = true + +[tool.ruff] +fix = false +unsafe-fixes = true +line-length = 120 +target-version = "py310" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D1", # allow missing docstrings + "S101", # allow asserts + "TCH", # ignore flake8-type-checking + "FBT", # allow boolean args + "D203", # "one-blank-line-before-class" conflicting with D211 + "D213", # "multi-line-summary-second-line" conflicting with D212 + "COM812", # flake8-commas "Trailing comma missing" + "ISC001", # flake8-implicit-str-concat + "G004", # allow f-strings in logging +] +isort.lines-after-imports = 2 +isort.no-lines-before = ["standard-library", "local-folder"] + +[tool.pytest.ini_options] +addopts = "--cov=. --cov-report term-missing" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.report] +exclude_also = [ + "if typing.TYPE_CHECKING:", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..88b6be7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import faststream +import pytest +from faststream.nats import NatsBroker +from modern_di import Container +from tests.dependencies import Dependencies + +from modern_di_faststream import setup_di + + +@pytest.fixture +async def app() -> faststream.FastStream: + app_ = faststream.FastStream(NatsBroker()) + setup_di(app_, container=Container(groups=[Dependencies])) + return app_ diff --git a/tests/dependencies.py b/tests/dependencies.py new file mode 100644 index 0000000..0a78d17 --- /dev/null +++ b/tests/dependencies.py @@ -0,0 +1,26 @@ +import dataclasses +import typing + +from faststream import StreamMessage +from modern_di import Group, Scope, providers + + +@dataclasses.dataclass(kw_only=True, slots=True) +class SimpleCreator: + dep1: str + + +@dataclasses.dataclass(kw_only=True, slots=True) +class DependentCreator: + dep1: SimpleCreator + + +def fetch_message_is_processed_from_request(message: StreamMessage[typing.Any] | None = None) -> bool: + return message.processed if message else False + + +class Dependencies(Group): + app_factory = providers.Factory(creator=SimpleCreator, kwargs={"dep1": "original"}) + request_factory = providers.Factory(scope=Scope.REQUEST, creator=DependentCreator, bound_type=None) + action_factory = providers.Factory(scope=Scope.ACTION, creator=DependentCreator, bound_type=None) + message_is_processed = providers.Factory(scope=Scope.REQUEST, creator=fetch_message_is_processed_from_request) diff --git a/tests/test_faststream_di.py b/tests/test_faststream_di.py new file mode 100644 index 0000000..562ace4 --- /dev/null +++ b/tests/test_faststream_di.py @@ -0,0 +1,57 @@ +import typing + +import faststream +import pytest +from faststream import TestApp +from faststream.nats import NatsBroker, TestNatsBroker +from modern_di import Container +from tests.dependencies import Dependencies, DependentCreator, SimpleCreator + +import modern_di_faststream +from modern_di_faststream import FromDI + + +TEST_SUBJECT = "test" + + +async def test_factories(app: faststream.FastStream) -> None: + broker = typing.cast(NatsBroker, app.broker) + + @broker.subscriber(TEST_SUBJECT) + async def index_subscriber( + message: str, + app_factory_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + request_factory_instance: typing.Annotated[DependentCreator, FromDI(Dependencies.request_factory)], + ) -> None: + assert message == "test" + assert isinstance(app_factory_instance, SimpleCreator) + assert isinstance(request_factory_instance, DependentCreator) + assert request_factory_instance.dep1 is not app_factory_instance + + async with TestNatsBroker(broker) as br, TestApp(app): + await br.publish("test", TEST_SUBJECT) + + +async def test_context_adapter(app: faststream.FastStream) -> None: + broker = typing.cast(NatsBroker, app.broker) + + @broker.subscriber(TEST_SUBJECT) + async def index_subscriber( + message_is_processed: typing.Annotated[bool, FromDI(Dependencies.message_is_processed)], + ) -> None: + assert message_is_processed is False + + async with TestNatsBroker(broker) as br, TestApp(app): + result = await br.request(None, TEST_SUBJECT) + result_str = await result.decode() + assert result_str == b"" + + +async def test_app_without_broker() -> None: + with pytest.raises(RuntimeError, match="Broker must be defined to setup DI"): + modern_di_faststream.setup_di(faststream.FastStream(), container=Container()) + + +def test_fetch_di_container(app: faststream.FastStream) -> None: + di_container = modern_di_faststream.fetch_di_container(app) + assert isinstance(di_container, Container) From 4acb7b2ba23a93809226f869167e62fa9913b5bf Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 14 Feb 2026 14:50:02 +0300 Subject: [PATCH 2/2] fix --- tests/conftest.py | 2 +- tests/test_faststream_di.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 88b6be7..8e50e6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,9 @@ import pytest from faststream.nats import NatsBroker from modern_di import Container -from tests.dependencies import Dependencies from modern_di_faststream import setup_di +from tests.dependencies import Dependencies @pytest.fixture diff --git a/tests/test_faststream_di.py b/tests/test_faststream_di.py index 562ace4..d0b5bb3 100644 --- a/tests/test_faststream_di.py +++ b/tests/test_faststream_di.py @@ -5,10 +5,10 @@ from faststream import TestApp from faststream.nats import NatsBroker, TestNatsBroker from modern_di import Container -from tests.dependencies import Dependencies, DependentCreator, SimpleCreator import modern_di_faststream from modern_di_faststream import FromDI +from tests.dependencies import Dependencies, DependentCreator, SimpleCreator TEST_SUBJECT = "test"