From 08b86ee69a3ae7d939ba048bf3b1d90ed2b087e0 Mon Sep 17 00:00:00 2001 From: "Oleg A." Date: Sat, 12 Oct 2024 12:42:36 +0300 Subject: [PATCH 01/48] Replace async Event with bool (#1846) * fix: replace async Event with bool * chore: ignore ASYNC110 * fix: replace asyncio with anyio --- faststream/_internal/application.py | 10 +++++++--- faststream/app.py | 12 +++--------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/faststream/_internal/application.py b/faststream/_internal/application.py index dd0140db4d..ff2e39fac4 100644 --- a/faststream/_internal/application.py +++ b/faststream/_internal/application.py @@ -23,7 +23,6 @@ P_HookParams = ParamSpec("P_HookParams") T_HookReturn = TypeVar("T_HookReturn") - if TYPE_CHECKING: from faststream.asyncapi.schema import ( Contact, @@ -71,7 +70,7 @@ def __init__( ) -> None: context.set_global("app", self) - self._should_exit = anyio.Event() + self._should_exit = False self.broker = broker self.logger = logger self.context = context @@ -159,7 +158,12 @@ def after_shutdown( def exit(self) -> None: """Stop application manually.""" - self._should_exit.set() + self._should_exit = True + + async def _main_loop(self, sleep_time: float) -> None: + """Run loop till exit signal.""" + while not self._should_exit: # noqa: ASYNC110 (requested by creator) + await anyio.sleep(sleep_time) async def start( self, diff --git a/faststream/app.py b/faststream/app.py index 3f6c2d546f..debe04f7dc 100644 --- a/faststream/app.py +++ b/faststream/app.py @@ -10,7 +10,7 @@ ) import anyio -from typing_extensions import Annotated, ParamSpec, deprecated +from typing_extensions import ParamSpec from faststream._compat import ExceptionGroup from faststream._internal.application import Application @@ -35,13 +35,7 @@ async def run( self, log_level: int = logging.INFO, run_extra_options: Optional[Dict[str, "SettingField"]] = None, - sleep_time: Annotated[ - float, - deprecated( - "Deprecated in **FastStream 0.5.24**. " - "Argument will be removed in **FastStream 0.6.0**." - ), - ] = 0.1, + sleep_time: float = 0.1, ) -> None: """Run FastStream Application.""" assert self.broker, "You should setup a broker" # nosec B101 @@ -54,7 +48,7 @@ async def run( try: async with anyio.create_task_group() as tg: tg.start_soon(self._startup, log_level, run_extra_options) - await self._should_exit.wait() + await self._main_loop(sleep_time) await self._shutdown(log_level) tg.cancel_scope.cancel() except ExceptionGroup as e: From c23106e2fc13c2e553401b38b177d8924d53c2cc Mon Sep 17 00:00:00 2001 From: Davor Runje Date: Sat, 12 Oct 2024 17:53:20 +0200 Subject: [PATCH 02/48] Add support for Python 3.13 (#1845) * upgraded Python to 3.13 * tests: fix 3.13 tabulation problem --------- Co-authored-by: Nikita Pastukhov --- .github/workflows/pr_tests.yaml | 26 +++++++------- pyproject.toml | 6 +++- .../asyncapi_customization/test_handler.py | 36 +++++++++++++------ 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/.github/workflows/pr_tests.yaml b/.github/workflows/pr_tests.yaml index 7cb3f21fcc..5e7259a6fb 100644 --- a/.github/workflows/pr_tests.yaml +++ b/.github/workflows/pr_tests.yaml @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] pydantic-version: ["pydantic-v1", "pydantic-v2"] fail-fast: false @@ -105,7 +105,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -124,7 +124,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -143,7 +143,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -182,7 +182,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -212,7 +212,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -251,7 +251,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -281,7 +281,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -309,7 +309,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -339,7 +339,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -367,7 +367,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -397,7 +397,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | @@ -425,7 +425,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | diff --git a/pyproject.toml b/pyproject.toml index edfbea8cd8..9c070bc318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", @@ -66,7 +67,10 @@ rabbit = ["aio-pika>=9,<10"] kafka = ["aiokafka>=0.9,<0.12"] -confluent = ["confluent-kafka>=2,<3"] +confluent = [ + "confluent-kafka>=2,<3; python_version < '3.13'", + "confluent-kafka>=2.6,<3; python_version >= '3.13'", +] nats = ["nats-py>=2.7.0,<=3.0.0"] diff --git a/tests/a_docs/getting_started/asyncapi/asyncapi_customization/test_handler.py b/tests/a_docs/getting_started/asyncapi/asyncapi_customization/test_handler.py index 6453f5a8d8..31eead7a3b 100644 --- a/tests/a_docs/getting_started/asyncapi/asyncapi_customization/test_handler.py +++ b/tests/a_docs/getting_started/asyncapi/asyncapi_customization/test_handler.py @@ -1,3 +1,5 @@ +from dirty_equals import IsPartialDict + from docs.docs_src.getting_started.asyncapi.asyncapi_customization.custom_handler import ( app, ) @@ -7,21 +9,33 @@ def test_handler_customization(): schema = get_app_schema(app).to_jsonable() - assert schema["channels"] == { - "input_data:Consume": { - "description": "Consumer function\n\n Args:\n msg: input msg\n ", + (subscriber_key, subscriber_value), (publisher_key, publisher_value) = schema[ + "channels" + ].items() + + assert subscriber_key == "input_data:Consume", subscriber_key + assert subscriber_value == IsPartialDict( + { "servers": ["development"], "bindings": {"kafka": {"topic": "input_data", "bindingVersion": "0.4.0"}}, "subscribe": { "message": {"$ref": "#/components/messages/input_data:Consume:Message"} }, - }, - "output_data:Produce": { - "description": "My publisher description", - "servers": ["development"], - "bindings": {"kafka": {"topic": "output_data", "bindingVersion": "0.4.0"}}, - "publish": { - "message": {"$ref": "#/components/messages/output_data:Produce:Message"} - }, + } + ), subscriber_value + desc = subscriber_value["description"] + assert ( # noqa: PT018 + "Consumer function\n\n" in desc + and "Args:\n" in desc + and " msg: input msg" in desc + ), desc + + assert publisher_key == "output_data:Produce", publisher_key + assert publisher_value == { + "description": "My publisher description", + "servers": ["development"], + "bindings": {"kafka": {"topic": "output_data", "bindingVersion": "0.4.0"}}, + "publish": { + "message": {"$ref": "#/components/messages/output_data:Produce:Message"} }, } From 10a079625d03da6504c2041d5ce77c56c8efb360 Mon Sep 17 00:00:00 2001 From: "airt-release-notes-updater[bot]" <153718812+airt-release-notes-updater[bot]@users.noreply.github.com> Date: Sat, 12 Oct 2024 18:51:12 +0000 Subject: [PATCH 03/48] Update Release Notes for 0.5.26 (#1847) * Update Release Notes for 0.5.26 * Update release.md * Update detect secrets --------- Co-authored-by: Lancetnik <44573917+Lancetnik@users.noreply.github.com> Co-authored-by: Pastukhov Nikita Co-authored-by: Kumaran Rajendhiran --- .secrets.baseline | 4 ++-- docs/docs/en/release.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index f4836e2025..88df582f08 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -153,7 +153,7 @@ "filename": "docs/docs/en/release.md", "hashed_secret": "35675e68f4b5af7b995d9205ad0fc43842f16450", "is_verified": false, - "line_number": 1723, + "line_number": 1745, "is_secret": false } ], @@ -178,5 +178,5 @@ } ] }, - "generated_at": "2024-09-25T19:57:57Z" + "generated_at": "2024-10-12T18:42:47Z" } diff --git a/docs/docs/en/release.md b/docs/docs/en/release.md index 87cf981708..abfa570024 100644 --- a/docs/docs/en/release.md +++ b/docs/docs/en/release.md @@ -12,6 +12,28 @@ hide: --- # Release Notes +## 0.5.26 + +### What's Changed + +This it the official **Python 3.13** support! Now, **FastStream** works (and tested) at **Python 3.8 - 3.13** versions! + +Warning: **Python3.8** is EOF since **3.13** release and we plan to drop it support in **FastStream 0.6.0** version. + +Also, current release has little bugfixes related to **CLI** and **AsyncAPI** schema. + +* fix: asgi docs by [@Sehat1137](https://github.com/Sehat1137){.external-link target="_blank"} in [#1828](https://github.com/airtai/faststream/pull/1828){.external-link target="_blank"} +* docs: add link to RU TG community by [@Lancetnik](https://github.com/Lancetnik){.external-link target="_blank"} in [#1831](https://github.com/airtai/faststream/pull/1831){.external-link target="_blank"} +* docs: add dynaconf NATS HowTo example by [@sheldygg](https://github.com/sheldygg){.external-link target="_blank"} in [#1832](https://github.com/airtai/faststream/pull/1832){.external-link target="_blank"} +* Fix AsyncAPI 2.6.0 operation label by [@KrySeyt](https://github.com/KrySeyt){.external-link target="_blank"} in [#1835](https://github.com/airtai/faststream/pull/1835){.external-link target="_blank"} +* fix: correct CLI factory behavior by [@Lancetnik](https://github.com/Lancetnik){.external-link target="_blank"} in [#1838](https://github.com/airtai/faststream/pull/1838){.external-link target="_blank"} +* Autocommit precommit changes by [@kumaranvpl](https://github.com/kumaranvpl){.external-link target="_blank"} in [#1840](https://github.com/airtai/faststream/pull/1840){.external-link target="_blank"} +* Add devcontainers supporting all the brokers by [@kumaranvpl](https://github.com/kumaranvpl){.external-link target="_blank"} in [#1839](https://github.com/airtai/faststream/pull/1839){.external-link target="_blank"} +* Replace async Event with bool by [@Olegt0rr](https://github.com/Olegt0rr){.external-link target="_blank"} in [#1846](https://github.com/airtai/faststream/pull/1846){.external-link target="_blank"} +* Add support for Python 3.13 by [@davorrunje](https://github.com/davorrunje){.external-link target="_blank"} in [#1845](https://github.com/airtai/faststream/pull/1845){.external-link target="_blank"} + +**Full Changelog**: [#0.5.25...0.5.26](https://github.com/airtai/faststream/compare/0.5.25...0.5.26){.external-link target="_blank"} + ## 0.5.25 ### What's Changed From 6bdea98ce6638e5bb0cb47d40aec4b98b5446b4c Mon Sep 17 00:00:00 2001 From: dotX12 <64792903+dotX12@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:33:07 +0300 Subject: [PATCH 04/48] fix: anyio major version parser (#1850) * fix: anyio major version parser * chore: bump version --------- Co-authored-by: Pastukhov Nikita --- faststream/__about__.py | 2 +- faststream/_compat.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/faststream/__about__.py b/faststream/__about__.py index 6bc5c9573f..a829efe0e2 100644 --- a/faststream/__about__.py +++ b/faststream/__about__.py @@ -1,5 +1,5 @@ """Simple and fast framework to create message brokers based microservices.""" -__version__ = "0.5.26" +__version__ = "0.5.27" SERVICE_NAME = f"faststream-{__version__}" diff --git a/faststream/_compat.py b/faststream/_compat.py index e605f64ece..08c150d382 100644 --- a/faststream/_compat.py +++ b/faststream/_compat.py @@ -150,7 +150,7 @@ def with_info_plain_validator_function( # type: ignore[misc] return {} -anyio_major, *_ = map(int, get_version("anyio").split(".")) +anyio_major = int(get_version("anyio").split(".")[0]) ANYIO_V3 = anyio_major == 3 From e9246d889c243e0848888fd3f788fa1bb5057de3 Mon Sep 17 00:00:00 2001 From: "airt-release-notes-updater[bot]" <153718812+airt-release-notes-updater[bot]@users.noreply.github.com> Date: Tue, 15 Oct 2024 04:57:04 +0000 Subject: [PATCH 05/48] Update Release Notes for 0.5.27 (#1851) * Update Release Notes for 0.5.27 * Update secrets --------- Co-authored-by: Lancetnik <44573917+Lancetnik@users.noreply.github.com> Co-authored-by: Kumaran Rajendhiran --- .secrets.baseline | 4 ++-- docs/docs/en/release.md | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 88df582f08..7964330ac3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -153,7 +153,7 @@ "filename": "docs/docs/en/release.md", "hashed_secret": "35675e68f4b5af7b995d9205ad0fc43842f16450", "is_verified": false, - "line_number": 1745, + "line_number": 1756, "is_secret": false } ], @@ -178,5 +178,5 @@ } ] }, - "generated_at": "2024-10-12T18:42:47Z" + "generated_at": "2024-10-15T04:48:28Z" } diff --git a/docs/docs/en/release.md b/docs/docs/en/release.md index abfa570024..a6e1c4855c 100644 --- a/docs/docs/en/release.md +++ b/docs/docs/en/release.md @@ -12,6 +12,17 @@ hide: --- # Release Notes +## 0.5.27 + +### What's Changed + +* fix: anyio major version parser by [@dotX12](https://github.com/dotX12){.external-link target="_blank"} in [#1850](https://github.com/airtai/faststream/pull/1850){.external-link target="_blank"} + +### New Contributors +* [@dotX12](https://github.com/dotX12){.external-link target="_blank"} made their first contribution in [#1850](https://github.com/airtai/faststream/pull/1850){.external-link target="_blank"} + +**Full Changelog**: [#0.5.26...0.5.27](https://github.com/airtai/faststream/compare/0.5.26...0.5.27){.external-link target="_blank"} + ## 0.5.26 ### What's Changed From 89103746793c52a7cf46317e8ec58140dd2e731f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:13:51 +0300 Subject: [PATCH 06/48] chore(deps): bump the pip group with 8 updates (#1848) * chore(deps): bump the pip group with 8 updates Bumps the pip group with 8 updates: | Package | From | To | | --- | --- | --- | | [mkdocs-material](https://github.com/squidfunk/mkdocs-material) | `9.5.39` | `9.5.40` | | [mkdocstrings[python]](https://github.com/mkdocstrings/mkdocstrings) | `0.26.1` | `0.26.2` | | [mkdocs-macros-plugin](https://github.com/fralau/mkdocs_macros_plugin) | `1.2.0` | `1.3.5` | | [mypy](https://github.com/python/mypy) | `1.11.2` | `1.12.0` | | [semgrep](https://github.com/returntocorp/semgrep) | `1.90.0` | `1.91.0` | | [coverage[toml]](https://github.com/nedbat/coveragepy) | `7.6.1` | `7.6.3` | | [fastapi](https://github.com/fastapi/fastapi) | `0.115.0` | `0.115.2` | | [pre-commit](https://github.com/pre-commit/pre-commit) | `4.0.0` | `4.0.1` | Updates `mkdocs-material` from 9.5.39 to 9.5.40 - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.39...9.5.40) Updates `mkdocstrings[python]` from 0.26.1 to 0.26.2 - [Release notes](https://github.com/mkdocstrings/mkdocstrings/releases) - [Changelog](https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/mkdocstrings/compare/0.26.1...0.26.2) Updates `mkdocs-macros-plugin` from 1.2.0 to 1.3.5 - [Release notes](https://github.com/fralau/mkdocs_macros_plugin/releases) - [Changelog](https://github.com/fralau/mkdocs-macros-plugin/blob/master/CHANGELOG.md) - [Commits](https://github.com/fralau/mkdocs_macros_plugin/compare/v1.2.0...v1.3.5) Updates `mypy` from 1.11.2 to 1.12.0 - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.11.2...v1.12.0) Updates `semgrep` from 1.90.0 to 1.91.0 - [Release notes](https://github.com/returntocorp/semgrep/releases) - [Changelog](https://github.com/semgrep/semgrep/blob/develop/CHANGELOG.md) - [Commits](https://github.com/returntocorp/semgrep/compare/v1.90.0...v1.91.0) Updates `coverage[toml]` from 7.6.1 to 7.6.3 - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.6.1...7.6.3) Updates `fastapi` from 0.115.0 to 0.115.2 - [Release notes](https://github.com/fastapi/fastapi/releases) - [Commits](https://github.com/fastapi/fastapi/compare/0.115.0...0.115.2) Updates `pre-commit` from 4.0.0 to 4.0.1 - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.0.0...v4.0.1) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: mkdocstrings[python] dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: mkdocs-macros-plugin dependency-type: direct:production update-type: version-update:semver-minor dependency-group: pip - dependency-name: mypy dependency-type: direct:production update-type: version-update:semver-minor dependency-group: pip - dependency-name: semgrep dependency-type: direct:production update-type: version-update:semver-minor dependency-group: pip - dependency-name: coverage[toml] dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: fastapi dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip ... Signed-off-by: dependabot[bot] * chore: fix errors * chore: fix coverage version --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kumaran Rajendhiran Co-authored-by: Nikita Pastukhov --- faststream/utils/functions.py | 2 +- pyproject.toml | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/faststream/utils/functions.py b/faststream/utils/functions.py index 453c70ffc7..685ab7da90 100644 --- a/faststream/utils/functions.py +++ b/faststream/utils/functions.py @@ -60,7 +60,7 @@ def timeout_scope( raise_timeout: bool = False, ) -> ContextManager[anyio.CancelScope]: scope: Callable[[Optional[float]], ContextManager[anyio.CancelScope]] - scope = anyio.fail_after if raise_timeout else anyio.move_on_after # type: ignore[assignment] + scope = anyio.fail_after if raise_timeout else anyio.move_on_after return scope(timeout) diff --git a/pyproject.toml b/pyproject.toml index 9c070bc318..10d7a66092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,15 +87,15 @@ cli = [ optionals = ["faststream[rabbit,kafka,confluent,nats,redis,otel,cli]"] devdocs = [ - "mkdocs-material==9.5.39", + "mkdocs-material==9.5.40", "mkdocs-static-i18n==1.2.3", "mdx-include==1.4.2", - "mkdocstrings[python]==0.26.1", + "mkdocstrings[python]==0.26.2", "mkdocs-literate-nav==0.6.1", "mkdocs-git-revision-date-localized-plugin==1.2.9", "mike==2.1.3", # versioning "mkdocs-minify-plugin==0.8.0", - "mkdocs-macros-plugin==1.2.0", # includes with variables + "mkdocs-macros-plugin==1.3.5", # includes with variables "mkdocs-glightbox==0.4.0", # img zoom "pillow", # required for mkdocs-glightbo "cairosvg", # required for mkdocs-glightbo @@ -104,7 +104,7 @@ devdocs = [ types = [ "faststream[optionals]", - "mypy==1.11.2", + "mypy==1.12.0", # mypy extensions "types-Deprecated", "types-PyYAML", @@ -120,12 +120,13 @@ lint = [ "faststream[types]", "ruff==0.6.9", "bandit==1.7.10", - "semgrep==1.90.0", + "semgrep==1.91.0", "codespell==2.3.0", ] test-core = [ - "coverage[toml]==7.6.1", + "coverage[toml]==7.6.1; python_version == '3.8'", + "coverage[toml]==7.6.3; python_version >= '3.9'", "pytest==8.3.3", "pytest-asyncio==0.24.0", "dirty-equals==0.8.0", @@ -134,7 +135,7 @@ test-core = [ testing = [ "faststream[test-core]", - "fastapi==0.115.0", + "fastapi==0.115.2", "pydantic-settings>=2.0.0,<3.0.0", "httpx==0.27.2", "PyYAML==6.0.2", @@ -144,7 +145,7 @@ testing = [ dev = [ "faststream[optionals,lint,testing,devdocs]", "pre-commit==3.5.0; python_version < '3.9'", - "pre-commit==4.0.0; python_version >= '3.9'", + "pre-commit==4.0.1; python_version >= '3.9'", "detect-secrets==1.5.0", ] From 8524b7f58d4ad6136dbc3ecfb3495f0b4e70b40c Mon Sep 17 00:00:00 2001 From: Tim Hutchinson Date: Wed, 16 Oct 2024 13:46:51 -0400 Subject: [PATCH 07/48] docs: Correct minimum FastAPI version for lifespan handling (#1853) --- docs/docs/en/getting-started/integrations/fastapi/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/en/getting-started/integrations/fastapi/index.md b/docs/docs/en/getting-started/integrations/fastapi/index.md index 9ece2eab5e..c020dfcb5d 100644 --- a/docs/docs/en/getting-started/integrations/fastapi/index.md +++ b/docs/docs/en/getting-started/integrations/fastapi/index.md @@ -27,7 +27,7 @@ Just import a **StreamRouter** you need and declare the message handler in the s {! includes/getting_started/integrations/fastapi/1.md !} !!! warning - If you are using **fastapi < 0.102.2** version, you should setup lifespan manually `#!python FastAPI(lifespan=router.lifespan_context)` + If you are using **fastapi < 0.112.2** version, you should setup lifespan manually `#!python FastAPI(lifespan=router.lifespan_context)` When processing a message from a broker, the entire message body is placed simultaneously in both the `body` and `path` request parameters. You can access them in any way convenient for you. The message header is placed in `headers`. From aa6dc13c723f6c482dfca325b309fd89189d1985 Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Thu, 17 Oct 2024 22:00:43 +0300 Subject: [PATCH 08/48] rm useless change --- faststream/_internal/application.py | 1 - 1 file changed, 1 deletion(-) diff --git a/faststream/_internal/application.py b/faststream/_internal/application.py index 2355f0e918..15c389b6e8 100644 --- a/faststream/_internal/application.py +++ b/faststream/_internal/application.py @@ -101,7 +101,6 @@ def __init__( context.set_global("app", self) - self.broker = broker self.logger = logger self.context = context From f63df92fee629ac1a3285026182ff6c9c3a2b93b Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Fri, 18 Oct 2024 17:22:04 +0300 Subject: [PATCH 09/48] refactor: create publisher factory --- faststream/_internal/publisher/proto.py | 6 - faststream/_internal/publisher/usecase.py | 4 +- faststream/confluent/broker/registrator.py | 9 +- faststream/confluent/fastapi/__init__.py | 3 +- faststream/confluent/fastapi/fastapi.py | 4 +- faststream/confluent/publisher/factory.py | 139 +++++++++++++ faststream/confluent/publisher/publisher.py | 190 ------------------ faststream/confluent/publisher/specified.py | 58 ++++++ faststream/confluent/subscriber/factory.py | 2 +- .../{subscriber.py => specified.py} | 0 faststream/confluent/testing.py | 16 +- faststream/kafka/broker/registrator.py | 8 +- faststream/kafka/fastapi/__init__.py | 3 +- faststream/kafka/fastapi/fastapi.py | 4 +- faststream/kafka/publisher/factory.py | 138 +++++++++++++ faststream/kafka/publisher/publisher.py | 190 ------------------ faststream/kafka/publisher/specified.py | 56 ++++++ faststream/kafka/subscriber/factory.py | 2 +- .../{subscriber.py => specified.py} | 0 faststream/kafka/testing.py | 16 +- faststream/nats/broker/broker.py | 4 +- faststream/nats/broker/registrator.py | 7 +- faststream/nats/fastapi/__init__.py | 3 +- faststream/nats/fastapi/fastapi.py | 6 +- faststream/nats/publisher/factory.py | 43 ++++ faststream/nats/publisher/publisher.py | 82 -------- faststream/nats/publisher/specified.py | 36 ++++ .../{subscription.py => adapters.py} | 0 faststream/nats/subscriber/factory.py | 2 +- .../{subscriber.py => specified.py} | 0 faststream/nats/subscriber/usecase.py | 2 +- faststream/nats/testing.py | 2 +- faststream/rabbit/broker/registrator.py | 9 +- faststream/rabbit/fastapi/__init__.py | 3 +- .../rabbit/fastapi/{router.py => fastapi.py} | 6 +- faststream/rabbit/publisher/factory.py | 43 ++++ .../publisher/{publisher.py => specified.py} | 45 +---- faststream/rabbit/schemas/proto.py | 2 +- faststream/rabbit/subscriber/factory.py | 2 +- .../{subscriber.py => specified.py} | 0 faststream/rabbit/testing.py | 10 +- faststream/redis/broker/registrator.py | 11 +- faststream/redis/fastapi/__init__.py | 3 +- faststream/redis/fastapi/fastapi.py | 6 +- faststream/redis/publisher/factory.py | 107 ++++++++++ faststream/redis/publisher/publisher.py | 183 ----------------- faststream/redis/publisher/specified.py | 89 ++++++++ faststream/redis/schemas/proto.py | 2 +- faststream/redis/subscriber/factory.py | 2 +- .../{subscriber.py => specified.py} | 4 +- faststream/redis/testing.py | 2 +- 51 files changed, 789 insertions(+), 775 deletions(-) create mode 100644 faststream/confluent/publisher/factory.py delete mode 100644 faststream/confluent/publisher/publisher.py create mode 100644 faststream/confluent/publisher/specified.py rename faststream/confluent/subscriber/{subscriber.py => specified.py} (100%) create mode 100644 faststream/kafka/publisher/factory.py delete mode 100644 faststream/kafka/publisher/publisher.py create mode 100644 faststream/kafka/publisher/specified.py rename faststream/kafka/subscriber/{subscriber.py => specified.py} (100%) create mode 100644 faststream/nats/publisher/factory.py delete mode 100644 faststream/nats/publisher/publisher.py create mode 100644 faststream/nats/publisher/specified.py rename faststream/nats/subscriber/{subscription.py => adapters.py} (100%) rename faststream/nats/subscriber/{subscriber.py => specified.py} (100%) rename faststream/rabbit/fastapi/{router.py => fastapi.py} (99%) create mode 100644 faststream/rabbit/publisher/factory.py rename faststream/rabbit/publisher/{publisher.py => specified.py} (72%) rename faststream/rabbit/subscriber/{subscriber.py => specified.py} (100%) create mode 100644 faststream/redis/publisher/factory.py delete mode 100644 faststream/redis/publisher/publisher.py create mode 100644 faststream/redis/publisher/specified.py rename faststream/redis/subscriber/{subscriber.py => specified.py} (95%) diff --git a/faststream/_internal/publisher/proto.py b/faststream/_internal/publisher/proto.py index 074fe97d97..1cb920b280 100644 --- a/faststream/_internal/publisher/proto.py +++ b/faststream/_internal/publisher/proto.py @@ -87,12 +87,6 @@ class PublisherProto( @abstractmethod def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: ... - @staticmethod - @abstractmethod - def create() -> "PublisherProto[MsgType]": - """Abstract factory to create a real Publisher.""" - ... - @override @abstractmethod def _setup( # type: ignore[override] diff --git a/faststream/_internal/publisher/usecase.py b/faststream/_internal/publisher/usecase.py index dbff727a29..b52ea7c387 100644 --- a/faststream/_internal/publisher/usecase.py +++ b/faststream/_internal/publisher/usecase.py @@ -32,9 +32,7 @@ ) -class PublisherUsecase( - PublisherProto[MsgType], -): +class PublisherUsecase(PublisherProto[MsgType]): """A base class for publishers in an asynchronous API.""" mock: Optional[MagicMock] diff --git a/faststream/confluent/broker/registrator.py b/faststream/confluent/broker/registrator.py index 823238e587..289ee413ac 100644 --- a/faststream/confluent/broker/registrator.py +++ b/faststream/confluent/broker/registrator.py @@ -13,7 +13,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker -from faststream.confluent.publisher.publisher import SpecificationPublisher +from faststream.confluent.publisher.factory import create_publisher from faststream.confluent.subscriber.factory import create_subscriber from faststream.exceptions import SetupError @@ -27,12 +27,12 @@ SubscriberMiddleware, ) from faststream.confluent.message import KafkaMessage - from faststream.confluent.publisher.publisher import ( + from faststream.confluent.publisher.specified import ( SpecificationBatchPublisher, SpecificationDefaultPublisher, ) from faststream.confluent.schemas import TopicPartition - from faststream.confluent.subscriber.subscriber import ( + from faststream.confluent.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) @@ -1508,7 +1508,7 @@ def publisher( Or you can create a publisher object to call it lately - `broker.publisher(...).publish(...)`. """ - publisher = SpecificationPublisher.create( + publisher = create_publisher( # batch flag batch=batch, # default args @@ -1530,4 +1530,5 @@ def publisher( if batch: return cast("SpecificationBatchPublisher", super().publisher(publisher)) + return cast("SpecificationDefaultPublisher", super().publisher(publisher)) diff --git a/faststream/confluent/fastapi/__init__.py b/faststream/confluent/fastapi/__init__.py index dc7cb73000..21354fcf98 100644 --- a/faststream/confluent/fastapi/__init__.py +++ b/faststream/confluent/fastapi/__init__.py @@ -2,10 +2,11 @@ from faststream._internal.fastapi.context import Context, ContextRepo, Logger from faststream.confluent.broker import KafkaBroker as KB -from faststream.confluent.fastapi.fastapi import KafkaRouter from faststream.confluent.message import KafkaMessage as KM from faststream.confluent.publisher.producer import AsyncConfluentFastProducer +from .fastapi import KafkaRouter + __all__ = ( "Context", "ContextRepo", diff --git a/faststream/confluent/fastapi/fastapi.py b/faststream/confluent/fastapi/fastapi.py index 7ebc3a6b77..73f6972a40 100644 --- a/faststream/confluent/fastapi/fastapi.py +++ b/faststream/confluent/fastapi/fastapi.py @@ -42,12 +42,12 @@ ) from faststream.confluent.config import ConfluentConfig from faststream.confluent.message import KafkaMessage - from faststream.confluent.publisher.publisher import ( + from faststream.confluent.publisher.specified import ( SpecificationBatchPublisher, SpecificationDefaultPublisher, ) from faststream.confluent.schemas import TopicPartition - from faststream.confluent.subscriber.subscriber import ( + from faststream.confluent.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) diff --git a/faststream/confluent/publisher/factory.py b/faststream/confluent/publisher/factory.py new file mode 100644 index 0000000000..284536604d --- /dev/null +++ b/faststream/confluent/publisher/factory.py @@ -0,0 +1,139 @@ +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Optional, + Union, + overload, +) + +from faststream.exceptions import SetupError + +from .specified import SpecificationBatchPublisher, SpecificationDefaultPublisher + +if TYPE_CHECKING: + from confluent_kafka import Message as ConfluentMsg + + from faststream._internal.types import BrokerMiddleware, PublisherMiddleware + + +@overload +def create_publisher( + *, + batch: Literal[True], + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable["BrokerMiddleware[tuple[ConfluentMsg, ...]]"], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> "SpecificationBatchPublisher": ... + + +@overload +def create_publisher( + *, + batch: Literal[False], + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable["BrokerMiddleware[ConfluentMsg]"], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> "SpecificationDefaultPublisher": ... + + +@overload +def create_publisher( + *, + batch: bool, + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable[ + "BrokerMiddleware[Union[tuple[ConfluentMsg, ...], ConfluentMsg]]" + ], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> Union[ + "SpecificationBatchPublisher", + "SpecificationDefaultPublisher", +]: ... + + +def create_publisher( + *, + batch: bool, + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable[ + "BrokerMiddleware[Union[tuple[ConfluentMsg, ...], ConfluentMsg]]" + ], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> Union[ + "SpecificationBatchPublisher", + "SpecificationDefaultPublisher", +]: + if batch: + if key: + msg = "You can't setup `key` with batch publisher" + raise SetupError(msg) + + return SpecificationBatchPublisher( + topic=topic, + partition=partition, + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + schema_=schema_, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) + + return SpecificationDefaultPublisher( + key=key, + # basic args + topic=topic, + partition=partition, + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + schema_=schema_, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) diff --git a/faststream/confluent/publisher/publisher.py b/faststream/confluent/publisher/publisher.py deleted file mode 100644 index 39ed1ac865..0000000000 --- a/faststream/confluent/publisher/publisher.py +++ /dev/null @@ -1,190 +0,0 @@ -from collections.abc import Iterable -from typing import ( - TYPE_CHECKING, - Any, - Literal, - Optional, - Union, - overload, -) - -from typing_extensions import override - -from faststream._internal.types import MsgType -from faststream.confluent.publisher.usecase import ( - BatchPublisher, - DefaultPublisher, - LogicPublisher, -) -from faststream.exceptions import SetupError -from faststream.specification.asyncapi.utils import resolve_payloads -from faststream.specification.schema.bindings import ChannelBinding, kafka -from faststream.specification.schema.channel import Channel -from faststream.specification.schema.message import CorrelationId, Message -from faststream.specification.schema.operation import Operation - -if TYPE_CHECKING: - from confluent_kafka import Message as ConfluentMsg - - from faststream._internal.types import BrokerMiddleware, PublisherMiddleware - - -class SpecificationPublisher(LogicPublisher[MsgType]): - """A class representing a publisher.""" - - def get_name(self) -> str: - return f"{self.topic}:Publisher" - - def get_schema(self) -> dict[str, Channel]: - payloads = self.get_payloads() - - return { - self.name: Channel( - description=self.description, - publish=Operation( - message=Message( - title=f"{self.name}:Message", - payload=resolve_payloads(payloads, "Publisher"), - correlationId=CorrelationId( - location="$message.header#/correlation_id", - ), - ), - ), - bindings=ChannelBinding(kafka=kafka.ChannelBinding(topic=self.topic)), - ), - } - - @overload # type: ignore[override] - @staticmethod - def create( - *, - batch: Literal[True], - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable["BrokerMiddleware[tuple[ConfluentMsg, ...]]"], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> "SpecificationBatchPublisher": ... - - @overload - @staticmethod - def create( - *, - batch: Literal[False], - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable["BrokerMiddleware[ConfluentMsg]"], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> "SpecificationDefaultPublisher": ... - - @overload - @staticmethod - def create( - *, - batch: bool, - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable[ - "BrokerMiddleware[Union[tuple[ConfluentMsg, ...], ConfluentMsg]]" - ], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> Union[ - "SpecificationBatchPublisher", - "SpecificationDefaultPublisher", - ]: ... - - @override - @staticmethod - def create( - *, - batch: bool, - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable[ - "BrokerMiddleware[Union[tuple[ConfluentMsg, ...], ConfluentMsg]]" - ], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> Union[ - "SpecificationBatchPublisher", - "SpecificationDefaultPublisher", - ]: - if batch: - if key: - msg = "You can't setup `key` with batch publisher" - raise SetupError(msg) - - return SpecificationBatchPublisher( - topic=topic, - partition=partition, - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - schema_=schema_, - title_=title_, - description_=description_, - include_in_schema=include_in_schema, - ) - return SpecificationDefaultPublisher( - key=key, - # basic args - topic=topic, - partition=partition, - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - schema_=schema_, - title_=title_, - description_=description_, - include_in_schema=include_in_schema, - ) - - -class SpecificationBatchPublisher( - BatchPublisher, - SpecificationPublisher[tuple["ConfluentMsg", ...]], -): - pass - - -class SpecificationDefaultPublisher( - DefaultPublisher, - SpecificationPublisher["ConfluentMsg"], -): - pass diff --git a/faststream/confluent/publisher/specified.py b/faststream/confluent/publisher/specified.py new file mode 100644 index 0000000000..fec0faf183 --- /dev/null +++ b/faststream/confluent/publisher/specified.py @@ -0,0 +1,58 @@ +from typing import ( + TYPE_CHECKING, +) + +from faststream._internal.types import MsgType +from faststream.confluent.publisher.usecase import ( + BatchPublisher, + DefaultPublisher, + LogicPublisher, +) +from faststream.specification.asyncapi.utils import resolve_payloads +from faststream.specification.schema.bindings import ChannelBinding, kafka +from faststream.specification.schema.channel import Channel +from faststream.specification.schema.message import CorrelationId, Message +from faststream.specification.schema.operation import Operation + +if TYPE_CHECKING: + from confluent_kafka import Message as ConfluentMsg + + +class SpecificationPublisher(LogicPublisher[MsgType]): + """A class representing a publisher.""" + + def get_name(self) -> str: + return f"{self.topic}:Publisher" + + def get_schema(self) -> dict[str, Channel]: + payloads = self.get_payloads() + + return { + self.name: Channel( + description=self.description, + publish=Operation( + message=Message( + title=f"{self.name}:Message", + payload=resolve_payloads(payloads, "Publisher"), + correlationId=CorrelationId( + location="$message.header#/correlation_id", + ), + ), + ), + bindings=ChannelBinding(kafka=kafka.ChannelBinding(topic=self.topic)), + ), + } + + +class SpecificationBatchPublisher( + BatchPublisher, + SpecificationPublisher[tuple["ConfluentMsg", ...]], +): + pass + + +class SpecificationDefaultPublisher( + DefaultPublisher, + SpecificationPublisher["ConfluentMsg"], +): + pass diff --git a/faststream/confluent/subscriber/factory.py b/faststream/confluent/subscriber/factory.py index 336e02c159..c1d4a2d444 100644 --- a/faststream/confluent/subscriber/factory.py +++ b/faststream/confluent/subscriber/factory.py @@ -7,7 +7,7 @@ overload, ) -from faststream.confluent.subscriber.subscriber import ( +from faststream.confluent.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) diff --git a/faststream/confluent/subscriber/subscriber.py b/faststream/confluent/subscriber/specified.py similarity index 100% rename from faststream/confluent/subscriber/subscriber.py rename to faststream/confluent/subscriber/specified.py diff --git a/faststream/confluent/testing.py b/faststream/confluent/testing.py index d9c5026298..c0a3cfa7bc 100644 --- a/faststream/confluent/testing.py +++ b/faststream/confluent/testing.py @@ -16,16 +16,16 @@ from faststream.confluent.broker import KafkaBroker from faststream.confluent.parser import AsyncConfluentParser from faststream.confluent.publisher.producer import AsyncConfluentFastProducer -from faststream.confluent.publisher.publisher import SpecificationBatchPublisher +from faststream.confluent.publisher.specified import SpecificationBatchPublisher from faststream.confluent.schemas import TopicPartition -from faststream.confluent.subscriber.subscriber import SpecificationBatchSubscriber +from faststream.confluent.subscriber.usecase import BatchSubscriber from faststream.exceptions import SubscriberNotFound from faststream.message import encode_message, gen_cor_id if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage from faststream._internal.setup.logger import LoggerState - from faststream.confluent.publisher.publisher import SpecificationPublisher + from faststream.confluent.publisher.specified import SpecificationPublisher from faststream.confluent.subscriber.usecase import LogicSubscriber __all__ = ("TestKafkaBroker",) @@ -133,9 +133,7 @@ async def publish( # type: ignore[override] partition, ): msg_to_send = ( - [incoming] - if isinstance(handler, SpecificationBatchSubscriber) - else incoming + [incoming] if isinstance(handler, BatchSubscriber) else incoming ) await self._execute_handler(msg_to_send, topic, handler) @@ -170,7 +168,7 @@ async def publish_batch( for message in msgs ) - if isinstance(handler, SpecificationBatchSubscriber): + if isinstance(handler, BatchSubscriber): await self._execute_handler(list(messages), topic, handler) else: @@ -206,9 +204,7 @@ async def request( # type: ignore[override] partition, ): msg_to_send = ( - [incoming] - if isinstance(handler, SpecificationBatchSubscriber) - else incoming + [incoming] if isinstance(handler, BatchSubscriber) else incoming ) with anyio.fail_after(timeout): diff --git a/faststream/kafka/broker/registrator.py b/faststream/kafka/broker/registrator.py index ce805e6066..8a7efeff2f 100644 --- a/faststream/kafka/broker/registrator.py +++ b/faststream/kafka/broker/registrator.py @@ -16,7 +16,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker -from faststream.kafka.publisher.publisher import SpecificationPublisher +from faststream.kafka.publisher.factory import create_publisher from faststream.kafka.subscriber.factory import create_subscriber if TYPE_CHECKING: @@ -31,11 +31,11 @@ SubscriberMiddleware, ) from faststream.kafka.message import KafkaMessage - from faststream.kafka.publisher.publisher import ( + from faststream.kafka.publisher.specified import ( SpecificationBatchPublisher, SpecificationDefaultPublisher, ) - from faststream.kafka.subscriber.subscriber import ( + from faststream.kafka.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) @@ -1911,7 +1911,7 @@ def publisher( Or you can create a publisher object to call it lately - `broker.publisher(...).publish(...)`. """ - publisher = SpecificationPublisher.create( + publisher = create_publisher( # batch flag batch=batch, # default args diff --git a/faststream/kafka/fastapi/__init__.py b/faststream/kafka/fastapi/__init__.py index e2a8447ef7..9fda6d07d3 100644 --- a/faststream/kafka/fastapi/__init__.py +++ b/faststream/kafka/fastapi/__init__.py @@ -2,10 +2,11 @@ from faststream._internal.fastapi.context import Context, ContextRepo, Logger from faststream.kafka.broker import KafkaBroker as KB -from faststream.kafka.fastapi.fastapi import KafkaRouter from faststream.kafka.message import KafkaMessage as KM from faststream.kafka.publisher.producer import AioKafkaFastProducer +from .fastapi import KafkaRouter + __all__ = ( "Context", "ContextRepo", diff --git a/faststream/kafka/fastapi/fastapi.py b/faststream/kafka/fastapi/fastapi.py index 0c805aa2dc..1b92a1029c 100644 --- a/faststream/kafka/fastapi/fastapi.py +++ b/faststream/kafka/fastapi/fastapi.py @@ -48,11 +48,11 @@ SubscriberMiddleware, ) from faststream.kafka.message import KafkaMessage - from faststream.kafka.publisher.publisher import ( + from faststream.kafka.publisher.specified import ( SpecificationBatchPublisher, SpecificationDefaultPublisher, ) - from faststream.kafka.subscriber.subscriber import ( + from faststream.kafka.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) diff --git a/faststream/kafka/publisher/factory.py b/faststream/kafka/publisher/factory.py new file mode 100644 index 0000000000..16c2b69e60 --- /dev/null +++ b/faststream/kafka/publisher/factory.py @@ -0,0 +1,138 @@ +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Optional, + Union, + overload, +) + +from faststream.exceptions import SetupError + +from .specified import SpecificationBatchPublisher, SpecificationDefaultPublisher + +if TYPE_CHECKING: + from aiokafka import ConsumerRecord + + from faststream._internal.types import BrokerMiddleware, PublisherMiddleware + + +@overload +def create_publisher( + *, + batch: Literal[True], + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable["BrokerMiddleware[tuple[ConsumerRecord, ...]]"], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> "SpecificationBatchPublisher": ... + + +@overload +def create_publisher( + *, + batch: Literal[False], + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable["BrokerMiddleware[ConsumerRecord]"], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> "SpecificationDefaultPublisher": ... + + +@overload +def create_publisher( + *, + batch: bool, + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable[ + "BrokerMiddleware[Union[tuple[ConsumerRecord, ...], ConsumerRecord]]" + ], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> Union[ + "SpecificationBatchPublisher", + "SpecificationDefaultPublisher", +]: ... + + +def create_publisher( + *, + batch: bool, + key: Optional[bytes], + topic: str, + partition: Optional[int], + headers: Optional[dict[str, str]], + reply_to: str, + # Publisher args + broker_middlewares: Iterable[ + "BrokerMiddleware[Union[tuple[ConsumerRecord, ...], ConsumerRecord]]" + ], + middlewares: Iterable["PublisherMiddleware"], + # Specification args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> Union[ + "SpecificationBatchPublisher", + "SpecificationDefaultPublisher", +]: + if batch: + if key: + msg = "You can't setup `key` with batch publisher" + raise SetupError(msg) + + return SpecificationBatchPublisher( + topic=topic, + partition=partition, + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + schema_=schema_, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) + return SpecificationDefaultPublisher( + key=key, + # basic args + topic=topic, + partition=partition, + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + schema_=schema_, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) diff --git a/faststream/kafka/publisher/publisher.py b/faststream/kafka/publisher/publisher.py deleted file mode 100644 index db575aac29..0000000000 --- a/faststream/kafka/publisher/publisher.py +++ /dev/null @@ -1,190 +0,0 @@ -from collections.abc import Iterable -from typing import ( - TYPE_CHECKING, - Any, - Literal, - Optional, - Union, - overload, -) - -from typing_extensions import override - -from faststream._internal.types import MsgType -from faststream.exceptions import SetupError -from faststream.kafka.publisher.usecase import ( - BatchPublisher, - DefaultPublisher, - LogicPublisher, -) -from faststream.specification.asyncapi.utils import resolve_payloads -from faststream.specification.schema.bindings import ChannelBinding, kafka -from faststream.specification.schema.channel import Channel -from faststream.specification.schema.message import CorrelationId, Message -from faststream.specification.schema.operation import Operation - -if TYPE_CHECKING: - from aiokafka import ConsumerRecord - - from faststream._internal.types import BrokerMiddleware, PublisherMiddleware - - -class SpecificationPublisher(LogicPublisher[MsgType]): - """A class representing a publisher.""" - - def get_name(self) -> str: - return f"{self.topic}:Publisher" - - def get_schema(self) -> dict[str, Channel]: - payloads = self.get_payloads() - - return { - self.name: Channel( - description=self.description, - publish=Operation( - message=Message( - title=f"{self.name}:Message", - payload=resolve_payloads(payloads, "Publisher"), - correlationId=CorrelationId( - location="$message.header#/correlation_id", - ), - ), - ), - bindings=ChannelBinding(kafka=kafka.ChannelBinding(topic=self.topic)), - ), - } - - @overload # type: ignore[override] - @staticmethod - def create( - *, - batch: Literal[True], - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable["BrokerMiddleware[tuple[ConsumerRecord, ...]]"], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> "SpecificationBatchPublisher": ... - - @overload - @staticmethod - def create( - *, - batch: Literal[False], - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable["BrokerMiddleware[ConsumerRecord]"], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> "SpecificationDefaultPublisher": ... - - @overload - @staticmethod - def create( - *, - batch: bool, - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable[ - "BrokerMiddleware[Union[tuple[ConsumerRecord, ...], ConsumerRecord]]" - ], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> Union[ - "SpecificationBatchPublisher", - "SpecificationDefaultPublisher", - ]: ... - - @override - @staticmethod - def create( - *, - batch: bool, - key: Optional[bytes], - topic: str, - partition: Optional[int], - headers: Optional[dict[str, str]], - reply_to: str, - # Publisher args - broker_middlewares: Iterable[ - "BrokerMiddleware[Union[tuple[ConsumerRecord, ...], ConsumerRecord]]" - ], - middlewares: Iterable["PublisherMiddleware"], - # Specification args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> Union[ - "SpecificationBatchPublisher", - "SpecificationDefaultPublisher", - ]: - if batch: - if key: - msg = "You can't setup `key` with batch publisher" - raise SetupError(msg) - - return SpecificationBatchPublisher( - topic=topic, - partition=partition, - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - schema_=schema_, - title_=title_, - description_=description_, - include_in_schema=include_in_schema, - ) - return SpecificationDefaultPublisher( - key=key, - # basic args - topic=topic, - partition=partition, - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - schema_=schema_, - title_=title_, - description_=description_, - include_in_schema=include_in_schema, - ) - - -class SpecificationBatchPublisher( - BatchPublisher, - SpecificationPublisher[tuple["ConsumerRecord", ...]], -): - pass - - -class SpecificationDefaultPublisher( - DefaultPublisher, - SpecificationPublisher["ConsumerRecord"], -): - pass diff --git a/faststream/kafka/publisher/specified.py b/faststream/kafka/publisher/specified.py new file mode 100644 index 0000000000..d765cc8f8b --- /dev/null +++ b/faststream/kafka/publisher/specified.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING + +from faststream._internal.types import MsgType +from faststream.kafka.publisher.usecase import ( + BatchPublisher, + DefaultPublisher, + LogicPublisher, +) +from faststream.specification.asyncapi.utils import resolve_payloads +from faststream.specification.schema.bindings import ChannelBinding, kafka +from faststream.specification.schema.channel import Channel +from faststream.specification.schema.message import CorrelationId, Message +from faststream.specification.schema.operation import Operation + +if TYPE_CHECKING: + from aiokafka import ConsumerRecord + + +class SpecificationPublisher(LogicPublisher[MsgType]): + """A class representing a publisher.""" + + def get_name(self) -> str: + return f"{self.topic}:Publisher" + + def get_schema(self) -> dict[str, Channel]: + payloads = self.get_payloads() + + return { + self.name: Channel( + description=self.description, + publish=Operation( + message=Message( + title=f"{self.name}:Message", + payload=resolve_payloads(payloads, "Publisher"), + correlationId=CorrelationId( + location="$message.header#/correlation_id", + ), + ), + ), + bindings=ChannelBinding(kafka=kafka.ChannelBinding(topic=self.topic)), + ), + } + + +class SpecificationBatchPublisher( + BatchPublisher, + SpecificationPublisher[tuple["ConsumerRecord", ...]], +): + pass + + +class SpecificationDefaultPublisher( + DefaultPublisher, + SpecificationPublisher["ConsumerRecord"], +): + pass diff --git a/faststream/kafka/subscriber/factory.py b/faststream/kafka/subscriber/factory.py index a31a5fde93..b4ae8638f1 100644 --- a/faststream/kafka/subscriber/factory.py +++ b/faststream/kafka/subscriber/factory.py @@ -8,7 +8,7 @@ ) from faststream.exceptions import SetupError -from faststream.kafka.subscriber.subscriber import ( +from faststream.kafka.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) diff --git a/faststream/kafka/subscriber/subscriber.py b/faststream/kafka/subscriber/specified.py similarity index 100% rename from faststream/kafka/subscriber/subscriber.py rename to faststream/kafka/subscriber/specified.py diff --git a/faststream/kafka/testing.py b/faststream/kafka/testing.py index a0135e9083..ce35bbd1b5 100755 --- a/faststream/kafka/testing.py +++ b/faststream/kafka/testing.py @@ -21,13 +21,13 @@ from faststream.kafka.message import KafkaMessage from faststream.kafka.parser import AioKafkaParser from faststream.kafka.publisher.producer import AioKafkaFastProducer -from faststream.kafka.publisher.publisher import SpecificationBatchPublisher -from faststream.kafka.subscriber.subscriber import SpecificationBatchSubscriber +from faststream.kafka.publisher.specified import SpecificationBatchPublisher +from faststream.kafka.subscriber.usecase import BatchSubscriber from faststream.message import encode_message, gen_cor_id if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage - from faststream.kafka.publisher.publisher import SpecificationPublisher + from faststream.kafka.publisher.specified import SpecificationPublisher from faststream.kafka.subscriber.usecase import LogicSubscriber __all__ = ("TestKafkaBroker",) @@ -128,9 +128,7 @@ async def publish( # type: ignore[override] partition, ): msg_to_send = ( - [incoming] - if isinstance(handler, SpecificationBatchSubscriber) - else incoming + [incoming] if isinstance(handler, BatchSubscriber) else incoming ) await self._execute_handler(msg_to_send, topic, handler) @@ -164,9 +162,7 @@ async def request( # type: ignore[override] partition, ): msg_to_send = ( - [incoming] - if isinstance(handler, SpecificationBatchSubscriber) - else incoming + [incoming] if isinstance(handler, BatchSubscriber) else incoming ) with anyio.fail_after(timeout): @@ -204,7 +200,7 @@ async def publish_batch( for message in msgs ) - if isinstance(handler, SpecificationBatchSubscriber): + if isinstance(handler, BatchSubscriber): await self._execute_handler(list(messages), topic, handler) else: diff --git a/faststream/nats/broker/broker.py b/faststream/nats/broker/broker.py index ee91cc291e..b9ef1afdfb 100644 --- a/faststream/nats/broker/broker.py +++ b/faststream/nats/broker/broker.py @@ -36,7 +36,7 @@ from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer from faststream.nats.publisher.producer import NatsFastProducer, NatsJSFastProducer from faststream.nats.security import parse_security -from faststream.nats.subscriber.subscriber import SpecificationSubscriber +from faststream.nats.subscriber.specified import SpecificationSubscriber from .logging import make_nats_logger_state from .registrator import NatsRegistrator @@ -71,7 +71,7 @@ CustomCallable, ) from faststream.nats.message import NatsMessage - from faststream.nats.publisher.publisher import SpecificationPublisher + from faststream.nats.publisher.specified import SpecificationPublisher from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict diff --git a/faststream/nats/broker/registrator.py b/faststream/nats/broker/registrator.py index 580501f2e8..811f1ab847 100644 --- a/faststream/nats/broker/registrator.py +++ b/faststream/nats/broker/registrator.py @@ -6,10 +6,11 @@ from faststream._internal.broker.abc_broker import ABCBroker from faststream.nats.helpers import StreamBuilder -from faststream.nats.publisher.publisher import SpecificationPublisher +from faststream.nats.publisher.factory import create_publisher +from faststream.nats.publisher.specified import SpecificationPublisher from faststream.nats.schemas import JStream, KvWatch, ObjWatch, PullSub from faststream.nats.subscriber.factory import create_subscriber -from faststream.nats.subscriber.subscriber import SpecificationSubscriber +from faststream.nats.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: from fast_depends.dependencies import Depends @@ -314,7 +315,7 @@ def publisher( # type: ignore[override] publisher = cast( SpecificationPublisher, super().publisher( - publisher=SpecificationPublisher.create( + publisher=create_publisher( subject=subject, headers=headers, # Core diff --git a/faststream/nats/fastapi/__init__.py b/faststream/nats/fastapi/__init__.py index 7351e313a2..18a28e4e8d 100644 --- a/faststream/nats/fastapi/__init__.py +++ b/faststream/nats/fastapi/__init__.py @@ -5,10 +5,11 @@ from faststream._internal.fastapi.context import Context, ContextRepo, Logger from faststream.nats.broker import NatsBroker as NB -from faststream.nats.fastapi.fastapi import NatsRouter from faststream.nats.message import NatsMessage as NM from faststream.nats.publisher.producer import NatsFastProducer, NatsJSFastProducer +from .fastapi import NatsRouter + NatsMessage = Annotated[NM, Context("message")] NatsBroker = Annotated[NB, Context("broker")] Client = Annotated[NatsClient, Context("broker._connection")] diff --git a/faststream/nats/fastapi/fastapi.py b/faststream/nats/fastapi/fastapi.py index ad910b71b6..e2c5ae3d98 100644 --- a/faststream/nats/fastapi/fastapi.py +++ b/faststream/nats/fastapi/fastapi.py @@ -33,8 +33,7 @@ from faststream._internal.constants import EMPTY from faststream._internal.fastapi.router import StreamRouter from faststream.nats.broker import NatsBroker -from faststream.nats.publisher.publisher import SpecificationPublisher -from faststream.nats.subscriber.subscriber import SpecificationSubscriber +from faststream.nats.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: import ssl @@ -61,6 +60,7 @@ SubscriberMiddleware, ) from faststream.nats.message import NatsMessage + from faststream.nats.publisher.specified import SpecificationPublisher from faststream.nats.schemas import JStream, KvWatch, ObjWatch, PullSub from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict @@ -945,7 +945,7 @@ def publisher( # type: ignore[override] bool, Doc("Whetever to include operation in AsyncAPI schema or not."), ] = True, - ) -> SpecificationPublisher: + ) -> "SpecificationPublisher": return self.broker.publisher( subject, headers=headers, diff --git a/faststream/nats/publisher/factory.py b/faststream/nats/publisher/factory.py new file mode 100644 index 0000000000..8f03f94b9d --- /dev/null +++ b/faststream/nats/publisher/factory.py @@ -0,0 +1,43 @@ +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Optional + +from .specified import SpecificationPublisher + +if TYPE_CHECKING: + from nats.aio.msg import Msg + + from faststream._internal.types import BrokerMiddleware, PublisherMiddleware + from faststream.nats.schemas.js_stream import JStream + + +def create_publisher( + *, + subject: str, + reply_to: str, + headers: Optional[dict[str, str]], + stream: Optional["JStream"], + timeout: Optional[float], + # Publisher args + broker_middlewares: Iterable["BrokerMiddleware[Msg]"], + middlewares: Iterable["PublisherMiddleware"], + # AsyncAPI args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> SpecificationPublisher: + return SpecificationPublisher( + subject=subject, + reply_to=reply_to, + headers=headers, + stream=stream, + timeout=timeout, + # Publisher args + broker_middlewares=broker_middlewares, + middlewares=middlewares, + # AsyncAPI args + schema_=schema_, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) diff --git a/faststream/nats/publisher/publisher.py b/faststream/nats/publisher/publisher.py deleted file mode 100644 index f80cd8dccb..0000000000 --- a/faststream/nats/publisher/publisher.py +++ /dev/null @@ -1,82 +0,0 @@ -from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Optional - -from typing_extensions import override - -from faststream.nats.publisher.usecase import LogicPublisher -from faststream.specification.asyncapi.utils import resolve_payloads -from faststream.specification.schema.bindings import ChannelBinding, nats -from faststream.specification.schema.channel import Channel -from faststream.specification.schema.message import CorrelationId, Message -from faststream.specification.schema.operation import Operation - -if TYPE_CHECKING: - from nats.aio.msg import Msg - - from faststream._internal.types import BrokerMiddleware, PublisherMiddleware - from faststream.nats.schemas.js_stream import JStream - - -class SpecificationPublisher(LogicPublisher): - """A class to represent a NATS publisher.""" - - def get_name(self) -> str: - return f"{self.subject}:Publisher" - - def get_schema(self) -> dict[str, Channel]: - payloads = self.get_payloads() - - return { - self.name: Channel( - description=self.description, - publish=Operation( - message=Message( - title=f"{self.name}:Message", - payload=resolve_payloads(payloads, "Publisher"), - correlationId=CorrelationId( - location="$message.header#/correlation_id", - ), - ), - ), - bindings=ChannelBinding( - nats=nats.ChannelBinding( - subject=self.subject, - ), - ), - ), - } - - @override - @classmethod - def create( # type: ignore[override] - cls, - *, - subject: str, - reply_to: str, - headers: Optional[dict[str, str]], - stream: Optional["JStream"], - timeout: Optional[float], - # Publisher args - broker_middlewares: Iterable["BrokerMiddleware[Msg]"], - middlewares: Iterable["PublisherMiddleware"], - # AsyncAPI args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> "SpecificationPublisher": - return cls( - subject=subject, - reply_to=reply_to, - headers=headers, - stream=stream, - timeout=timeout, - # Publisher args - broker_middlewares=broker_middlewares, - middlewares=middlewares, - # AsyncAPI args - schema_=schema_, - title_=title_, - description_=description_, - include_in_schema=include_in_schema, - ) diff --git a/faststream/nats/publisher/specified.py b/faststream/nats/publisher/specified.py new file mode 100644 index 0000000000..41cfdc27b9 --- /dev/null +++ b/faststream/nats/publisher/specified.py @@ -0,0 +1,36 @@ +from faststream.nats.publisher.usecase import LogicPublisher +from faststream.specification.asyncapi.utils import resolve_payloads +from faststream.specification.schema.bindings import ChannelBinding, nats +from faststream.specification.schema.channel import Channel +from faststream.specification.schema.message import CorrelationId, Message +from faststream.specification.schema.operation import Operation + + +class SpecificationPublisher(LogicPublisher): + """A class to represent a NATS publisher.""" + + def get_name(self) -> str: + return f"{self.subject}:Publisher" + + def get_schema(self) -> dict[str, Channel]: + payloads = self.get_payloads() + + return { + self.name: Channel( + description=self.description, + publish=Operation( + message=Message( + title=f"{self.name}:Message", + payload=resolve_payloads(payloads, "Publisher"), + correlationId=CorrelationId( + location="$message.header#/correlation_id", + ), + ), + ), + bindings=ChannelBinding( + nats=nats.ChannelBinding( + subject=self.subject, + ), + ), + ), + } diff --git a/faststream/nats/subscriber/subscription.py b/faststream/nats/subscriber/adapters.py similarity index 100% rename from faststream/nats/subscriber/subscription.py rename to faststream/nats/subscriber/adapters.py diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index b3170988b8..f2853f06b9 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -12,7 +12,7 @@ ) from faststream.exceptions import SetupError -from faststream.nats.subscriber.subscriber import ( +from faststream.nats.subscriber.specified import ( SpecificationBatchPullStreamSubscriber, SpecificationConcurrentCoreSubscriber, SpecificationConcurrentPullStreamSubscriber, diff --git a/faststream/nats/subscriber/subscriber.py b/faststream/nats/subscriber/specified.py similarity index 100% rename from faststream/nats/subscriber/subscriber.py rename to faststream/nats/subscriber/specified.py diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index 457db20587..be75b89776 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -34,7 +34,7 @@ ObjParser, ) from faststream.nats.schemas.js_stream import compile_nats_wildcard -from faststream.nats.subscriber.subscription import ( +from faststream.nats.subscriber.adapters import ( UnsubscribeAdapter, Unsubscriptable, ) diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index 4df82ae989..7765df0181 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage - from faststream.nats.publisher.publisher import SpecificationPublisher + from faststream.nats.publisher.specified import SpecificationPublisher from faststream.nats.subscriber.usecase import LogicSubscriber __all__ = ("TestNatsBroker",) diff --git a/faststream/rabbit/broker/registrator.py b/faststream/rabbit/broker/registrator.py index 32aedfcfec..5b6ffbb606 100644 --- a/faststream/rabbit/broker/registrator.py +++ b/faststream/rabbit/broker/registrator.py @@ -4,14 +4,15 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker -from faststream.rabbit.publisher.publisher import SpecificationPublisher +from faststream.rabbit.publisher.factory import create_publisher +from faststream.rabbit.publisher.specified import SpecificationPublisher from faststream.rabbit.publisher.usecase import PublishKwargs from faststream.rabbit.schemas import ( RabbitExchange, RabbitQueue, ) from faststream.rabbit.subscriber.factory import create_subscriber -from faststream.rabbit.subscriber.subscriber import SpecificationSubscriber +from faststream.rabbit.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: from aio_pika import IncomingMessage # noqa: F401 @@ -240,7 +241,7 @@ def publisher( # type: ignore[override] Optional[str], Doc("Publisher connection User ID, validated if set."), ] = None, - ) -> SpecificationPublisher: + ) -> "SpecificationPublisher": """Creates long-living and AsyncAPI-documented publisher object. You can use it as a handler decorator (handler should be decorated by `@broker.subscriber(...)` too) - `@broker.publisher(...)`. @@ -266,7 +267,7 @@ def publisher( # type: ignore[override] return cast( SpecificationPublisher, super().publisher( - SpecificationPublisher.create( + create_publisher( routing_key=routing_key, queue=RabbitQueue.validate(queue), exchange=RabbitExchange.validate(exchange), diff --git a/faststream/rabbit/fastapi/__init__.py b/faststream/rabbit/fastapi/__init__.py index da296cd366..cb7c7c26d4 100644 --- a/faststream/rabbit/fastapi/__init__.py +++ b/faststream/rabbit/fastapi/__init__.py @@ -2,10 +2,11 @@ from faststream._internal.fastapi.context import Context, ContextRepo, Logger from faststream.rabbit.broker import RabbitBroker as RB -from faststream.rabbit.fastapi.router import RabbitRouter from faststream.rabbit.message import RabbitMessage as RM from faststream.rabbit.publisher.producer import AioPikaFastProducer +from .fastapi import RabbitRouter + RabbitMessage = Annotated[RM, Context("message")] RabbitBroker = Annotated[RB, Context("broker")] RabbitProducer = Annotated[AioPikaFastProducer, Context("broker._producer")] diff --git a/faststream/rabbit/fastapi/router.py b/faststream/rabbit/fastapi/fastapi.py similarity index 99% rename from faststream/rabbit/fastapi/router.py rename to faststream/rabbit/fastapi/fastapi.py index 69a7a23d73..9240380079 100644 --- a/faststream/rabbit/fastapi/router.py +++ b/faststream/rabbit/fastapi/fastapi.py @@ -21,12 +21,11 @@ from faststream._internal.constants import EMPTY from faststream._internal.fastapi.router import StreamRouter from faststream.rabbit.broker.broker import RabbitBroker as RB -from faststream.rabbit.publisher.publisher import SpecificationPublisher from faststream.rabbit.schemas import ( RabbitExchange, RabbitQueue, ) -from faststream.rabbit.subscriber.subscriber import SpecificationSubscriber +from faststream.rabbit.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: from enum import Enum @@ -48,6 +47,7 @@ SubscriberMiddleware, ) from faststream.rabbit.message import RabbitMessage + from faststream.rabbit.publisher.specified import SpecificationPublisher from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict @@ -800,7 +800,7 @@ def publisher( Optional[str], Doc("Publisher connection User ID, validated if set."), ] = None, - ) -> SpecificationPublisher: + ) -> "SpecificationPublisher": return self.broker.publisher( queue=queue, exchange=exchange, diff --git a/faststream/rabbit/publisher/factory.py b/faststream/rabbit/publisher/factory.py new file mode 100644 index 0000000000..cf2bc27c86 --- /dev/null +++ b/faststream/rabbit/publisher/factory.py @@ -0,0 +1,43 @@ +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Optional + +from .specified import SpecificationPublisher + +if TYPE_CHECKING: + from aio_pika import IncomingMessage + + from faststream._internal.types import BrokerMiddleware, PublisherMiddleware + from faststream.rabbit.schemas import RabbitExchange, RabbitQueue + + from .usecase import PublishKwargs + + +def create_publisher( + *, + routing_key: str, + queue: "RabbitQueue", + exchange: "RabbitExchange", + message_kwargs: "PublishKwargs", + # Publisher args + broker_middlewares: Iterable["BrokerMiddleware[IncomingMessage]"], + middlewares: Iterable["PublisherMiddleware"], + # AsyncAPI args + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, +) -> SpecificationPublisher: + return SpecificationPublisher( + routing_key=routing_key, + queue=queue, + exchange=exchange, + message_kwargs=message_kwargs, + # Publisher args + broker_middlewares=broker_middlewares, + middlewares=middlewares, + # AsyncAPI args + schema_=schema_, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) diff --git a/faststream/rabbit/publisher/publisher.py b/faststream/rabbit/publisher/specified.py similarity index 72% rename from faststream/rabbit/publisher/publisher.py rename to faststream/rabbit/publisher/specified.py index 18796b92b4..fce0411752 100644 --- a/faststream/rabbit/publisher/publisher.py +++ b/faststream/rabbit/publisher/specified.py @@ -1,9 +1,3 @@ -from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Optional - -from typing_extensions import override - -from faststream.rabbit.publisher.usecase import LogicPublisher, PublishKwargs from faststream.rabbit.utils import is_routing_exchange from faststream.specification.asyncapi.utils import resolve_payloads from faststream.specification.schema.bindings import ( @@ -15,11 +9,7 @@ from faststream.specification.schema.message import CorrelationId, Message from faststream.specification.schema.operation import Operation -if TYPE_CHECKING: - from aio_pika import IncomingMessage - - from faststream._internal.types import BrokerMiddleware, PublisherMiddleware - from faststream.rabbit.schemas import RabbitExchange, RabbitQueue +from .usecase import LogicPublisher class SpecificationPublisher(LogicPublisher): @@ -104,36 +94,3 @@ def get_schema(self) -> dict[str, Channel]: ), ), } - - @override - @classmethod - def create( # type: ignore[override] - cls, - *, - routing_key: str, - queue: "RabbitQueue", - exchange: "RabbitExchange", - message_kwargs: "PublishKwargs", - # Publisher args - broker_middlewares: Iterable["BrokerMiddleware[IncomingMessage]"], - middlewares: Iterable["PublisherMiddleware"], - # AsyncAPI args - schema_: Optional[Any], - title_: Optional[str], - description_: Optional[str], - include_in_schema: bool, - ) -> "SpecificationPublisher": - return cls( - routing_key=routing_key, - queue=queue, - exchange=exchange, - message_kwargs=message_kwargs, - # Publisher args - broker_middlewares=broker_middlewares, - middlewares=middlewares, - # AsyncAPI args - schema_=schema_, - title_=title_, - description_=description_, - include_in_schema=include_in_schema, - ) diff --git a/faststream/rabbit/schemas/proto.py b/faststream/rabbit/schemas/proto.py index 226840925e..ca00168745 100644 --- a/faststream/rabbit/schemas/proto.py +++ b/faststream/rabbit/schemas/proto.py @@ -5,7 +5,7 @@ class BaseRMQInformation(Protocol): - """Base class to store AsyncAPI RMQ bindings.""" + """Base class to store Specification RMQ bindings.""" virtual_host: str queue: RabbitQueue diff --git a/faststream/rabbit/subscriber/factory.py b/faststream/rabbit/subscriber/factory.py index c69884503a..23b190b13e 100644 --- a/faststream/rabbit/subscriber/factory.py +++ b/faststream/rabbit/subscriber/factory.py @@ -1,7 +1,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, Optional, Union -from faststream.rabbit.subscriber.subscriber import SpecificationSubscriber +from faststream.rabbit.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: from aio_pika import IncomingMessage diff --git a/faststream/rabbit/subscriber/subscriber.py b/faststream/rabbit/subscriber/specified.py similarity index 100% rename from faststream/rabbit/subscriber/subscriber.py rename to faststream/rabbit/subscriber/specified.py diff --git a/faststream/rabbit/testing.py b/faststream/rabbit/testing.py index 5831fc903c..7d45ed786b 100644 --- a/faststream/rabbit/testing.py +++ b/faststream/rabbit/testing.py @@ -18,7 +18,6 @@ from faststream.rabbit.broker.broker import RabbitBroker from faststream.rabbit.parser import AioPikaParser from faststream.rabbit.publisher.producer import AioPikaFastProducer -from faststream.rabbit.publisher.publisher import SpecificationPublisher from faststream.rabbit.schemas import ( ExchangeType, RabbitExchange, @@ -28,6 +27,7 @@ if TYPE_CHECKING: from aio_pika.abc import DateType, HeadersType, TimeoutType + from faststream.rabbit.publisher.specified import SpecificationPublisher from faststream.rabbit.subscriber.usecase import LogicSubscriber from faststream.rabbit.types import AioPikaSendableMessage @@ -39,7 +39,7 @@ class TestRabbitBroker(TestBroker[RabbitBroker]): """A class to test RabbitMQ brokers.""" @contextmanager - def _patch_broker(self, broker: RabbitBroker) -> Generator[None, None, None]: + def _patch_broker(self, broker: "RabbitBroker") -> Generator[None, None, None]: with ( mock.patch.object( broker, @@ -56,13 +56,13 @@ def _patch_broker(self, broker: RabbitBroker) -> Generator[None, None, None]: yield @staticmethod - async def _fake_connect(broker: RabbitBroker, *args: Any, **kwargs: Any) -> None: + async def _fake_connect(broker: "RabbitBroker", *args: Any, **kwargs: Any) -> None: broker._producer = FakeProducer(broker) @staticmethod def create_publisher_fake_subscriber( - broker: RabbitBroker, - publisher: SpecificationPublisher, + broker: "RabbitBroker", + publisher: "SpecificationPublisher", ) -> tuple["LogicSubscriber", bool]: sub: Optional[LogicSubscriber] = None for handler in broker._subscribers: diff --git a/faststream/redis/broker/registrator.py b/faststream/redis/broker/registrator.py index da6759f03a..9f852284d3 100644 --- a/faststream/redis/broker/registrator.py +++ b/faststream/redis/broker/registrator.py @@ -5,9 +5,10 @@ from faststream._internal.broker.abc_broker import ABCBroker from faststream.redis.message import UnifyRedisDict -from faststream.redis.publisher.publisher import SpecificationPublisher +from faststream.redis.publisher.factory import create_publisher +from faststream.redis.publisher.specified import SpecificationPublisher from faststream.redis.subscriber.factory import SubsciberType, create_subscriber -from faststream.redis.subscriber.subscriber import SpecificationSubscriber +from faststream.redis.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: from fast_depends.dependencies import Depends @@ -19,7 +20,7 @@ SubscriberMiddleware, ) from faststream.redis.message import UnifyRedisMessage - from faststream.redis.publisher.publisher import PublisherType + from faststream.redis.publisher.specified import PublisherType from faststream.redis.schemas import ListSub, PubSub, StreamSub @@ -174,7 +175,7 @@ def publisher( # type: ignore[override] bool, Doc("Whetever to include operation in AsyncAPI schema or not."), ] = True, - ) -> SpecificationPublisher: + ) -> "SpecificationPublisher": """Creates long-living and AsyncAPI-documented publisher object. You can use it as a handler decorator (handler should be decorated by `@broker.subscriber(...)` too) - `@broker.publisher(...)`. @@ -185,7 +186,7 @@ def publisher( # type: ignore[override] return cast( SpecificationPublisher, super().publisher( - SpecificationPublisher.create( + create_publisher( channel=channel, list=list, stream=stream, diff --git a/faststream/redis/fastapi/__init__.py b/faststream/redis/fastapi/__init__.py index da6dfd1c85..117c03aae2 100644 --- a/faststream/redis/fastapi/__init__.py +++ b/faststream/redis/fastapi/__init__.py @@ -4,9 +4,10 @@ from faststream._internal.fastapi.context import Context, ContextRepo, Logger from faststream.redis.broker.broker import RedisBroker as RB -from faststream.redis.fastapi.fastapi import RedisRouter from faststream.redis.message import BaseMessage as RM # noqa: N814 +from .fastapi import RedisRouter + __all__ = ( "Context", "ContextRepo", diff --git a/faststream/redis/fastapi/fastapi.py b/faststream/redis/fastapi/fastapi.py index 0a1a93e441..ed97300b87 100644 --- a/faststream/redis/fastapi/fastapi.py +++ b/faststream/redis/fastapi/fastapi.py @@ -27,9 +27,8 @@ from faststream._internal.fastapi.router import StreamRouter from faststream.redis.broker.broker import RedisBroker as RB from faststream.redis.message import UnifyRedisDict -from faststream.redis.publisher.publisher import SpecificationPublisher from faststream.redis.schemas import ListSub, PubSub, StreamSub -from faststream.redis.subscriber.subscriber import SpecificationSubscriber +from faststream.redis.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: from enum import Enum @@ -48,6 +47,7 @@ SubscriberMiddleware, ) from faststream.redis.message import UnifyRedisMessage + from faststream.redis.publisher.specified import SpecificationPublisher from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict @@ -692,7 +692,7 @@ def publisher( bool, Doc("Whetever to include operation in AsyncAPI schema or not."), ] = True, - ) -> SpecificationPublisher: + ) -> "SpecificationPublisher": return self.broker.publisher( channel, list=list, diff --git a/faststream/redis/publisher/factory.py b/faststream/redis/publisher/factory.py new file mode 100644 index 0000000000..2174a0b4c8 --- /dev/null +++ b/faststream/redis/publisher/factory.py @@ -0,0 +1,107 @@ +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Optional, Union + +from typing_extensions import TypeAlias + +from faststream.exceptions import SetupError +from faststream.redis.schemas import INCORRECT_SETUP_MSG, ListSub, PubSub, StreamSub +from faststream.redis.schemas.proto import validate_options + +from .specified import ( + SpecificationChannelPublisher, + SpecificationListBatchPublisher, + SpecificationListPublisher, + SpecificationStreamPublisher, +) + +if TYPE_CHECKING: + from faststream._internal.basic_types import AnyDict + from faststream._internal.types import BrokerMiddleware, PublisherMiddleware + from faststream.redis.message import UnifyRedisDict + + +PublisherType: TypeAlias = Union[ + "SpecificationChannelPublisher", + "SpecificationStreamPublisher", + "SpecificationListPublisher", + "SpecificationListBatchPublisher", +] + + +def create_publisher( + *, + channel: Union["PubSub", str, None], + list: Union["ListSub", str, None], + stream: Union["StreamSub", str, None], + headers: Optional["AnyDict"], + reply_to: str, + broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], + middlewares: Iterable["PublisherMiddleware"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + schema_: Optional[Any], + include_in_schema: bool, +) -> PublisherType: + validate_options(channel=channel, list=list, stream=stream) + + if (channel := PubSub.validate(channel)) is not None: + return SpecificationChannelPublisher( + channel=channel, + # basic args + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + # AsyncAPI args + title_=title_, + description_=description_, + schema_=schema_, + include_in_schema=include_in_schema, + ) + + if (stream := StreamSub.validate(stream)) is not None: + return SpecificationStreamPublisher( + stream=stream, + # basic args + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + # AsyncAPI args + title_=title_, + description_=description_, + schema_=schema_, + include_in_schema=include_in_schema, + ) + + if (list := ListSub.validate(list)) is not None: + if list.batch: + return SpecificationListBatchPublisher( + list=list, + # basic args + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + # AsyncAPI args + title_=title_, + description_=description_, + schema_=schema_, + include_in_schema=include_in_schema, + ) + return SpecificationListPublisher( + list=list, + # basic args + headers=headers, + reply_to=reply_to, + broker_middlewares=broker_middlewares, + middlewares=middlewares, + # AsyncAPI args + title_=title_, + description_=description_, + schema_=schema_, + include_in_schema=include_in_schema, + ) + + raise SetupError(INCORRECT_SETUP_MSG) diff --git a/faststream/redis/publisher/publisher.py b/faststream/redis/publisher/publisher.py deleted file mode 100644 index 11b7e5f4c4..0000000000 --- a/faststream/redis/publisher/publisher.py +++ /dev/null @@ -1,183 +0,0 @@ -from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Optional, Union - -from typing_extensions import TypeAlias, override - -from faststream.exceptions import SetupError -from faststream.redis.publisher.usecase import ( - ChannelPublisher, - ListBatchPublisher, - ListPublisher, - LogicPublisher, - StreamPublisher, -) -from faststream.redis.schemas import INCORRECT_SETUP_MSG, ListSub, PubSub, StreamSub -from faststream.redis.schemas.proto import RedisAsyncAPIProtocol, validate_options -from faststream.specification.asyncapi.utils import resolve_payloads -from faststream.specification.schema.bindings import ChannelBinding, redis -from faststream.specification.schema.channel import Channel -from faststream.specification.schema.message import CorrelationId, Message -from faststream.specification.schema.operation import Operation - -if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict - from faststream._internal.types import BrokerMiddleware, PublisherMiddleware - from faststream.redis.message import UnifyRedisDict - -PublisherType: TypeAlias = Union[ - "AsyncAPIChannelPublisher", - "AsyncAPIStreamPublisher", - "AsyncAPIListPublisher", - "AsyncAPIListBatchPublisher", -] - - -class SpecificationPublisher(LogicPublisher, RedisAsyncAPIProtocol): - """A class to represent a Redis publisher.""" - - def get_schema(self) -> dict[str, Channel]: - payloads = self.get_payloads() - - return { - self.name: Channel( - description=self.description, - publish=Operation( - message=Message( - title=f"{self.name}:Message", - payload=resolve_payloads(payloads, "Publisher"), - correlationId=CorrelationId( - location="$message.header#/correlation_id", - ), - ), - ), - bindings=ChannelBinding( - redis=self.channel_binding, - ), - ), - } - - @override - @staticmethod - def create( # type: ignore[override] - *, - channel: Union["PubSub", str, None], - list: Union["ListSub", str, None], - stream: Union["StreamSub", str, None], - headers: Optional["AnyDict"], - reply_to: str, - broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], - middlewares: Iterable["PublisherMiddleware"], - # AsyncAPI args - title_: Optional[str], - description_: Optional[str], - schema_: Optional[Any], - include_in_schema: bool, - ) -> PublisherType: - validate_options(channel=channel, list=list, stream=stream) - - if (channel := PubSub.validate(channel)) is not None: - return AsyncAPIChannelPublisher( - channel=channel, - # basic args - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - # AsyncAPI args - title_=title_, - description_=description_, - schema_=schema_, - include_in_schema=include_in_schema, - ) - - if (stream := StreamSub.validate(stream)) is not None: - return AsyncAPIStreamPublisher( - stream=stream, - # basic args - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - # AsyncAPI args - title_=title_, - description_=description_, - schema_=schema_, - include_in_schema=include_in_schema, - ) - - if (list := ListSub.validate(list)) is not None: - if list.batch: - return AsyncAPIListBatchPublisher( - list=list, - # basic args - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - # AsyncAPI args - title_=title_, - description_=description_, - schema_=schema_, - include_in_schema=include_in_schema, - ) - return AsyncAPIListPublisher( - list=list, - # basic args - headers=headers, - reply_to=reply_to, - broker_middlewares=broker_middlewares, - middlewares=middlewares, - # AsyncAPI args - title_=title_, - description_=description_, - schema_=schema_, - include_in_schema=include_in_schema, - ) - - raise SetupError(INCORRECT_SETUP_MSG) - - -class AsyncAPIChannelPublisher(ChannelPublisher, SpecificationPublisher): - def get_name(self) -> str: - return f"{self.channel.name}:Publisher" - - @property - def channel_binding(self) -> "redis.ChannelBinding": - return redis.ChannelBinding( - channel=self.channel.name, - method="publish", - ) - - -class _ListPublisherMixin(SpecificationPublisher): - list: "ListSub" - - def get_name(self) -> str: - return f"{self.list.name}:Publisher" - - @property - def channel_binding(self) -> "redis.ChannelBinding": - return redis.ChannelBinding( - channel=self.list.name, - method="rpush", - ) - - -class AsyncAPIListPublisher(ListPublisher, _ListPublisherMixin): - pass - - -class AsyncAPIListBatchPublisher(ListBatchPublisher, _ListPublisherMixin): - pass - - -class AsyncAPIStreamPublisher(StreamPublisher, SpecificationPublisher): - def get_name(self) -> str: - return f"{self.stream.name}:Publisher" - - @property - def channel_binding(self) -> "redis.ChannelBinding": - return redis.ChannelBinding( - channel=self.stream.name, - method="xadd", - ) diff --git a/faststream/redis/publisher/specified.py b/faststream/redis/publisher/specified.py new file mode 100644 index 0000000000..f0598834c6 --- /dev/null +++ b/faststream/redis/publisher/specified.py @@ -0,0 +1,89 @@ +from typing import TYPE_CHECKING + +from faststream.redis.publisher.usecase import ( + ChannelPublisher, + ListBatchPublisher, + ListPublisher, + LogicPublisher, + StreamPublisher, +) +from faststream.redis.schemas.proto import RedisSpecificationProtocol +from faststream.specification.asyncapi.utils import resolve_payloads +from faststream.specification.schema.bindings import ChannelBinding, redis +from faststream.specification.schema.channel import Channel +from faststream.specification.schema.message import CorrelationId, Message +from faststream.specification.schema.operation import Operation + +if TYPE_CHECKING: + from faststream.redis.schemas import ListSub + + +class SpecificationPublisher(LogicPublisher, RedisSpecificationProtocol): + """A class to represent a Redis publisher.""" + + def get_schema(self) -> dict[str, Channel]: + payloads = self.get_payloads() + + return { + self.name: Channel( + description=self.description, + publish=Operation( + message=Message( + title=f"{self.name}:Message", + payload=resolve_payloads(payloads, "Publisher"), + correlationId=CorrelationId( + location="$message.header#/correlation_id", + ), + ), + ), + bindings=ChannelBinding( + redis=self.channel_binding, + ), + ), + } + + +class SpecificationChannelPublisher(ChannelPublisher, SpecificationPublisher): + def get_name(self) -> str: + return f"{self.channel.name}:Publisher" + + @property + def channel_binding(self) -> "redis.ChannelBinding": + return redis.ChannelBinding( + channel=self.channel.name, + method="publish", + ) + + +class _ListPublisherMixin(SpecificationPublisher): + list: "ListSub" + + def get_name(self) -> str: + return f"{self.list.name}:Publisher" + + @property + def channel_binding(self) -> "redis.ChannelBinding": + return redis.ChannelBinding( + channel=self.list.name, + method="rpush", + ) + + +class SpecificationListPublisher(ListPublisher, _ListPublisherMixin): + pass + + +class SpecificationListBatchPublisher(ListBatchPublisher, _ListPublisherMixin): + pass + + +class SpecificationStreamPublisher(StreamPublisher, SpecificationPublisher): + def get_name(self) -> str: + return f"{self.stream.name}:Publisher" + + @property + def channel_binding(self) -> "redis.ChannelBinding": + return redis.ChannelBinding( + channel=self.stream.name, + method="xadd", + ) diff --git a/faststream/redis/schemas/proto.py b/faststream/redis/schemas/proto.py index 0e1d929211..1b4a5526f6 100644 --- a/faststream/redis/schemas/proto.py +++ b/faststream/redis/schemas/proto.py @@ -9,7 +9,7 @@ from faststream.specification.schema.bindings import redis -class RedisAsyncAPIProtocol(EndpointProto): +class RedisSpecificationProtocol(EndpointProto): @property @abstractmethod def channel_binding(self) -> "redis.ChannelBinding": ... diff --git a/faststream/redis/subscriber/factory.py b/faststream/redis/subscriber/factory.py index 8cd414f278..248ce141cf 100644 --- a/faststream/redis/subscriber/factory.py +++ b/faststream/redis/subscriber/factory.py @@ -6,7 +6,7 @@ from faststream.exceptions import SetupError from faststream.redis.schemas import INCORRECT_SETUP_MSG, ListSub, PubSub, StreamSub from faststream.redis.schemas.proto import validate_options -from faststream.redis.subscriber.subscriber import ( +from faststream.redis.subscriber.specified import ( AsyncAPIChannelSubscriber, AsyncAPIListBatchSubscriber, AsyncAPIListSubscriber, diff --git a/faststream/redis/subscriber/subscriber.py b/faststream/redis/subscriber/specified.py similarity index 95% rename from faststream/redis/subscriber/subscriber.py rename to faststream/redis/subscriber/specified.py index eba6c89f40..3c62aa3168 100644 --- a/faststream/redis/subscriber/subscriber.py +++ b/faststream/redis/subscriber/specified.py @@ -1,5 +1,5 @@ from faststream.redis.schemas import ListSub, StreamSub -from faststream.redis.schemas.proto import RedisAsyncAPIProtocol +from faststream.redis.schemas.proto import RedisSpecificationProtocol from faststream.redis.subscriber.usecase import ( BatchListSubscriber, BatchStreamSubscriber, @@ -15,7 +15,7 @@ from faststream.specification.schema.operation import Operation -class SpecificationSubscriber(LogicSubscriber, RedisAsyncAPIProtocol): +class SpecificationSubscriber(LogicSubscriber, RedisSpecificationProtocol): """A class to represent a Redis handler.""" def get_schema(self) -> dict[str, Channel]: diff --git a/faststream/redis/testing.py b/faststream/redis/testing.py index 8f065bf5a7..48205dbe08 100644 --- a/faststream/redis/testing.py +++ b/faststream/redis/testing.py @@ -38,7 +38,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, SendableMessage - from faststream.redis.publisher.publisher import SpecificationPublisher + from faststream.redis.publisher.specified import SpecificationPublisher __all__ = ("TestRedisBroker",) From 9a6461d80ae7eae192514968d92423a585d4fa25 Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Fri, 18 Oct 2024 17:33:24 +0300 Subject: [PATCH 10/48] refactor: split publish to private --- faststream/_internal/publisher/fake.py | 13 ++- faststream/_internal/publisher/proto.py | 19 +++- faststream/_internal/subscriber/usecase.py | 2 +- faststream/confluent/publisher/usecase.py | 53 +++++++++++ faststream/kafka/publisher/usecase.py | 63 +++++++++++-- faststream/nats/publisher/usecase.py | 29 +++++- faststream/rabbit/publisher/usecase.py | 32 ++++++- faststream/redis/publisher/usecase.py | 102 +++++++++++++++++---- 8 files changed, 278 insertions(+), 35 deletions(-) diff --git a/faststream/_internal/publisher/fake.py b/faststream/_internal/publisher/fake.py index 3fb3b1e074..2c5a4eaa7e 100644 --- a/faststream/_internal/publisher/fake.py +++ b/faststream/_internal/publisher/fake.py @@ -3,10 +3,11 @@ from itertools import chain from typing import TYPE_CHECKING, Any, Optional +from faststream._internal.basic_types import SendableMessage from faststream._internal.publisher.proto import BasePublisherProto if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict, AsyncFunc, SendableMessage + from faststream._internal.basic_types import AnyDict, AsyncFunc from faststream._internal.types import PublisherMiddleware @@ -26,6 +27,16 @@ def __init__( self.middlewares = middlewares async def publish( + self, + message: SendableMessage, + /, + *, + correlation_id: Optional[str] = None, + ) -> Optional[Any]: + msg = "You can't use `FakePublisher` directly." + raise NotImplementedError(msg) + + async def _publish( self, message: "SendableMessage", *, diff --git a/faststream/_internal/publisher/proto.py b/faststream/_internal/publisher/proto.py index 1cb920b280..d2094b0e46 100644 --- a/faststream/_internal/publisher/proto.py +++ b/faststream/_internal/publisher/proto.py @@ -54,9 +54,26 @@ async def publish( /, *, correlation_id: Optional[str] = None, + ) -> Optional[Any]: + """Public method to publish a message. + + Should be called by user only `broker.publisher(...).publish(...)`. + """ + ... + + @abstractmethod + async def _publish( + self, + message: "SendableMessage", + /, + *, + correlation_id: Optional[str] = None, _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> Optional[Any]: - """Publishes a message asynchronously.""" + """Private method to publish a message. + + Should be called inside `publish` method or as a step of `consume` scope. + """ ... @abstractmethod diff --git a/faststream/_internal/subscriber/usecase.py b/faststream/_internal/subscriber/usecase.py index 54d5a9250c..75f4063a10 100644 --- a/faststream/_internal/subscriber/usecase.py +++ b/faststream/_internal/subscriber/usecase.py @@ -376,7 +376,7 @@ async def process_message(self, msg: MsgType) -> "Response": self.__get_response_publisher(message), h.handler._publishers, ): - await p.publish( + await p._publish( result_msg.body, **result_msg.as_publish_kwargs(), # publisher middlewares diff --git a/faststream/confluent/publisher/usecase.py b/faststream/confluent/publisher/usecase.py index 6b7fcf101c..120928b68f 100644 --- a/faststream/confluent/publisher/usecase.py +++ b/faststream/confluent/publisher/usecase.py @@ -165,6 +165,33 @@ async def publish( correlation_id: Optional[str] = None, reply_to: str = "", no_confirm: bool = False, + ) -> None: + return await self._publish( + message, + topic=topic, + key=key, + partition=partition, + timestamp_ms=timestamp_ms, + headers=headers, + correlation_id=correlation_id, + reply_to=reply_to, + no_confirm=no_confirm, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + message: "SendableMessage", + topic: str = "", + *, + key: Optional[bytes] = None, + partition: Optional[int] = None, + timestamp_ms: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + correlation_id: Optional[str] = None, + reply_to: str = "", + no_confirm: bool = False, # publisher specific _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> None: @@ -236,6 +263,32 @@ async def publish( correlation_id: Optional[str] = None, reply_to: str = "", no_confirm: bool = False, + ) -> None: + return await self._publish( + message, + *extra_messages, + topic=topic, + partition=partition, + timestamp_ms=timestamp_ms, + headers=headers, + correlation_id=correlation_id, + reply_to=reply_to, + no_confirm=no_confirm, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + message: Union["SendableMessage", Iterable["SendableMessage"]], + *extra_messages: "SendableMessage", + topic: str = "", + partition: Optional[int] = None, + timestamp_ms: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + correlation_id: Optional[str] = None, + reply_to: str = "", + no_confirm: bool = False, # publisher specific _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> None: diff --git a/faststream/kafka/publisher/usecase.py b/faststream/kafka/publisher/usecase.py index de0094033e..3c09c32dd8 100644 --- a/faststream/kafka/publisher/usecase.py +++ b/faststream/kafka/publisher/usecase.py @@ -269,11 +269,35 @@ async def publish( bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, + ) -> None: + return await self._publish( + message, + topic=topic, + key=key, + partition=partition, + timestamp_ms=timestamp_ms, + headers=headers, + correlation_id=correlation_id, + reply_to=reply_to, + no_confirm=no_confirm, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + message: "SendableMessage", + topic: str = "", + *, + key: Union[bytes, Any, None] = None, + partition: Optional[int] = None, + timestamp_ms: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + correlation_id: Optional[str] = None, + reply_to: str = "", + no_confirm: bool = False, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), + _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 @@ -438,11 +462,34 @@ async def publish( bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, + ) -> None: + return await self._publish( + message, + *extra_messages, + topic=topic, + partition=partition, + timestamp_ms=timestamp_ms, + headers=headers, + reply_to=reply_to, + correlation_id=correlation_id, + no_confirm=no_confirm, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + message: Union["SendableMessage", Iterable["SendableMessage"]], + *extra_messages: "SendableMessage", + topic: str = "", + partition: Optional[int] = None, + timestamp_ms: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + correlation_id: Optional[str] = None, + no_confirm: bool = False, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), + _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 diff --git a/faststream/nats/publisher/usecase.py b/faststream/nats/publisher/usecase.py index f76e24d070..b3317e4be9 100644 --- a/faststream/nats/publisher/usecase.py +++ b/faststream/nats/publisher/usecase.py @@ -76,8 +76,6 @@ async def publish( correlation_id: Optional[str] = None, stream: Optional[str] = None, timeout: Optional[float] = None, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> None: """Publish message directly. @@ -95,9 +93,32 @@ async def publish( stream (str, optional): This option validates that the target subject is in presented stream (default is `None`). Can be omitted without any effect. timeout (float, optional): Timeout to send message to NATS in seconds (default is `None`). - - _extra_middlewares (:obj:`Iterable` of :obj:`PublisherMiddleware`): Extra middlewares to wrap publishing process (default is `()`). """ + return await self._publish( + message, + subject=subject, + headers=headers, + reply_to=reply_to, + correlation_id=correlation_id, + stream=stream, + timeout=timeout, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + message: "SendableMessage", + subject: str = "", + *, + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + correlation_id: Optional[str] = None, + stream: Optional[str] = None, + timeout: Optional[float] = None, + # publisher specific + _extra_middlewares: Iterable["PublisherMiddleware"] = (), + ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 kwargs: AnyDict = { diff --git a/faststream/rabbit/publisher/usecase.py b/faststream/rabbit/publisher/usecase.py index 49cdf71f14..6a01bdbdac 100644 --- a/faststream/rabbit/publisher/usecase.py +++ b/faststream/rabbit/publisher/usecase.py @@ -211,10 +211,34 @@ async def publish( Doc("Message publish timestamp. Generated automatically if not presented."), ] = None, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), + **publish_kwargs: "Unpack[PublishKwargs]", + ) -> Optional["aiormq.abc.ConfirmationFrameType"]: + return await self._publish( + message, + queue=queue, + exchange=exchange, + routing_key=routing_key, + correlation_id=correlation_id, + message_id=message_id, + timestamp=timestamp, + _extra_middlewares=(), + **publish_kwargs, + ) + + @override + async def _publish( + self, + message: "AioPikaSendableMessage", + queue: Union["RabbitQueue", str, None] = None, + exchange: Union["RabbitExchange", str, None] = None, + *, + routing_key: str = "", + # message args + correlation_id: Optional[str] = None, + message_id: Optional[str] = None, + timestamp: Optional["DateType"] = None, + # publisher specific + _extra_middlewares: Iterable["PublisherMiddleware"] = (), **publish_kwargs: "Unpack[PublishKwargs]", ) -> Optional["aiormq.abc.ConfirmationFrameType"]: assert self._producer, NOT_CONNECTED_YET # nosec B101 diff --git a/faststream/redis/publisher/usecase.py b/faststream/redis/publisher/usecase.py index 9aae92c837..27ad3a3543 100644 --- a/faststream/redis/publisher/usecase.py +++ b/faststream/redis/publisher/usecase.py @@ -128,11 +128,28 @@ async def publish( "**correlation_id** is a useful option to trace messages.", ), ] = None, + **kwargs: Any, # option to suppress maxlen + ) -> None: + return await self._publish( + message, + channel=channel, + reply_to=reply_to, + headers=headers, + correlation_id=correlation_id, + _extra_middlewares=(), + **kwargs, + ) + + @override + async def _publish( + self, + message: "SendableMessage" = None, + channel: Optional[str] = None, + reply_to: str = "", + headers: Optional["AnyDict"] = None, + correlation_id: Optional[str] = None, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), + _extra_middlewares: Iterable["PublisherMiddleware"] = (), **kwargs: Any, # option to suppress maxlen ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 @@ -298,10 +315,28 @@ async def publish( ), ] = None, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), + **kwargs: Any, # option to suppress maxlen + ) -> None: + return await self._publish( + message, + list=list, + reply_to=reply_to, + headers=headers, + correlation_id=correlation_id, + _extra_middlewares=(), + **kwargs, + ) + + @override + async def _publish( + self, + message: "SendableMessage" = None, + list: Optional[str] = None, + reply_to: str = "", + headers: Optional["AnyDict"] = None, + correlation_id: Optional[str] = None, + # publisher specific + _extra_middlewares: Iterable["PublisherMiddleware"] = (), **kwargs: Any, # option to suppress maxlen ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 @@ -420,10 +455,27 @@ async def publish( # type: ignore[override] Doc("Message headers to store metainformation."), ] = None, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), + **kwargs: Any, # option to suppress maxlen + ) -> None: + return await self._publish( + message, + list=list, + correlation_id=correlation_id, + headers=headers, + _extra_middlewares=(), + **kwargs, + ) + + @override + async def _publish( # type: ignore[override] + self, + message: "SendableMessage" = (), + list: Optional[str] = None, + *, + correlation_id: Optional[str] = None, + headers: Optional["AnyDict"] = None, + # publisher specific + _extra_middlewares: Iterable["PublisherMiddleware"] = (), **kwargs: Any, # option to suppress maxlen ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 @@ -526,11 +578,29 @@ async def publish( "Remove eldest message if maxlen exceeded.", ), ] = None, + ) -> None: + return await self._publish( + message, + stream=stream, + reply_to=reply_to, + headers=headers, + correlation_id=correlation_id, + maxlen=maxlen, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + message: "SendableMessage" = None, + stream: Optional[str] = None, + reply_to: str = "", + headers: Optional["AnyDict"] = None, + correlation_id: Optional[str] = None, + *, + maxlen: Optional[int] = None, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), + _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 From e9580ba237b7dab9572323017a17710d99934411 Mon Sep 17 00:00:00 2001 From: Ivan Kirpichnikov Date: Fri, 18 Oct 2024 23:18:44 +0300 Subject: [PATCH 11/48] add aiogram example (#1858) * add aiogram example * add highlighting lines * add types-aiofiles in types optional dependencies * run pre-commit --- .../integrations/frameworks/index.md | 1 + docs/docs/en/public_api | 2 +- .../__init__.py | 0 .../aiogram.py | 34 +++++++++++++++++++ .../getting_started/integrations/http/1.md | 7 ++++ pyproject.toml | 1 + 6 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 docs/docs_src/integrations/no_http_frameworks_integrations/__init__.py create mode 100644 docs/docs_src/integrations/no_http_frameworks_integrations/aiogram.py diff --git a/docs/docs/en/getting-started/integrations/frameworks/index.md b/docs/docs/en/getting-started/integrations/frameworks/index.md index fcb09ce7f2..d6fe094465 100644 --- a/docs/docs/en/getting-started/integrations/frameworks/index.md +++ b/docs/docs/en/getting-started/integrations/frameworks/index.md @@ -9,6 +9,7 @@ search: # template variables fastapi_plugin: If you want to use **FastStream** in conjunction with **FastAPI**, perhaps you should use a special [plugin](../fastapi/index.md){.internal-link} no_hook: However, even if such a hook is not provided, you can do it yourself. +and_not_only_http: And not only HTTP frameworks. --- # INTEGRATIONS diff --git a/docs/docs/en/public_api b/docs/docs/en/public_api index b14a93fe9e..2c417d1ba5 120000 --- a/docs/docs/en/public_api +++ b/docs/docs/en/public_api @@ -1 +1 @@ -./api/ \ No newline at end of file +./api/ diff --git a/docs/docs_src/integrations/no_http_frameworks_integrations/__init__.py b/docs/docs_src/integrations/no_http_frameworks_integrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/docs_src/integrations/no_http_frameworks_integrations/aiogram.py b/docs/docs_src/integrations/no_http_frameworks_integrations/aiogram.py new file mode 100644 index 0000000000..94d8cc7aae --- /dev/null +++ b/docs/docs_src/integrations/no_http_frameworks_integrations/aiogram.py @@ -0,0 +1,34 @@ +import asyncio + +from aiogram import Bot, Dispatcher +from aiogram.types import Message + +from faststream.nats import NatsBroker + +bot = Bot("") +dispatcher = Dispatcher() +broker = NatsBroker() + +@broker.subscriber("echo") +async def echo_faststream_handler(data: dict[str, str]) -> None: + await bot.copy_message(**data) + + +@dispatcher.message() +async def echo_aiogram_handler(event: Message) -> None: + await broker.publish( + subject="echo", + message={ + "chat_id": event.chat.id, + "message_id": event.message_id, + "from_chat_id": event.chat.id, + }, + ) + + +async def main() -> None: + async with broker: + await broker.start() + await dispatcher.start_polling(bot) + +asyncio.run(main()) diff --git a/docs/includes/getting_started/integrations/http/1.md b/docs/includes/getting_started/integrations/http/1.md index 6c6d58e0c0..d124345ce4 100644 --- a/docs/includes/getting_started/integrations/http/1.md +++ b/docs/includes/getting_started/integrations/http/1.md @@ -42,3 +42,10 @@ ```python linenums="1" hl_lines="5 7 10-12 32-36" {!> docs_src/integrations/http_frameworks_integrations/tornado.py !} ``` + +{{ and_not_only_http }} + +=== "Aiogram" + ```python linenums="1" hl_lines="6 10 12-14 30-31" + {!> docs_src/integrations/no_http_frameworks_integrations/aiogram.py !} + ``` diff --git a/pyproject.toml b/pyproject.toml index 10d7a66092..350d53ed3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ types = [ "types-redis", "types-Pygments", "types-docutils", + "types-aiofiles", "confluent-kafka-stubs; python_version >= '3.11'", ] From 5812e298f1cb83ae1ecfd614fe8a68e64d02cc74 Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Sat, 19 Oct 2024 14:08:59 +0300 Subject: [PATCH 12/48] docs: fix public api directory (#1861) --- docs/docs/en/public_api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/en/public_api b/docs/docs/en/public_api index 2c417d1ba5..b14a93fe9e 120000 --- a/docs/docs/en/public_api +++ b/docs/docs/en/public_api @@ -1 +1 @@ -./api/ +./api/ \ No newline at end of file From 975d182b209608d3b8b9ca3a4210503000939882 Mon Sep 17 00:00:00 2001 From: Roma Frolov <98967567+roma-frolov@users.noreply.github.com> Date: Sun, 20 Oct 2024 19:46:37 +0300 Subject: [PATCH 13/48] Feature: Prometheus Middleware (#1791) * base prometheus middleware * rabbit prometheus middleware * fixed always message ack in the absence of errors * small refactoring, redis metrics * kafka prometheus * str statuses -> StrEnums * fix kafka tests * confluent prometheus * nats prometheus * edit message count in process & fix settings provider for Nats KV and Nats OS * importorskip in tests * doc & ruff & mypy * docs: generate API References * clarifications added * pushback tests fixed * lint fixed * lint fixed * number of tests reduced & fix confluent mark * test cases renamed * confluent tests fixed * documentation addition * updated SUMMARY.md * docs: generate API References * trailing-whitespace in index.md * deleted pragma: no-cover * tests for getting started code examples * Revert "tests for getting started code examples" This reverts commit b54950dc771b10b1102c52db68449993942e3024. * MetricsManager abstraction added and used in middleware * fixed an error when there was no exchange in tests * documentation has been corrected due to the renaming of metrics * docs: generate API References * fixed type annotations * added app_name label in index.md * removed extra space in metric doc * fixed status in add_published_message * fixed buckets type * test for implementing metrics on a real prometheus_client * small tests refactoring * changed metrics_prefix default value * **kwargs in get_broker mtehod * readable params in tests * simplified annotation * EMPTY as metrics_prefix default value * fixed test_observe_received_messages_size * apply_types=False as default in get_broker * external links in prometheus/index.md * fixed doc * fixed params naming * fix: revert acknowledgement changes * lint: fix precommit * chore: bump version * app_name EMPTY is default --------- Co-authored-by: roma-frolov Co-authored-by: Nikita Pastukhov Co-authored-by: Pastukhov Nikita --- docs/docs/SUMMARY.md | 60 ++ .../prometheus/KafkaPrometheusMiddleware.md | 11 + .../middleware/KafkaPrometheusMiddleware.md | 11 + .../BaseConfluentMetricsSettingsProvider.md | 11 + .../BatchConfluentMetricsSettingsProvider.md | 11 + .../ConfluentMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../prometheus/KafkaPrometheusMiddleware.md | 11 + .../middleware/KafkaPrometheusMiddleware.md | 11 + .../BaseKafkaMetricsSettingsProvider.md | 11 + .../BatchKafkaMetricsSettingsProvider.md | 11 + .../provider/KafkaMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../prometheus/NatsPrometheusMiddleware.md | 11 + .../middleware/NatsPrometheusMiddleware.md | 11 + .../BaseNatsMetricsSettingsProvider.md | 11 + .../BatchNatsMetricsSettingsProvider.md | 11 + .../provider/NatsMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../prometheus/BasePrometheusMiddleware.md | 11 + .../api/faststream/prometheus/ConsumeAttrs.md | 11 + .../prometheus/MetricsSettingsProvider.md | 11 + .../prometheus/container/MetricsContainer.md | 11 + .../prometheus/manager/MetricsManager.md | 11 + .../middleware/BasePrometheusMiddleware.md | 11 + .../middleware/PrometheusMiddleware.md | 11 + .../provider/MetricsSettingsProvider.md | 11 + .../prometheus/types/ConsumeAttrs.md | 11 + .../prometheus/types/ProcessingStatus.md | 11 + .../prometheus/types/PublishingStatus.md | 11 + .../prometheus/RabbitPrometheusMiddleware.md | 11 + .../middleware/RabbitPrometheusMiddleware.md | 11 + .../provider/RabbitMetricsSettingsProvider.md | 11 + .../prometheus/RedisPrometheusMiddleware.md | 11 + .../middleware/RedisPrometheusMiddleware.md | 11 + .../BaseRedisMetricsSettingsProvider.md | 11 + .../BatchRedisMetricsSettingsProvider.md | 11 + .../provider/RedisMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../en/getting-started/prometheus/index.md | 82 +++ docs/docs/navigation_template.txt | 1 + .../getting_started/prometheus/__init__.py | 0 .../getting_started/prometheus/confluent.py | 13 + .../prometheus/confluent_asgi.py | 18 + .../getting_started/prometheus/kafka.py | 13 + .../getting_started/prometheus/kafka_asgi.py | 18 + .../getting_started/prometheus/nats.py | 13 + .../getting_started/prometheus/nats_asgi.py | 18 + .../getting_started/prometheus/rabbit.py | 13 + .../getting_started/prometheus/rabbit_asgi.py | 18 + .../getting_started/prometheus/redis.py | 13 + .../getting_started/prometheus/redis_asgi.py | 18 + docs/includes/getting_started/prometheus/1.md | 24 + docs/includes/getting_started/prometheus/2.md | 24 + faststream/__about__.py | 2 +- faststream/broker/message.py | 9 +- faststream/confluent/message.py | 4 +- faststream/confluent/prometheus/__init__.py | 3 + faststream/confluent/prometheus/middleware.py | 26 + faststream/confluent/prometheus/provider.py | 64 ++ faststream/kafka/message.py | 4 +- faststream/kafka/prometheus/__init__.py | 3 + faststream/kafka/prometheus/middleware.py | 26 + faststream/kafka/prometheus/provider.py | 64 ++ faststream/nats/message.py | 6 +- faststream/nats/prometheus/__init__.py | 3 + faststream/nats/prometheus/middleware.py | 26 + faststream/nats/prometheus/provider.py | 66 ++ faststream/prometheus/__init__.py | 9 + faststream/prometheus/consts.py | 17 + faststream/prometheus/container.py | 100 +++ faststream/prometheus/manager.py | 131 ++++ faststream/prometheus/middleware.py | 202 ++++++ faststream/prometheus/provider.py | 22 + faststream/prometheus/types.py | 21 + faststream/rabbit/prometheus/__init__.py | 3 + faststream/rabbit/prometheus/middleware.py | 26 + faststream/rabbit/prometheus/provider.py | 44 ++ faststream/redis/message.py | 2 +- faststream/redis/prometheus/__init__.py | 3 + faststream/redis/prometheus/middleware.py | 26 + faststream/redis/prometheus/provider.py | 63 ++ pyproject.toml | 4 +- tests/brokers/test_pushback.py | 2 +- tests/prometheus/__init__.py | 0 tests/prometheus/basic.py | 204 ++++++ tests/prometheus/confluent/__init__.py | 3 + tests/prometheus/confluent/test_confluent.py | 79 +++ tests/prometheus/kafka/__init__.py | 3 + tests/prometheus/kafka/test_kafka.py | 82 +++ tests/prometheus/nats/__init__.py | 3 + tests/prometheus/nats/test_nats.py | 86 +++ tests/prometheus/rabbit/__init__.py | 3 + tests/prometheus/rabbit/test_rabbit.py | 42 ++ tests/prometheus/redis/__init__.py | 3 + tests/prometheus/redis/test_redis.py | 77 +++ tests/prometheus/test_metrics.py | 644 ++++++++++++++++++ 97 files changed, 2960 insertions(+), 14 deletions(-) create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md create mode 100644 docs/docs/en/api/faststream/prometheus/BasePrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md create mode 100644 docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md create mode 100644 docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md create mode 100644 docs/docs/en/api/faststream/prometheus/middleware/BasePrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md create mode 100644 docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md create mode 100644 docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md create mode 100644 docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md create mode 100644 docs/docs/en/getting-started/prometheus/index.md create mode 100644 docs/docs_src/getting_started/prometheus/__init__.py create mode 100644 docs/docs_src/getting_started/prometheus/confluent.py create mode 100644 docs/docs_src/getting_started/prometheus/confluent_asgi.py create mode 100644 docs/docs_src/getting_started/prometheus/kafka.py create mode 100644 docs/docs_src/getting_started/prometheus/kafka_asgi.py create mode 100644 docs/docs_src/getting_started/prometheus/nats.py create mode 100644 docs/docs_src/getting_started/prometheus/nats_asgi.py create mode 100644 docs/docs_src/getting_started/prometheus/rabbit.py create mode 100644 docs/docs_src/getting_started/prometheus/rabbit_asgi.py create mode 100644 docs/docs_src/getting_started/prometheus/redis.py create mode 100644 docs/docs_src/getting_started/prometheus/redis_asgi.py create mode 100644 docs/includes/getting_started/prometheus/1.md create mode 100644 docs/includes/getting_started/prometheus/2.md create mode 100644 faststream/confluent/prometheus/__init__.py create mode 100644 faststream/confluent/prometheus/middleware.py create mode 100644 faststream/confluent/prometheus/provider.py create mode 100644 faststream/kafka/prometheus/__init__.py create mode 100644 faststream/kafka/prometheus/middleware.py create mode 100644 faststream/kafka/prometheus/provider.py create mode 100644 faststream/nats/prometheus/__init__.py create mode 100644 faststream/nats/prometheus/middleware.py create mode 100644 faststream/nats/prometheus/provider.py create mode 100644 faststream/prometheus/__init__.py create mode 100644 faststream/prometheus/consts.py create mode 100644 faststream/prometheus/container.py create mode 100644 faststream/prometheus/manager.py create mode 100644 faststream/prometheus/middleware.py create mode 100644 faststream/prometheus/provider.py create mode 100644 faststream/prometheus/types.py create mode 100644 faststream/rabbit/prometheus/__init__.py create mode 100644 faststream/rabbit/prometheus/middleware.py create mode 100644 faststream/rabbit/prometheus/provider.py create mode 100644 faststream/redis/prometheus/__init__.py create mode 100644 faststream/redis/prometheus/middleware.py create mode 100644 faststream/redis/prometheus/provider.py create mode 100644 tests/prometheus/__init__.py create mode 100644 tests/prometheus/basic.py create mode 100644 tests/prometheus/confluent/__init__.py create mode 100644 tests/prometheus/confluent/test_confluent.py create mode 100644 tests/prometheus/kafka/__init__.py create mode 100644 tests/prometheus/kafka/test_kafka.py create mode 100644 tests/prometheus/nats/__init__.py create mode 100644 tests/prometheus/nats/test_nats.py create mode 100644 tests/prometheus/rabbit/__init__.py create mode 100644 tests/prometheus/rabbit/test_rabbit.py create mode 100644 tests/prometheus/redis/__init__.py create mode 100644 tests/prometheus/redis/test_redis.py create mode 100644 tests/prometheus/test_metrics.py diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index 6f998be4f8..86ff1025b7 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -44,6 +44,7 @@ search: - [CLI](getting-started/cli/index.md) - [ASGI](getting-started/asgi.md) - [OpenTelemetry](getting-started/opentelemetry/index.md) + - [Prometheus](getting-started/prometheus/index.md) - [Logging](getting-started/logging.md) - [Config Management](getting-started/config/index.md) - [Task Scheduling](scheduling.md) @@ -523,6 +524,15 @@ search: - [telemetry_attributes_provider_factory](api/faststream/confluent/opentelemetry/provider/telemetry_attributes_provider_factory.md) - parser - [AsyncConfluentParser](api/faststream/confluent/parser/AsyncConfluentParser.md) + - prometheus + - [KafkaPrometheusMiddleware](api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md) + - middleware + - [KafkaPrometheusMiddleware](api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md) + - provider + - [BaseConfluentMetricsSettingsProvider](api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md) + - [BatchConfluentMetricsSettingsProvider](api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md) + - [ConfluentMetricsSettingsProvider](api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/confluent/prometheus/provider/settings_provider_factory.md) - publisher - asyncapi - [AsyncAPIBatchPublisher](api/faststream/confluent/publisher/asyncapi/AsyncAPIBatchPublisher.md) @@ -619,6 +629,15 @@ search: - parser - [AioKafkaBatchParser](api/faststream/kafka/parser/AioKafkaBatchParser.md) - [AioKafkaParser](api/faststream/kafka/parser/AioKafkaParser.md) + - prometheus + - [KafkaPrometheusMiddleware](api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md) + - middleware + - [KafkaPrometheusMiddleware](api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md) + - provider + - [BaseKafkaMetricsSettingsProvider](api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md) + - [BatchKafkaMetricsSettingsProvider](api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md) + - [KafkaMetricsSettingsProvider](api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/kafka/prometheus/provider/settings_provider_factory.md) - publisher - asyncapi - [AsyncAPIBatchPublisher](api/faststream/kafka/publisher/asyncapi/AsyncAPIBatchPublisher.md) @@ -732,6 +751,15 @@ search: - [NatsBaseParser](api/faststream/nats/parser/NatsBaseParser.md) - [NatsParser](api/faststream/nats/parser/NatsParser.md) - [ObjParser](api/faststream/nats/parser/ObjParser.md) + - prometheus + - [NatsPrometheusMiddleware](api/faststream/nats/prometheus/NatsPrometheusMiddleware.md) + - middleware + - [NatsPrometheusMiddleware](api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md) + - provider + - [BaseNatsMetricsSettingsProvider](api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md) + - [BatchNatsMetricsSettingsProvider](api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md) + - [NatsMetricsSettingsProvider](api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/nats/prometheus/provider/settings_provider_factory.md) - publisher - asyncapi - [AsyncAPIPublisher](api/faststream/nats/publisher/asyncapi/AsyncAPIPublisher.md) @@ -810,6 +838,23 @@ search: - [TelemetryMiddleware](api/faststream/opentelemetry/middleware/TelemetryMiddleware.md) - provider - [TelemetrySettingsProvider](api/faststream/opentelemetry/provider/TelemetrySettingsProvider.md) + - prometheus + - [BasePrometheusMiddleware](api/faststream/prometheus/BasePrometheusMiddleware.md) + - [ConsumeAttrs](api/faststream/prometheus/ConsumeAttrs.md) + - [MetricsSettingsProvider](api/faststream/prometheus/MetricsSettingsProvider.md) + - container + - [MetricsContainer](api/faststream/prometheus/container/MetricsContainer.md) + - manager + - [MetricsManager](api/faststream/prometheus/manager/MetricsManager.md) + - middleware + - [BasePrometheusMiddleware](api/faststream/prometheus/middleware/BasePrometheusMiddleware.md) + - [PrometheusMiddleware](api/faststream/prometheus/middleware/PrometheusMiddleware.md) + - provider + - [MetricsSettingsProvider](api/faststream/prometheus/provider/MetricsSettingsProvider.md) + - types + - [ConsumeAttrs](api/faststream/prometheus/types/ConsumeAttrs.md) + - [ProcessingStatus](api/faststream/prometheus/types/ProcessingStatus.md) + - [PublishingStatus](api/faststream/prometheus/types/PublishingStatus.md) - rabbit - [ExchangeType](api/faststream/rabbit/ExchangeType.md) - [RabbitBroker](api/faststream/rabbit/RabbitBroker.md) @@ -848,6 +893,12 @@ search: - [RabbitTelemetrySettingsProvider](api/faststream/rabbit/opentelemetry/provider/RabbitTelemetrySettingsProvider.md) - parser - [AioPikaParser](api/faststream/rabbit/parser/AioPikaParser.md) + - prometheus + - [RabbitPrometheusMiddleware](api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md) + - middleware + - [RabbitPrometheusMiddleware](api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md) + - provider + - [RabbitMetricsSettingsProvider](api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md) - publisher - asyncapi - [AsyncAPIPublisher](api/faststream/rabbit/publisher/asyncapi/AsyncAPIPublisher.md) @@ -949,6 +1000,15 @@ search: - [RedisPubSubParser](api/faststream/redis/parser/RedisPubSubParser.md) - [RedisStreamParser](api/faststream/redis/parser/RedisStreamParser.md) - [SimpleParser](api/faststream/redis/parser/SimpleParser.md) + - prometheus + - [RedisPrometheusMiddleware](api/faststream/redis/prometheus/RedisPrometheusMiddleware.md) + - middleware + - [RedisPrometheusMiddleware](api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md) + - provider + - [BaseRedisMetricsSettingsProvider](api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md) + - [BatchRedisMetricsSettingsProvider](api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md) + - [RedisMetricsSettingsProvider](api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/redis/prometheus/provider/settings_provider_factory.md) - publisher - asyncapi - [AsyncAPIChannelPublisher](api/faststream/redis/publisher/asyncapi/AsyncAPIChannelPublisher.md) diff --git a/docs/docs/en/api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md b/docs/docs/en/api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md new file mode 100644 index 0000000000..e84e84acc3 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md b/docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md new file mode 100644 index 0000000000..6603893f74 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.middleware.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md new file mode 100644 index 0000000000..27c186c098 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.BaseConfluentMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md new file mode 100644 index 0000000000..f784a64e9f --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.BatchConfluentMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md new file mode 100644 index 0000000000..65f0a8348e --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.ConfluentMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..78358f46e3 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md b/docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md new file mode 100644 index 0000000000..c2ffd5356a --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md b/docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md new file mode 100644 index 0000000000..451b7080c0 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.middleware.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md new file mode 100644 index 0000000000..0fd044f694 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.BaseKafkaMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md new file mode 100644 index 0000000000..9bd01d5e71 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.BatchKafkaMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md new file mode 100644 index 0000000000..ae7c490da8 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.KafkaMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..1393fd9065 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md b/docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md new file mode 100644 index 0000000000..d9b179b0c4 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.NatsPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md b/docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md new file mode 100644 index 0000000000..7202731048 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.middleware.NatsPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md b/docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md new file mode 100644 index 0000000000..80742833bc --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.BaseNatsMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md b/docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md new file mode 100644 index 0000000000..163ebb7bc6 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.BatchNatsMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md b/docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md new file mode 100644 index 0000000000..e5515a4cc5 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.NatsMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..aeaa7b26e0 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/api/faststream/prometheus/BasePrometheusMiddleware.md b/docs/docs/en/api/faststream/prometheus/BasePrometheusMiddleware.md new file mode 100644 index 0000000000..1f5cf6a1d4 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/BasePrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.BasePrometheusMiddleware diff --git a/docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md b/docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md new file mode 100644 index 0000000000..ad8e536b7a --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.ConsumeAttrs diff --git a/docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md b/docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md new file mode 100644 index 0000000000..0f7405e44d --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.MetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md b/docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md new file mode 100644 index 0000000000..009d88d263 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.container.MetricsContainer diff --git a/docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md b/docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md new file mode 100644 index 0000000000..b1a897c717 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.manager.MetricsManager diff --git a/docs/docs/en/api/faststream/prometheus/middleware/BasePrometheusMiddleware.md b/docs/docs/en/api/faststream/prometheus/middleware/BasePrometheusMiddleware.md new file mode 100644 index 0000000000..62bbd031ac --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/middleware/BasePrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.middleware.BasePrometheusMiddleware diff --git a/docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md b/docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md new file mode 100644 index 0000000000..2902586e38 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.middleware.PrometheusMiddleware diff --git a/docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md b/docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md new file mode 100644 index 0000000000..3511a21a5b --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.provider.MetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md b/docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md new file mode 100644 index 0000000000..d9196cab8d --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.types.ConsumeAttrs diff --git a/docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md b/docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md new file mode 100644 index 0000000000..98b6710bcd --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.types.ProcessingStatus diff --git a/docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md b/docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md new file mode 100644 index 0000000000..4e7435fbea --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.types.PublishingStatus diff --git a/docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md b/docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md new file mode 100644 index 0000000000..2c4308fabd --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.prometheus.RabbitPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md b/docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md new file mode 100644 index 0000000000..45163c998a --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.prometheus.middleware.RabbitPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md b/docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md new file mode 100644 index 0000000000..6d63301b34 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.prometheus.provider.RabbitMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md b/docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md new file mode 100644 index 0000000000..01b23fe4f1 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.RedisPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md b/docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md new file mode 100644 index 0000000000..c29cc91130 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.middleware.RedisPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md b/docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md new file mode 100644 index 0000000000..243414331b --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.BaseRedisMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md b/docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md new file mode 100644 index 0000000000..33d1d2d3a1 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.BatchRedisMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md b/docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md new file mode 100644 index 0000000000..a7f5f3abe8 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.RedisMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..aa4812f1e2 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/getting-started/prometheus/index.md b/docs/docs/en/getting-started/prometheus/index.md new file mode 100644 index 0000000000..54203ce7df --- /dev/null +++ b/docs/docs/en/getting-started/prometheus/index.md @@ -0,0 +1,82 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 10 +--- + +# Prometheus + +[**Prometheus**](https://prometheus.io/){.external-link target="_blank"} is an open-source monitoring and alerting toolkit originally built at SoundCloud. +With a focus on reliability, robustness, and easy scalability, **Prometheus** allows users to collect metrics, +scrape data from various sources, store them efficiently, and query them in real-time. Its flexible data model, +powerful query language, and seamless integration with [**Grafana**](https://grafana.com/){.external-link target="_blank"} make it a popular choice for monitoring the health +and performance of systems and applications. + +### FastStream Metrics + +To add a metrics to your broker, you need to: + +1. Install `FastStream` with `prometheus-client` + + ```shell + pip install faststream[prometheus] + ``` + +2. Add `PrometheusMiddleware` to your broker + +{!> includes/getting_started/prometheus/1.md !} + +### Exposing the `/metrics` endpoint +The way Prometheus works requires the service to expose an HTTP endpoint for analysis. +By convention, this is a GET endpoint, and its path is usually `/metrics`. + +FastStream's built-in **ASGI** support allows you to expose endpoints in your application. + +A convenient way to serve this endpoint is to use `make_asgi_app` from `prometheus_client`, +passing in the registry that was passed to `PrometheusMiddleware`. + +{!> includes/getting_started/prometheus/2.md !} + +--- + +### Exported metrics + +{% set received_messages_total_description = 'The metric is incremented each time the application receives a message.

This is necessary to count messages that the application has received but has not yet started processing.' %} +{% set received_messages_size_bytes_description = 'The metric is filled with the sizes of received messages. When a message is received, the size of its body in bytes is calculated and written to the metric.

Useful for analyzing the sizes of incoming messages, also in cases when the application receives messages of unexpected sizes.' %} +{% set received_messages_in_process_description = 'The metric is incremented when the message processing starts and decremented when the processing ends.

It is necessary to count the number of messages that the application processes.

Such a metric will help answer the question: _`is there a need to scale the service?`_' %} +{% set received_processed_messages_total_description = 'The metric is incremented after a message is processed, regardless of whether the processing ended with a success or an error.

This metric allows you to analyze the number of processed messages and their statuses.' %} +{% set received_processed_messages_duration_seconds_description = 'The metric is filled with the message processing time regardless of whether the processing ended with a success or an error.

Time stamps are recorded just before and immediately after the processing.

Then the metric is filled with their difference (in seconds).' %} +{% set received_processed_messages_exceptions_total_description = 'The metric is incremented if any exception occurred while processing a message (except `AckMessage`, `NackMessage`, `RejectMessage` and `SkipMessage`).

It can be used to draw conclusions about how many and what kind of exceptions occurred while processing messages.' %} +{% set published_messages_total_description = 'The metric is incremented when messages are sent, regardless of whether the sending was successful or not.' %} +{% set published_messages_duration_seconds_description = 'The metric is filled with the time the message was sent, regardless of whether the sending was successful or failed.

Timestamps are written immediately before and immediately after sending.

Then the metric is filled with their difference (in seconds).' %} +{% set published_messages_exceptions_total_description = 'The metric increases if any exception occurred while sending a message.

You can draw conclusions about how many and what exceptions occurred while sending messages.' %} + + +| Metric | Type | Description | Labels | +|--------------------------------------------------|---------------|----------------------------------------------------------------|-------------------------------------------------------| +| **received_messages_total** | **Counter** | {{ received_messages_total_description }} | `app_name`, `broker`, `handler` | +| **received_messages_size_bytes** | **Histogram** | {{ received_messages_size_bytes_description }} | `app_name`, `broker`, `handler` | +| **received_messages_in_process** | **Gauge** | {{ received_messages_in_process_description }} | `app_name`, `broker`, `handler` | +| **received_processed_messages_total** | **Counter** | {{ received_processed_messages_total_description }} | `app_name`, `broker`, `handler`, `status` | +| **received_processed_messages_duration_seconds** | **Histogram** | {{ received_processed_messages_duration_seconds_description }} | `app_name`, `broker`, `handler` | +| **received_processed_messages_exceptions_total** | **Counter** | {{ received_processed_messages_exceptions_total_description }} | `app_name`, `broker`, `handler`, `exception_type` | +| **published_messages_total** | **Counter** | {{ published_messages_total_description }} | `app_name`, `broker`, `destination`, `status` | +| **published_messages_duration_seconds** | **Histogram** | {{ published_messages_duration_seconds_description }} | `app_name`, `broker`, `destination` | +| **published_messages_exceptions_total** | **Counter** | {{ published_messages_exceptions_total_description }} | `app_name`, `broker`, `destination`, `exception_type` | + +### Labels + +| Label | Description | Values | +|-----------------------------------|-----------------------------------------------------------------|---------------------------------------------------| +| app_name | The name of the application, which the user can specify himself | `faststream` by default | +| broker | Broker name | `kafka`, `rabbit`, `nats`, `redis` | +| handler | Where the message came from | | +| status (while receiving) | Message processing status | `acked`, `nacked`, `rejected`, `skipped`, `error` | +| exception_type (while receiving) | Exception type when processing message | | +| status (while publishing) | Message publishing status | `success`, `error` | +| destination | Where the message is sent | | +| exception_type (while publishing) | Exception type when publishing message | | diff --git a/docs/docs/navigation_template.txt b/docs/docs/navigation_template.txt index 4cd45d0874..e505d5a783 100644 --- a/docs/docs/navigation_template.txt +++ b/docs/docs/navigation_template.txt @@ -44,6 +44,7 @@ search: - [CLI](getting-started/cli/index.md) - [ASGI](getting-started/asgi.md) - [OpenTelemetry](getting-started/opentelemetry/index.md) + - [Prometheus](getting-started/prometheus/index.md) - [Logging](getting-started/logging.md) - [Config Management](getting-started/config/index.md) - [Task Scheduling](scheduling.md) diff --git a/docs/docs_src/getting_started/prometheus/__init__.py b/docs/docs_src/getting_started/prometheus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/docs_src/getting_started/prometheus/confluent.py b/docs/docs_src/getting_started/prometheus/confluent.py new file mode 100644 index 0000000000..2d89e8bee6 --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/confluent.py @@ -0,0 +1,13 @@ +from faststream import FastStream +from faststream.confluent import KafkaBroker +from faststream.confluent.prometheus import KafkaPrometheusMiddleware +from prometheus_client import CollectorRegistry + +registry = CollectorRegistry() + +broker = KafkaBroker( + middlewares=( + KafkaPrometheusMiddleware(registry=registry), + ) +) +app = FastStream(broker) diff --git a/docs/docs_src/getting_started/prometheus/confluent_asgi.py b/docs/docs_src/getting_started/prometheus/confluent_asgi.py new file mode 100644 index 0000000000..2574a49cef --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/confluent_asgi.py @@ -0,0 +1,18 @@ +from faststream.asgi import AsgiFastStream +from faststream.confluent import KafkaBroker +from faststream.confluent.prometheus import KafkaPrometheusMiddleware +from prometheus_client import CollectorRegistry, make_asgi_app + +registry = CollectorRegistry() + +broker = KafkaBroker( + middlewares=( + KafkaPrometheusMiddleware(registry=registry), + ) +) +app = AsgiFastStream( + broker, + asgi_routes=[ + ("/metrics", make_asgi_app(registry)), + ] +) diff --git a/docs/docs_src/getting_started/prometheus/kafka.py b/docs/docs_src/getting_started/prometheus/kafka.py new file mode 100644 index 0000000000..f6d1224e66 --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/kafka.py @@ -0,0 +1,13 @@ +from faststream import FastStream +from faststream.kafka import KafkaBroker +from faststream.kafka.prometheus import KafkaPrometheusMiddleware +from prometheus_client import CollectorRegistry + +registry = CollectorRegistry() + +broker = KafkaBroker( + middlewares=( + KafkaPrometheusMiddleware(registry=registry), + ) +) +app = FastStream(broker) diff --git a/docs/docs_src/getting_started/prometheus/kafka_asgi.py b/docs/docs_src/getting_started/prometheus/kafka_asgi.py new file mode 100644 index 0000000000..ddf79040d9 --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/kafka_asgi.py @@ -0,0 +1,18 @@ +from faststream.asgi import AsgiFastStream +from faststream.kafka import KafkaBroker +from faststream.kafka.prometheus import KafkaPrometheusMiddleware +from prometheus_client import CollectorRegistry, make_asgi_app + +registry = CollectorRegistry() + +broker = KafkaBroker( + middlewares=( + KafkaPrometheusMiddleware(registry=registry), + ) +) +app = AsgiFastStream( + broker, + asgi_routes=[ + ("/metrics", make_asgi_app(registry)), + ] +) diff --git a/docs/docs_src/getting_started/prometheus/nats.py b/docs/docs_src/getting_started/prometheus/nats.py new file mode 100644 index 0000000000..0894078881 --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/nats.py @@ -0,0 +1,13 @@ +from faststream import FastStream +from faststream.nats import NatsBroker +from faststream.nats.prometheus import NatsPrometheusMiddleware +from prometheus_client import CollectorRegistry + +registry = CollectorRegistry() + +broker = NatsBroker( + middlewares=( + NatsPrometheusMiddleware(registry=registry), + ) +) +app = FastStream(broker) diff --git a/docs/docs_src/getting_started/prometheus/nats_asgi.py b/docs/docs_src/getting_started/prometheus/nats_asgi.py new file mode 100644 index 0000000000..c273a36f36 --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/nats_asgi.py @@ -0,0 +1,18 @@ +from faststream.asgi import AsgiFastStream +from faststream.nats import NatsBroker +from faststream.nats.prometheus import NatsPrometheusMiddleware +from prometheus_client import CollectorRegistry, make_asgi_app + +registry = CollectorRegistry() + +broker = NatsBroker( + middlewares=( + NatsPrometheusMiddleware(registry=registry), + ) +) +app = AsgiFastStream( + broker, + asgi_routes=[ + ("/metrics", make_asgi_app(registry)), + ] +) diff --git a/docs/docs_src/getting_started/prometheus/rabbit.py b/docs/docs_src/getting_started/prometheus/rabbit.py new file mode 100644 index 0000000000..a0fb683b7f --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/rabbit.py @@ -0,0 +1,13 @@ +from faststream import FastStream +from faststream.rabbit import RabbitBroker +from faststream.rabbit.prometheus import RabbitPrometheusMiddleware +from prometheus_client import CollectorRegistry + +registry = CollectorRegistry() + +broker = RabbitBroker( + middlewares=( + RabbitPrometheusMiddleware(registry=registry), + ) +) +app = FastStream(broker) diff --git a/docs/docs_src/getting_started/prometheus/rabbit_asgi.py b/docs/docs_src/getting_started/prometheus/rabbit_asgi.py new file mode 100644 index 0000000000..40bc990fcc --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/rabbit_asgi.py @@ -0,0 +1,18 @@ +from faststream.asgi import AsgiFastStream +from faststream.rabbit import RabbitBroker +from faststream.rabbit.prometheus import RabbitPrometheusMiddleware +from prometheus_client import CollectorRegistry, make_asgi_app + +registry = CollectorRegistry() + +broker = RabbitBroker( + middlewares=( + RabbitPrometheusMiddleware(registry=registry), + ) +) +app = AsgiFastStream( + broker, + asgi_routes=[ + ("/metrics", make_asgi_app(registry)), + ] +) diff --git a/docs/docs_src/getting_started/prometheus/redis.py b/docs/docs_src/getting_started/prometheus/redis.py new file mode 100644 index 0000000000..98fc2b70c0 --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/redis.py @@ -0,0 +1,13 @@ +from faststream import FastStream +from faststream.redis import RedisBroker +from faststream.redis.prometheus import RedisPrometheusMiddleware +from prometheus_client import CollectorRegistry + +registry = CollectorRegistry() + +broker = RedisBroker( + middlewares=( + RedisPrometheusMiddleware(registry=registry), + ) +) +app = FastStream(broker) diff --git a/docs/docs_src/getting_started/prometheus/redis_asgi.py b/docs/docs_src/getting_started/prometheus/redis_asgi.py new file mode 100644 index 0000000000..2c3b095f1b --- /dev/null +++ b/docs/docs_src/getting_started/prometheus/redis_asgi.py @@ -0,0 +1,18 @@ +from faststream.asgi import AsgiFastStream +from faststream.redis import RedisBroker +from faststream.redis.prometheus import RedisPrometheusMiddleware +from prometheus_client import CollectorRegistry, make_asgi_app + +registry = CollectorRegistry() + +broker = RedisBroker( + middlewares=( + RedisPrometheusMiddleware(registry=registry), + ) +) +app = AsgiFastStream( + broker, + asgi_routes=[ + ("/metrics", make_asgi_app(registry)), + ] +) diff --git a/docs/includes/getting_started/prometheus/1.md b/docs/includes/getting_started/prometheus/1.md new file mode 100644 index 0000000000..16ad860ff8 --- /dev/null +++ b/docs/includes/getting_started/prometheus/1.md @@ -0,0 +1,24 @@ +=== "AIOKafka" + ```python linenums="1" hl_lines="6 10" + {!> docs_src/getting_started/prometheus/kafka.py!} + ``` + +=== "Confluent" + ```python linenums="1" hl_lines="6 10" + {!> docs_src/getting_started/prometheus/confluent.py!} + ``` + +=== "RabbitMQ" + ```python linenums="1" hl_lines="6 10" + {!> docs_src/getting_started/prometheus/rabbit.py!} + ``` + +=== "NATS" + ```python linenums="1" hl_lines="6 10" + {!> docs_src/getting_started/prometheus/nats.py!} + ``` + +=== "Redis" + ```python linenums="1" hl_lines="6 10" + {!> docs_src/getting_started/prometheus/redis.py!} + ``` diff --git a/docs/includes/getting_started/prometheus/2.md b/docs/includes/getting_started/prometheus/2.md new file mode 100644 index 0000000000..483e5437f4 --- /dev/null +++ b/docs/includes/getting_started/prometheus/2.md @@ -0,0 +1,24 @@ +=== "AIOKafka" + ```python linenums="1" hl_lines="6 10 13 16" + {!> docs_src/getting_started/prometheus/kafka_asgi.py!} + ``` + +=== "Confluent" + ```python linenums="1" hl_lines="6 10 13 16" + {!> docs_src/getting_started/prometheus/confluent_asgi.py!} + ``` + +=== "RabbitMQ" + ```python linenums="1" hl_lines="6 10 13 16" + {!> docs_src/getting_started/prometheus/rabbit_asgi.py!} + ``` + +=== "NATS" + ```python linenums="1" hl_lines="6 10 13 16" + {!> docs_src/getting_started/prometheus/nats_asgi.py!} + ``` + +=== "Redis" + ```python linenums="1" hl_lines="6 10 13 16" + {!> docs_src/getting_started/prometheus/redis_asgi.py!} + ``` diff --git a/faststream/__about__.py b/faststream/__about__.py index a829efe0e2..f350fa0af9 100644 --- a/faststream/__about__.py +++ b/faststream/__about__.py @@ -1,5 +1,5 @@ """Simple and fast framework to create message brokers based microservices.""" -__version__ = "0.5.27" +__version__ = "0.5.28" SERVICE_NAME = f"faststream-{__version__}" diff --git a/faststream/broker/message.py b/faststream/broker/message.py index 82593e7cdc..e06e912593 100644 --- a/faststream/broker/message.py +++ b/faststream/broker/message.py @@ -63,13 +63,16 @@ class StreamMessage(Generic[MsgType]): _decoded_body: Optional["DecodedMessage"] = field(default=None, init=False) async def ack(self) -> None: - self.committed = AckStatus.acked + if not self.committed: + self.committed = AckStatus.acked async def nack(self) -> None: - self.committed = AckStatus.nacked + if not self.committed: + self.committed = AckStatus.nacked async def reject(self) -> None: - self.committed = AckStatus.rejected + if not self.committed: + self.committed = AckStatus.rejected async def decode(self) -> Optional["DecodedMessage"]: """Serialize the message by lazy decoder.""" diff --git a/faststream/confluent/message.py b/faststream/confluent/message.py index 14fe05ae7b..83ee0e814b 100644 --- a/faststream/confluent/message.py +++ b/faststream/confluent/message.py @@ -66,7 +66,7 @@ async def ack(self) -> None: """Acknowledge the Kafka message.""" if self.is_manual and not self.committed: await self.consumer.commit() - await super().ack() + await super().ack() async def nack(self) -> None: """Reject the Kafka message.""" @@ -81,4 +81,4 @@ async def nack(self) -> None: partition=raw_message.partition(), offset=raw_message.offset(), ) - await super().nack() + await super().nack() diff --git a/faststream/confluent/prometheus/__init__.py b/faststream/confluent/prometheus/__init__.py new file mode 100644 index 0000000000..7498fa5ddc --- /dev/null +++ b/faststream/confluent/prometheus/__init__.py @@ -0,0 +1,3 @@ +from faststream.confluent.prometheus.middleware import KafkaPrometheusMiddleware + +__all__ = ("KafkaPrometheusMiddleware",) diff --git a/faststream/confluent/prometheus/middleware.py b/faststream/confluent/prometheus/middleware.py new file mode 100644 index 0000000000..2ac27dacea --- /dev/null +++ b/faststream/confluent/prometheus/middleware.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING, Optional, Sequence + +from faststream.confluent.prometheus.provider import settings_provider_factory +from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream.types import EMPTY + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + + +class KafkaPrometheusMiddleware(BasePrometheusMiddleware): + def __init__( + self, + *, + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ) -> None: + super().__init__( + settings_provider_factory=settings_provider_factory, + registry=registry, + app_name=app_name, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) diff --git a/faststream/confluent/prometheus/provider.py b/faststream/confluent/prometheus/provider.py new file mode 100644 index 0000000000..bdcb26728a --- /dev/null +++ b/faststream/confluent/prometheus/provider.py @@ -0,0 +1,64 @@ +from typing import TYPE_CHECKING, Sequence, Tuple, Union, cast + +from faststream.broker.message import MsgType, StreamMessage +from faststream.prometheus import ( + ConsumeAttrs, + MetricsSettingsProvider, +) + +if TYPE_CHECKING: + from confluent_kafka import Message + + from faststream.types import AnyDict + + +class BaseConfluentMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): + __slots__ = ("messaging_system",) + + def __init__(self) -> None: + self.messaging_system = "kafka" + + def get_publish_destination_name_from_kwargs( + self, + kwargs: "AnyDict", + ) -> str: + return cast(str, kwargs["topic"]) + + +class ConfluentMetricsSettingsProvider(BaseConfluentMetricsSettingsProvider["Message"]): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[Message]", + ) -> ConsumeAttrs: + return { + "destination_name": cast(str, msg.raw_message.topic()), + "message_size": len(msg.body), + "messages_count": 1, + } + + +class BatchConfluentMetricsSettingsProvider( + BaseConfluentMetricsSettingsProvider[Tuple["Message", ...]] +): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[Tuple[Message, ...]]", + ) -> ConsumeAttrs: + raw_message = msg.raw_message[0] + return { + "destination_name": cast(str, raw_message.topic()), + "message_size": len(bytearray().join(cast(Sequence[bytes], msg.body))), + "messages_count": len(msg.raw_message), + } + + +def settings_provider_factory( + msg: Union["Message", Sequence["Message"], None], +) -> Union[ + ConfluentMetricsSettingsProvider, + BatchConfluentMetricsSettingsProvider, +]: + if isinstance(msg, Sequence): + return BatchConfluentMetricsSettingsProvider() + else: + return ConfluentMetricsSettingsProvider() diff --git a/faststream/kafka/message.py b/faststream/kafka/message.py index d83a57bf6a..bde7669787 100644 --- a/faststream/kafka/message.py +++ b/faststream/kafka/message.py @@ -77,7 +77,7 @@ async def nack(self) -> None: partition=topic_partition, offset=raw_message.offset, ) - await super().nack() + await super().nack() class KafkaAckableMessage(KafkaMessage): @@ -85,4 +85,4 @@ async def ack(self) -> None: """Acknowledge the Kafka message.""" if not self.committed: await self.consumer.commit() - await super().ack() + await super().ack() diff --git a/faststream/kafka/prometheus/__init__.py b/faststream/kafka/prometheus/__init__.py new file mode 100644 index 0000000000..e5ae7e2d4f --- /dev/null +++ b/faststream/kafka/prometheus/__init__.py @@ -0,0 +1,3 @@ +from faststream.kafka.prometheus.middleware import KafkaPrometheusMiddleware + +__all__ = ("KafkaPrometheusMiddleware",) diff --git a/faststream/kafka/prometheus/middleware.py b/faststream/kafka/prometheus/middleware.py new file mode 100644 index 0000000000..3fd41edeba --- /dev/null +++ b/faststream/kafka/prometheus/middleware.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING, Optional, Sequence + +from faststream.kafka.prometheus.provider import settings_provider_factory +from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream.types import EMPTY + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + + +class KafkaPrometheusMiddleware(BasePrometheusMiddleware): + def __init__( + self, + *, + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ) -> None: + super().__init__( + settings_provider_factory=settings_provider_factory, + registry=registry, + app_name=app_name, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) diff --git a/faststream/kafka/prometheus/provider.py b/faststream/kafka/prometheus/provider.py new file mode 100644 index 0000000000..9caf118e1f --- /dev/null +++ b/faststream/kafka/prometheus/provider.py @@ -0,0 +1,64 @@ +from typing import TYPE_CHECKING, Sequence, Tuple, Union, cast + +from faststream.broker.message import MsgType, StreamMessage +from faststream.prometheus import ( + MetricsSettingsProvider, +) + +if TYPE_CHECKING: + from aiokafka import ConsumerRecord + + from faststream.prometheus import ConsumeAttrs + from faststream.types import AnyDict + + +class BaseKafkaMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): + __slots__ = ("messaging_system",) + + def __init__(self) -> None: + self.messaging_system = "kafka" + + def get_publish_destination_name_from_kwargs( + self, + kwargs: "AnyDict", + ) -> str: + return cast(str, kwargs["topic"]) + + +class KafkaMetricsSettingsProvider(BaseKafkaMetricsSettingsProvider["ConsumerRecord"]): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[ConsumerRecord]", + ) -> "ConsumeAttrs": + return { + "destination_name": msg.raw_message.topic, + "message_size": len(msg.body), + "messages_count": 1, + } + + +class BatchKafkaMetricsSettingsProvider( + BaseKafkaMetricsSettingsProvider[Tuple["ConsumerRecord", ...]] +): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[Tuple[ConsumerRecord, ...]]", + ) -> "ConsumeAttrs": + raw_message = msg.raw_message[0] + return { + "destination_name": raw_message.topic, + "message_size": len(bytearray().join(cast(Sequence[bytes], msg.body))), + "messages_count": len(msg.raw_message), + } + + +def settings_provider_factory( + msg: Union["ConsumerRecord", Sequence["ConsumerRecord"], None], +) -> Union[ + KafkaMetricsSettingsProvider, + BatchKafkaMetricsSettingsProvider, +]: + if isinstance(msg, Sequence): + return BatchKafkaMetricsSettingsProvider() + else: + return KafkaMetricsSettingsProvider() diff --git a/faststream/nats/message.py b/faststream/nats/message.py index ee54ef2caa..0f104a3310 100644 --- a/faststream/nats/message.py +++ b/faststream/nats/message.py @@ -15,7 +15,7 @@ async def ack(self) -> None: # to be compatible with `self.raw_message.ack()` if not self.raw_message._ackd: await self.raw_message.ack() - await super().ack() + await super().ack() async def nack( self, @@ -23,12 +23,12 @@ async def nack( ) -> None: if not self.raw_message._ackd: await self.raw_message.nak(delay=delay) - await super().nack() + await super().nack() async def reject(self) -> None: if not self.raw_message._ackd: await self.raw_message.term() - await super().reject() + await super().reject() async def in_progress(self) -> None: if not self.raw_message._ackd: diff --git a/faststream/nats/prometheus/__init__.py b/faststream/nats/prometheus/__init__.py new file mode 100644 index 0000000000..564d3ea4f4 --- /dev/null +++ b/faststream/nats/prometheus/__init__.py @@ -0,0 +1,3 @@ +from faststream.nats.prometheus.middleware import NatsPrometheusMiddleware + +__all__ = ("NatsPrometheusMiddleware",) diff --git a/faststream/nats/prometheus/middleware.py b/faststream/nats/prometheus/middleware.py new file mode 100644 index 0000000000..3aadeb61d1 --- /dev/null +++ b/faststream/nats/prometheus/middleware.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING, Optional, Sequence + +from faststream.nats.prometheus.provider import settings_provider_factory +from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream.types import EMPTY + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + + +class NatsPrometheusMiddleware(BasePrometheusMiddleware): + def __init__( + self, + *, + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ) -> None: + super().__init__( + settings_provider_factory=settings_provider_factory, + registry=registry, + app_name=app_name, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) diff --git a/faststream/nats/prometheus/provider.py b/faststream/nats/prometheus/provider.py new file mode 100644 index 0000000000..e6ac0a4684 --- /dev/null +++ b/faststream/nats/prometheus/provider.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING, List, Sequence, Union, cast + +from nats.aio.msg import Msg + +from faststream.broker.message import MsgType, StreamMessage +from faststream.prometheus import ( + ConsumeAttrs, + MetricsSettingsProvider, +) + +if TYPE_CHECKING: + from faststream.types import AnyDict + + +class BaseNatsMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): + __slots__ = ("messaging_system",) + + def __init__(self) -> None: + self.messaging_system = "nats" + + def get_publish_destination_name_from_kwargs( + self, + kwargs: "AnyDict", + ) -> str: + return cast(str, kwargs["subject"]) + + +class NatsMetricsSettingsProvider(BaseNatsMetricsSettingsProvider["Msg"]): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[Msg]", + ) -> ConsumeAttrs: + return { + "destination_name": msg.raw_message.subject, + "message_size": len(msg.body), + "messages_count": 1, + } + + +class BatchNatsMetricsSettingsProvider(BaseNatsMetricsSettingsProvider[List["Msg"]]): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[List[Msg]]", + ) -> ConsumeAttrs: + raw_message = msg.raw_message[0] + return { + "destination_name": raw_message.subject, + "message_size": len(msg.body), + "messages_count": len(msg.raw_message), + } + + +def settings_provider_factory( + msg: Union["Msg", Sequence["Msg"], None], +) -> Union[ + NatsMetricsSettingsProvider, + BatchNatsMetricsSettingsProvider, + None, +]: + if isinstance(msg, Sequence): + return BatchNatsMetricsSettingsProvider() + elif isinstance(msg, Msg) or msg is None: + return NatsMetricsSettingsProvider() + else: + # KeyValue and Object Storage watch cases + return None diff --git a/faststream/prometheus/__init__.py b/faststream/prometheus/__init__.py new file mode 100644 index 0000000000..8b21a09eee --- /dev/null +++ b/faststream/prometheus/__init__.py @@ -0,0 +1,9 @@ +from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream.prometheus.provider import MetricsSettingsProvider +from faststream.prometheus.types import ConsumeAttrs + +__all__ = ( + "BasePrometheusMiddleware", + "MetricsSettingsProvider", + "ConsumeAttrs", +) diff --git a/faststream/prometheus/consts.py b/faststream/prometheus/consts.py new file mode 100644 index 0000000000..3c4648d333 --- /dev/null +++ b/faststream/prometheus/consts.py @@ -0,0 +1,17 @@ +from faststream.broker.message import AckStatus +from faststream.exceptions import AckMessage, NackMessage, RejectMessage, SkipMessage +from faststream.prometheus.types import ProcessingStatus + +PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP = { + AckMessage: ProcessingStatus.acked, + NackMessage: ProcessingStatus.nacked, + RejectMessage: ProcessingStatus.rejected, + SkipMessage: ProcessingStatus.skipped, +} + + +PROCESSING_STATUS_BY_ACK_STATUS = { + AckStatus.acked: ProcessingStatus.acked, + AckStatus.nacked: ProcessingStatus.nacked, + AckStatus.rejected: ProcessingStatus.rejected, +} diff --git a/faststream/prometheus/container.py b/faststream/prometheus/container.py new file mode 100644 index 0000000000..6b5f813f63 --- /dev/null +++ b/faststream/prometheus/container.py @@ -0,0 +1,100 @@ +from typing import Optional, Sequence + +from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram + + +class MetricsContainer: + __slots__ = ( + "_registry", + "_metrics_prefix", + "received_messages_total", + "received_messages_size_bytes", + "received_processed_messages_duration_seconds", + "received_messages_in_process", + "received_processed_messages_total", + "received_processed_messages_exceptions_total", + "published_messages_total", + "published_messages_duration_seconds", + "published_messages_exceptions_total", + ) + + DEFAULT_SIZE_BUCKETS = ( + 2.0**4, + 2.0**6, + 2.0**8, + 2.0**10, + 2.0**12, + 2.0**14, + 2.0**16, + 2.0**18, + 2.0**20, + 2.0**22, + 2.0**24, + float("inf"), + ) + + def __init__( + self, + registry: "CollectorRegistry", + *, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ): + self._registry = registry + self._metrics_prefix = metrics_prefix + + self.received_messages_total = Counter( + name=f"{metrics_prefix}_received_messages_total", + documentation="Count of received messages by broker and handler", + labelnames=["app_name", "broker", "handler"], + registry=registry, + ) + self.received_messages_size_bytes = Histogram( + name=f"{metrics_prefix}_received_messages_size_bytes", + documentation="Histogram of received messages size in bytes by broker and handler", + labelnames=["app_name", "broker", "handler"], + registry=registry, + buckets=received_messages_size_buckets or self.DEFAULT_SIZE_BUCKETS, + ) + self.received_messages_in_process = Gauge( + name=f"{metrics_prefix}_received_messages_in_process", + documentation="Gauge of received messages in process by broker and handler", + labelnames=["app_name", "broker", "handler"], + registry=registry, + ) + self.received_processed_messages_total = Counter( + name=f"{metrics_prefix}_received_processed_messages_total", + documentation="Count of received processed messages by broker, handler and status", + labelnames=["app_name", "broker", "handler", "status"], + registry=registry, + ) + self.received_processed_messages_duration_seconds = Histogram( + name=f"{metrics_prefix}_received_processed_messages_duration_seconds", + documentation="Histogram of received processed messages duration in seconds by broker and handler", + labelnames=["app_name", "broker", "handler"], + registry=registry, + ) + self.received_processed_messages_exceptions_total = Counter( + name=f"{metrics_prefix}_received_processed_messages_exceptions_total", + documentation="Count of received processed messages exceptions by broker, handler and exception_type", + labelnames=["app_name", "broker", "handler", "exception_type"], + registry=registry, + ) + self.published_messages_total = Counter( + name=f"{metrics_prefix}_published_messages_total", + documentation="Count of published messages by destination and status", + labelnames=["app_name", "broker", "destination", "status"], + registry=registry, + ) + self.published_messages_duration_seconds = Histogram( + name=f"{metrics_prefix}_published_messages_duration_seconds", + documentation="Histogram of published messages duration in seconds by broker and destination", + labelnames=["app_name", "broker", "destination"], + registry=registry, + ) + self.published_messages_exceptions_total = Counter( + name=f"{metrics_prefix}_published_messages_exceptions_total", + documentation="Count of published messages exceptions by broker, destination and exception_type", + labelnames=["app_name", "broker", "destination", "exception_type"], + registry=registry, + ) diff --git a/faststream/prometheus/manager.py b/faststream/prometheus/manager.py new file mode 100644 index 0000000000..e2f7704f77 --- /dev/null +++ b/faststream/prometheus/manager.py @@ -0,0 +1,131 @@ +from faststream.prometheus.container import MetricsContainer +from faststream.prometheus.types import ProcessingStatus, PublishingStatus + + +class MetricsManager: + __slots__ = ("_container", "_app_name") + + def __init__(self, container: MetricsContainer, *, app_name: str = "faststream"): + self._container = container + self._app_name = app_name + + def add_received_message(self, broker: str, handler: str, amount: int = 1) -> None: + self._container.received_messages_total.labels( + app_name=self._app_name, + broker=broker, + handler=handler, + ).inc(amount) + + def observe_received_messages_size( + self, + broker: str, + handler: str, + size: int, + ) -> None: + self._container.received_messages_size_bytes.labels( + app_name=self._app_name, + broker=broker, + handler=handler, + ).observe(size) + + def add_received_message_in_process( + self, + broker: str, + handler: str, + amount: int = 1, + ) -> None: + self._container.received_messages_in_process.labels( + app_name=self._app_name, + broker=broker, + handler=handler, + ).inc(amount) + + def remove_received_message_in_process( + self, + broker: str, + handler: str, + amount: int = 1, + ) -> None: + self._container.received_messages_in_process.labels( + app_name=self._app_name, + broker=broker, + handler=handler, + ).dec(amount) + + def add_received_processed_message( + self, + broker: str, + handler: str, + status: ProcessingStatus, + amount: int = 1, + ) -> None: + self._container.received_processed_messages_total.labels( + app_name=self._app_name, + broker=broker, + handler=handler, + status=status.value, + ).inc(amount) + + def observe_received_processed_message_duration( + self, + duration: float, + broker: str, + handler: str, + ) -> None: + self._container.received_processed_messages_duration_seconds.labels( + app_name=self._app_name, + broker=broker, + handler=handler, + ).observe(duration) + + def add_received_processed_message_exception( + self, + broker: str, + handler: str, + exception_type: str, + ) -> None: + self._container.received_processed_messages_exceptions_total.labels( + app_name=self._app_name, + broker=broker, + handler=handler, + exception_type=exception_type, + ).inc() + + def add_published_message( + self, + broker: str, + destination: str, + status: PublishingStatus, + amount: int = 1, + ) -> None: + self._container.published_messages_total.labels( + app_name=self._app_name, + broker=broker, + destination=destination, + status=status.value, + ).inc(amount) + + def observe_published_message_duration( + self, + duration: float, + broker: str, + destination: str, + ) -> None: + self._container.published_messages_duration_seconds.labels( + app_name=self._app_name, + broker=broker, + destination=destination, + ).observe(duration) + + def add_published_message_exception( + self, + broker: str, + destination: str, + exception_type: str, + ) -> None: + self._container.published_messages_exceptions_total.labels( + app_name=self._app_name, + broker=broker, + destination=destination, + exception_type=exception_type, + ).inc() diff --git a/faststream/prometheus/middleware.py b/faststream/prometheus/middleware.py new file mode 100644 index 0000000000..575d846342 --- /dev/null +++ b/faststream/prometheus/middleware.py @@ -0,0 +1,202 @@ +import time +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence + +from faststream import BaseMiddleware +from faststream.prometheus.consts import ( + PROCESSING_STATUS_BY_ACK_STATUS, + PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP, +) +from faststream.prometheus.container import MetricsContainer +from faststream.prometheus.manager import MetricsManager +from faststream.prometheus.provider import MetricsSettingsProvider +from faststream.prometheus.types import ProcessingStatus, PublishingStatus +from faststream.types import EMPTY + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + + from faststream.broker.message import StreamMessage + from faststream.types import AsyncFunc, AsyncFuncAny + + +class PrometheusMiddleware(BaseMiddleware): + def __init__( + self, + msg: Optional[Any] = None, + *, + settings_provider_factory: Callable[ + [Any], Optional[MetricsSettingsProvider[Any]] + ], + metrics_manager: MetricsManager, + ) -> None: + self._metrics_manager = metrics_manager + self._settings_provider = settings_provider_factory(msg) + super().__init__(msg) + + async def consume_scope( + self, + call_next: "AsyncFuncAny", + msg: "StreamMessage[Any]", + ) -> Any: + if self._settings_provider is None: + return await call_next(msg) + + messaging_system = self._settings_provider.messaging_system + consume_attrs = self._settings_provider.get_consume_attrs_from_message(msg) + destination_name = consume_attrs["destination_name"] + + self._metrics_manager.add_received_message( + amount=consume_attrs["messages_count"], + broker=messaging_system, + handler=destination_name, + ) + + self._metrics_manager.observe_received_messages_size( + size=consume_attrs["message_size"], + broker=messaging_system, + handler=destination_name, + ) + + self._metrics_manager.add_received_message_in_process( + amount=consume_attrs["messages_count"], + broker=messaging_system, + handler=destination_name, + ) + + err: Optional[Exception] = None + start_time = time.perf_counter() + + try: + result = await call_next(await self.on_consume(msg)) + + except Exception as e: + err = e + self._metrics_manager.add_received_processed_message_exception( + exception_type=type(err).__name__, + broker=messaging_system, + handler=destination_name, + ) + raise + + finally: + duration = time.perf_counter() - start_time + self._metrics_manager.observe_received_processed_message_duration( + duration=duration, + broker=messaging_system, + handler=destination_name, + ) + + self._metrics_manager.remove_received_message_in_process( + amount=consume_attrs["messages_count"], + broker=messaging_system, + handler=destination_name, + ) + + status = ProcessingStatus.acked + + if msg.committed or err: + status = ( + PROCESSING_STATUS_BY_ACK_STATUS.get(msg.committed) # type: ignore[arg-type] + or PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP.get(type(err)) + or ProcessingStatus.error + ) + + self._metrics_manager.add_received_processed_message( + amount=consume_attrs["messages_count"], + status=status, + broker=messaging_system, + handler=destination_name, + ) + + return result + + async def publish_scope( + self, + call_next: "AsyncFunc", + msg: Any, + *args: Any, + **kwargs: Any, + ) -> Any: + if self._settings_provider is None: + return await call_next(msg, *args, **kwargs) + + destination_name = ( + self._settings_provider.get_publish_destination_name_from_kwargs(kwargs) + ) + messaging_system = self._settings_provider.messaging_system + + err: Optional[Exception] = None + start_time = time.perf_counter() + + try: + result = await call_next( + await self.on_publish(msg, *args, **kwargs), + *args, + **kwargs, + ) + + except Exception as e: + err = e + self._metrics_manager.add_published_message_exception( + exception_type=type(err).__name__, + broker=messaging_system, + destination=destination_name, + ) + raise + + finally: + duration = time.perf_counter() - start_time + + self._metrics_manager.observe_published_message_duration( + duration=duration, + broker=messaging_system, + destination=destination_name, + ) + + status = PublishingStatus.error if err else PublishingStatus.success + messages_count = len((msg, *args)) + + self._metrics_manager.add_published_message( + amount=messages_count, + status=status, + broker=messaging_system, + destination=destination_name, + ) + + return result + + +class BasePrometheusMiddleware: + __slots__ = ("_metrics_container", "_metrics_manager", "_settings_provider_factory") + + def __init__( + self, + *, + settings_provider_factory: Callable[ + [Any], Optional[MetricsSettingsProvider[Any]] + ], + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ): + if app_name is EMPTY: + app_name = metrics_prefix + + self._settings_provider_factory = settings_provider_factory + self._metrics_container = MetricsContainer( + registry, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) + self._metrics_manager = MetricsManager( + self._metrics_container, + app_name=app_name, + ) + + def __call__(self, msg: Optional[Any]) -> BaseMiddleware: + return PrometheusMiddleware( + msg=msg, + metrics_manager=self._metrics_manager, + settings_provider_factory=self._settings_provider_factory, + ) diff --git a/faststream/prometheus/provider.py b/faststream/prometheus/provider.py new file mode 100644 index 0000000000..1a543f5b55 --- /dev/null +++ b/faststream/prometheus/provider.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING, Protocol + +from faststream.broker.message import MsgType + +if TYPE_CHECKING: + from faststream.broker.message import StreamMessage + from faststream.prometheus import ConsumeAttrs + from faststream.types import AnyDict + + +class MetricsSettingsProvider(Protocol[MsgType]): + messaging_system: str + + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[MsgType]", + ) -> "ConsumeAttrs": ... + + def get_publish_destination_name_from_kwargs( + self, + kwargs: "AnyDict", + ) -> str: ... diff --git a/faststream/prometheus/types.py b/faststream/prometheus/types.py new file mode 100644 index 0000000000..ae6ffb7161 --- /dev/null +++ b/faststream/prometheus/types.py @@ -0,0 +1,21 @@ +from enum import Enum +from typing import TypedDict + + +class ProcessingStatus(str, Enum): + acked = "acked" + nacked = "nacked" + rejected = "rejected" + skipped = "skipped" + error = "error" + + +class PublishingStatus(str, Enum): + success = "success" + error = "error" + + +class ConsumeAttrs(TypedDict): + message_size: int + destination_name: str + messages_count: int diff --git a/faststream/rabbit/prometheus/__init__.py b/faststream/rabbit/prometheus/__init__.py new file mode 100644 index 0000000000..bdb07907ee --- /dev/null +++ b/faststream/rabbit/prometheus/__init__.py @@ -0,0 +1,3 @@ +from faststream.rabbit.prometheus.middleware import RabbitPrometheusMiddleware + +__all__ = ("RabbitPrometheusMiddleware",) diff --git a/faststream/rabbit/prometheus/middleware.py b/faststream/rabbit/prometheus/middleware.py new file mode 100644 index 0000000000..b2f96e45ca --- /dev/null +++ b/faststream/rabbit/prometheus/middleware.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING, Optional, Sequence + +from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream.rabbit.prometheus.provider import RabbitMetricsSettingsProvider +from faststream.types import EMPTY + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + + +class RabbitPrometheusMiddleware(BasePrometheusMiddleware): + def __init__( + self, + *, + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ) -> None: + super().__init__( + settings_provider_factory=lambda _: RabbitMetricsSettingsProvider(), + registry=registry, + app_name=app_name, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) diff --git a/faststream/rabbit/prometheus/provider.py b/faststream/rabbit/prometheus/provider.py new file mode 100644 index 0000000000..48c1bb2541 --- /dev/null +++ b/faststream/rabbit/prometheus/provider.py @@ -0,0 +1,44 @@ +from typing import TYPE_CHECKING, Union + +from faststream.prometheus import ( + ConsumeAttrs, + MetricsSettingsProvider, +) + +if TYPE_CHECKING: + from aio_pika import IncomingMessage + + from faststream.broker.message import StreamMessage + from faststream.rabbit.schemas.exchange import RabbitExchange + from faststream.types import AnyDict + + +class RabbitMetricsSettingsProvider(MetricsSettingsProvider["IncomingMessage"]): + __slots__ = ("messaging_system",) + + def __init__(self) -> None: + self.messaging_system = "rabbitmq" + + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[IncomingMessage]", + ) -> ConsumeAttrs: + exchange = msg.raw_message.exchange or "default" + routing_key = msg.raw_message.routing_key + + return { + "destination_name": f"{exchange}.{routing_key}", + "message_size": len(msg.body), + "messages_count": 1, + } + + def get_publish_destination_name_from_kwargs( + self, + kwargs: "AnyDict", + ) -> str: + exchange: Union[None, str, RabbitExchange] = kwargs.get("exchange") + exchange_prefix = getattr(exchange, "name", exchange or "default") + + routing_key: str = kwargs["routing_key"] + + return f"{exchange_prefix}.{routing_key}" diff --git a/faststream/redis/message.py b/faststream/redis/message.py index 8bce4005c8..86cc9b3d96 100644 --- a/faststream/redis/message.py +++ b/faststream/redis/message.py @@ -128,7 +128,7 @@ async def ack( ids = self.raw_message["message_ids"] channel = self.raw_message["channel"] await redis.xack(channel, group, *ids) # type: ignore[no-untyped-call] - await super().ack() + await super().ack() @override async def nack( diff --git a/faststream/redis/prometheus/__init__.py b/faststream/redis/prometheus/__init__.py new file mode 100644 index 0000000000..84c831aabb --- /dev/null +++ b/faststream/redis/prometheus/__init__.py @@ -0,0 +1,3 @@ +from faststream.redis.prometheus.middleware import RedisPrometheusMiddleware + +__all__ = ("RedisPrometheusMiddleware",) diff --git a/faststream/redis/prometheus/middleware.py b/faststream/redis/prometheus/middleware.py new file mode 100644 index 0000000000..1b157cb5a9 --- /dev/null +++ b/faststream/redis/prometheus/middleware.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING, Optional, Sequence + +from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream.redis.prometheus.provider import settings_provider_factory +from faststream.types import EMPTY + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + + +class RedisPrometheusMiddleware(BasePrometheusMiddleware): + def __init__( + self, + *, + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ) -> None: + super().__init__( + settings_provider_factory=settings_provider_factory, + registry=registry, + app_name=app_name, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) diff --git a/faststream/redis/prometheus/provider.py b/faststream/redis/prometheus/provider.py new file mode 100644 index 0000000000..51eb831669 --- /dev/null +++ b/faststream/redis/prometheus/provider.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING, Optional, Sized, Union, cast + +from faststream.prometheus import ( + ConsumeAttrs, + MetricsSettingsProvider, +) + +if TYPE_CHECKING: + from faststream.broker.message import StreamMessage + from faststream.types import AnyDict + + +class BaseRedisMetricsSettingsProvider(MetricsSettingsProvider["AnyDict"]): + __slots__ = ("messaging_system",) + + def __init__(self) -> None: + self.messaging_system = "redis" + + def get_publish_destination_name_from_kwargs( + self, + kwargs: "AnyDict", + ) -> str: + return self._get_destination(kwargs) + + @staticmethod + def _get_destination(kwargs: "AnyDict") -> str: + return kwargs.get("channel") or kwargs.get("list") or kwargs.get("stream") or "" + + +class RedisMetricsSettingsProvider(BaseRedisMetricsSettingsProvider): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[AnyDict]", + ) -> ConsumeAttrs: + return { + "destination_name": self._get_destination(msg.raw_message), + "message_size": len(msg.body), + "messages_count": 1, + } + + +class BatchRedisMetricsSettingsProvider(BaseRedisMetricsSettingsProvider): + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[AnyDict]", + ) -> ConsumeAttrs: + return { + "destination_name": self._get_destination(msg.raw_message), + "message_size": len(msg.body), + "messages_count": len(cast(Sized, msg._decoded_body)), + } + + +def settings_provider_factory( + msg: Optional["AnyDict"], +) -> Union[ + RedisMetricsSettingsProvider, + BatchRedisMetricsSettingsProvider, +]: + if msg is not None and msg.get("type", "").startswith("b"): + return BatchRedisMetricsSettingsProvider() + else: + return RedisMetricsSettingsProvider() diff --git a/pyproject.toml b/pyproject.toml index 350d53ed3e..7867cfc450 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,8 +83,10 @@ cli = [ "watchfiles>=0.15.0,<0.25.0" ] +prometheus = ["prometheus-client>=0.20.0,<0.30.0"] + # dev dependencies -optionals = ["faststream[rabbit,kafka,confluent,nats,redis,otel,cli]"] +optionals = ["faststream[rabbit,kafka,confluent,nats,redis,otel,cli,prometheus]"] devdocs = [ "mkdocs-material==9.5.40", diff --git a/tests/brokers/test_pushback.py b/tests/brokers/test_pushback.py index 9f18aa038b..ac56078cb0 100644 --- a/tests/brokers/test_pushback.py +++ b/tests/brokers/test_pushback.py @@ -12,7 +12,7 @@ @pytest.fixture def message(): - return AsyncMock(message_id=1) + return AsyncMock(message_id=1, committed=None) @pytest.mark.asyncio diff --git a/tests/prometheus/__init__.py b/tests/prometheus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/prometheus/basic.py b/tests/prometheus/basic.py new file mode 100644 index 0000000000..f2d9a5d6cf --- /dev/null +++ b/tests/prometheus/basic.py @@ -0,0 +1,204 @@ +import asyncio +from typing import Any, Optional, Type +from unittest.mock import ANY, Mock, call + +import pytest +from prometheus_client import CollectorRegistry + +from faststream import Context +from faststream.broker.message import AckStatus +from faststream.exceptions import RejectMessage +from faststream.prometheus.middleware import ( + PROCESSING_STATUS_BY_ACK_STATUS, + PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP, +) +from faststream.prometheus.types import ProcessingStatus +from tests.brokers.base.basic import BaseTestcaseConfig + + +@pytest.mark.asyncio +class LocalPrometheusTestcase(BaseTestcaseConfig): + def get_broker(self, apply_types=False, **kwargs): + raise NotImplementedError + + def get_middleware(self, **kwargs): + raise NotImplementedError + + @staticmethod + def consume_destination_name(queue: str) -> str: + return queue + + @property + def settings_provider_factory(self): + return self.get_middleware( + registry=CollectorRegistry() + )._settings_provider_factory + + @pytest.mark.parametrize( + ( + "status", + "exception_class", + ), + [ + pytest.param( + AckStatus.acked, + RejectMessage, + id="acked status with reject message exception", + ), + pytest.param( + AckStatus.acked, Exception, id="acked status with not handler exception" + ), + pytest.param(AckStatus.acked, None, id="acked status without exception"), + pytest.param(AckStatus.nacked, None, id="nacked status without exception"), + pytest.param( + AckStatus.rejected, None, id="rejected status without exception" + ), + ], + ) + async def test_metrics( + self, + event: asyncio.Event, + queue: str, + status: AckStatus, + exception_class: Optional[Type[Exception]], + ): + middleware = self.get_middleware(registry=CollectorRegistry()) + metrics_manager_mock = Mock() + middleware._metrics_manager = metrics_manager_mock + + broker = self.get_broker(apply_types=True, middlewares=(middleware,)) + + args, kwargs = self.get_subscriber_params(queue) + + message = None + + @broker.subscriber(*args, **kwargs) + async def handler(m=Context("message")): + event.set() + + nonlocal message + message = m + + if exception_class: + raise exception_class + + if status == AckStatus.acked: + await message.ack() + elif status == AckStatus.nacked: + await message.nack() + elif status == AckStatus.rejected: + await message.reject() + + async with broker: + await broker.start() + tasks = ( + asyncio.create_task(broker.publish("hello", queue)), + asyncio.create_task(event.wait()), + ) + await asyncio.wait(tasks, timeout=self.timeout) + + assert event.is_set() + self.assert_consume_metrics( + metrics_manager=metrics_manager_mock, + message=message, + exception_class=exception_class, + ) + self.assert_publish_metrics(metrics_manager=metrics_manager_mock) + + def assert_consume_metrics( + self, + *, + metrics_manager: Any, + message: Any, + exception_class: Optional[Type[Exception]], + ): + settings_provider = self.settings_provider_factory(message.raw_message) + consume_attrs = settings_provider.get_consume_attrs_from_message(message) + assert metrics_manager.add_received_message.mock_calls == [ + call( + amount=consume_attrs["messages_count"], + broker=settings_provider.messaging_system, + handler=consume_attrs["destination_name"], + ), + ] + + assert metrics_manager.observe_received_messages_size.mock_calls == [ + call( + size=consume_attrs["message_size"], + broker=settings_provider.messaging_system, + handler=consume_attrs["destination_name"], + ), + ] + + assert metrics_manager.add_received_message_in_process.mock_calls == [ + call( + amount=consume_attrs["messages_count"], + broker=settings_provider.messaging_system, + handler=consume_attrs["destination_name"], + ), + ] + assert metrics_manager.remove_received_message_in_process.mock_calls == [ + call( + amount=consume_attrs["messages_count"], + broker=settings_provider.messaging_system, + handler=consume_attrs["destination_name"], + ) + ] + + assert ( + metrics_manager.observe_received_processed_message_duration.mock_calls + == [ + call( + duration=ANY, + broker=settings_provider.messaging_system, + handler=consume_attrs["destination_name"], + ), + ] + ) + + status = ProcessingStatus.acked + + if exception_class: + status = ( + PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP.get(exception_class) + or ProcessingStatus.error + ) + elif message.committed: + status = PROCESSING_STATUS_BY_ACK_STATUS[message.committed] + + assert metrics_manager.add_received_processed_message.mock_calls == [ + call( + amount=consume_attrs["messages_count"], + broker=settings_provider.messaging_system, + handler=consume_attrs["destination_name"], + status=status.value, + ), + ] + + if status == ProcessingStatus.error: + assert ( + metrics_manager.add_received_processed_message_exception.mock_calls + == [ + call( + broker=settings_provider.messaging_system, + handler=consume_attrs["destination_name"], + exception_type=exception_class.__name__, + ), + ] + ) + + def assert_publish_metrics(self, metrics_manager: Any): + settings_provider = self.settings_provider_factory(None) + assert metrics_manager.observe_published_message_duration.mock_calls == [ + call( + duration=ANY, broker=settings_provider.messaging_system, destination=ANY + ), + ] + assert metrics_manager.add_published_message.mock_calls == [ + call( + amount=ANY, + broker=settings_provider.messaging_system, + destination=ANY, + status="success", + ), + ] diff --git a/tests/prometheus/confluent/__init__.py b/tests/prometheus/confluent/__init__.py new file mode 100644 index 0000000000..c4a1803708 --- /dev/null +++ b/tests/prometheus/confluent/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("confluent_kafka") diff --git a/tests/prometheus/confluent/test_confluent.py b/tests/prometheus/confluent/test_confluent.py new file mode 100644 index 0000000000..d1e3034ad6 --- /dev/null +++ b/tests/prometheus/confluent/test_confluent.py @@ -0,0 +1,79 @@ +import asyncio +from unittest.mock import Mock + +import pytest +from prometheus_client import CollectorRegistry + +from faststream import Context +from faststream.confluent import KafkaBroker +from faststream.confluent.prometheus.middleware import KafkaPrometheusMiddleware +from tests.brokers.confluent.basic import ConfluentTestcaseConfig +from tests.brokers.confluent.test_consume import TestConsume +from tests.brokers.confluent.test_publish import TestPublish +from tests.prometheus.basic import LocalPrometheusTestcase + + +@pytest.mark.confluent +class TestPrometheus(ConfluentTestcaseConfig, LocalPrometheusTestcase): + def get_broker(self, apply_types=False, **kwargs): + return KafkaBroker(apply_types=apply_types, **kwargs) + + def get_middleware(self, **kwargs): + return KafkaPrometheusMiddleware(**kwargs) + + async def test_metrics_batch( + self, + event: asyncio.Event, + queue: str, + ): + middleware = self.get_middleware(registry=CollectorRegistry()) + metrics_manager_mock = Mock() + middleware._metrics_manager = metrics_manager_mock + + broker = self.get_broker(apply_types=True, middlewares=(middleware,)) + + args, kwargs = self.get_subscriber_params(queue, batch=True) + message = None + + @broker.subscriber(*args, **kwargs) + async def handler(m=Context("message")): + event.set() + + nonlocal message + message = m + + async with broker: + await broker.start() + tasks = ( + asyncio.create_task( + broker.publish_batch("hello", "world", topic=queue) + ), + asyncio.create_task(event.wait()), + ) + await asyncio.wait(tasks, timeout=self.timeout) + + assert event.is_set() + self.assert_consume_metrics( + metrics_manager=metrics_manager_mock, message=message, exception_class=None + ) + self.assert_publish_metrics(metrics_manager=metrics_manager_mock) + + +@pytest.mark.confluent +class TestPublishWithPrometheus(TestPublish): + def get_broker(self, apply_types: bool = False, **kwargs): + return KafkaBroker( + middlewares=(KafkaPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) + + +@pytest.mark.confluent +class TestConsumeWithPrometheus(TestConsume): + def get_broker(self, apply_types: bool = False, **kwargs): + return KafkaBroker( + middlewares=(KafkaPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) diff --git a/tests/prometheus/kafka/__init__.py b/tests/prometheus/kafka/__init__.py new file mode 100644 index 0000000000..bd6bc708fc --- /dev/null +++ b/tests/prometheus/kafka/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aiokafka") diff --git a/tests/prometheus/kafka/test_kafka.py b/tests/prometheus/kafka/test_kafka.py new file mode 100644 index 0000000000..abb5c86b3f --- /dev/null +++ b/tests/prometheus/kafka/test_kafka.py @@ -0,0 +1,82 @@ +import asyncio +from unittest.mock import Mock + +import pytest +from prometheus_client import CollectorRegistry + +from faststream import Context +from faststream.kafka import KafkaBroker +from faststream.kafka.prometheus.middleware import KafkaPrometheusMiddleware +from tests.brokers.kafka.test_consume import TestConsume +from tests.brokers.kafka.test_publish import TestPublish +from tests.prometheus.basic import LocalPrometheusTestcase + + +@pytest.mark.kafka +class TestPrometheus(LocalPrometheusTestcase): + def get_broker(self, apply_types=False, **kwargs): + return KafkaBroker(apply_types=apply_types, **kwargs) + + def get_middleware(self, **kwargs): + return KafkaPrometheusMiddleware(**kwargs) + + async def test_metrics_batch( + self, + event: asyncio.Event, + queue: str, + ): + middleware = self.get_middleware(registry=CollectorRegistry()) + metrics_manager_mock = Mock() + middleware._metrics_manager = metrics_manager_mock + + broker = self.get_broker(apply_types=True, middlewares=(middleware,)) + + args, kwargs = self.get_subscriber_params(queue, batch=True) + message = None + + @broker.subscriber(*args, **kwargs) + async def handler(m=Context("message")): + event.set() + + nonlocal message + message = m + + async with broker: + await broker.start() + tasks = ( + asyncio.create_task( + broker.publish_batch("hello", "world", topic=queue) + ), + asyncio.create_task(event.wait()), + ) + await asyncio.wait(tasks, timeout=self.timeout) + + assert event.is_set() + self.assert_consume_metrics( + metrics_manager=metrics_manager_mock, message=message, exception_class=None + ) + self.assert_publish_metrics(metrics_manager=metrics_manager_mock) + + +@pytest.mark.kafka +class TestPublishWithPrometheus(TestPublish): + def get_broker( + self, + apply_types: bool = False, + **kwargs, + ): + return KafkaBroker( + middlewares=(KafkaPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) + + +@pytest.mark.kafka +class TestConsumeWithPrometheus(TestConsume): + def get_broker(self, apply_types: bool = False, **kwargs): + return KafkaBroker( + middlewares=(KafkaPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) diff --git a/tests/prometheus/nats/__init__.py b/tests/prometheus/nats/__init__.py new file mode 100644 index 0000000000..87ead90ee6 --- /dev/null +++ b/tests/prometheus/nats/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("nats") diff --git a/tests/prometheus/nats/test_nats.py b/tests/prometheus/nats/test_nats.py new file mode 100644 index 0000000000..f65eb41e85 --- /dev/null +++ b/tests/prometheus/nats/test_nats.py @@ -0,0 +1,86 @@ +import asyncio +from unittest.mock import Mock + +import pytest +from prometheus_client import CollectorRegistry + +from faststream import Context +from faststream.nats import JStream, NatsBroker, PullSub +from faststream.nats.prometheus.middleware import NatsPrometheusMiddleware +from tests.brokers.nats.test_consume import TestConsume +from tests.brokers.nats.test_publish import TestPublish +from tests.prometheus.basic import LocalPrometheusTestcase + + +@pytest.fixture +def stream(queue): + return JStream(queue) + + +@pytest.mark.nats +class TestPrometheus(LocalPrometheusTestcase): + def get_broker(self, apply_types=False, **kwargs): + return NatsBroker(apply_types=apply_types, **kwargs) + + def get_middleware(self, **kwargs): + return NatsPrometheusMiddleware(**kwargs) + + async def test_metrics_batch( + self, + event: asyncio.Event, + queue: str, + stream: JStream, + ): + middleware = self.get_middleware(registry=CollectorRegistry()) + metrics_manager_mock = Mock() + middleware._metrics_manager = metrics_manager_mock + + broker = self.get_broker(apply_types=True, middlewares=(middleware,)) + + args, kwargs = self.get_subscriber_params( + queue, + stream=stream, + pull_sub=PullSub(1, batch=True, timeout=self.timeout), + ) + message = None + + @broker.subscriber(*args, **kwargs) + async def handler(m=Context("message")): + event.set() + + nonlocal message + message = m + + async with broker: + await broker.start() + tasks = ( + asyncio.create_task(broker.publish("hello", queue)), + asyncio.create_task(event.wait()), + ) + await asyncio.wait(tasks, timeout=self.timeout) + + assert event.is_set() + self.assert_consume_metrics( + metrics_manager=metrics_manager_mock, message=message, exception_class=None + ) + self.assert_publish_metrics(metrics_manager=metrics_manager_mock) + + +@pytest.mark.nats +class TestPublishWithPrometheus(TestPublish): + def get_broker(self, apply_types: bool = False, **kwargs): + return NatsBroker( + middlewares=(NatsPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) + + +@pytest.mark.nats +class TestConsumeWithPrometheus(TestConsume): + def get_broker(self, apply_types: bool = False, **kwargs): + return NatsBroker( + middlewares=(NatsPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) diff --git a/tests/prometheus/rabbit/__init__.py b/tests/prometheus/rabbit/__init__.py new file mode 100644 index 0000000000..ebec43fcd5 --- /dev/null +++ b/tests/prometheus/rabbit/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aio_pika") diff --git a/tests/prometheus/rabbit/test_rabbit.py b/tests/prometheus/rabbit/test_rabbit.py new file mode 100644 index 0000000000..6eef6d224f --- /dev/null +++ b/tests/prometheus/rabbit/test_rabbit.py @@ -0,0 +1,42 @@ +import pytest +from prometheus_client import CollectorRegistry + +from faststream.rabbit import RabbitBroker, RabbitExchange +from faststream.rabbit.prometheus.middleware import RabbitPrometheusMiddleware +from tests.brokers.rabbit.test_consume import TestConsume +from tests.brokers.rabbit.test_publish import TestPublish +from tests.prometheus.basic import LocalPrometheusTestcase + + +@pytest.fixture +def exchange(queue): + return RabbitExchange(name=queue) + + +@pytest.mark.rabbit +class TestPrometheus(LocalPrometheusTestcase): + def get_broker(self, apply_types=False, **kwargs): + return RabbitBroker(apply_types=apply_types, **kwargs) + + def get_middleware(self, **kwargs): + return RabbitPrometheusMiddleware(**kwargs) + + +@pytest.mark.rabbit +class TestPublishWithPrometheus(TestPublish): + def get_broker(self, apply_types: bool = False, **kwargs): + return RabbitBroker( + middlewares=(RabbitPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) + + +@pytest.mark.rabbit +class TestConsumeWithPrometheus(TestConsume): + def get_broker(self, apply_types: bool = False, **kwargs): + return RabbitBroker( + middlewares=(RabbitPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) diff --git a/tests/prometheus/redis/__init__.py b/tests/prometheus/redis/__init__.py new file mode 100644 index 0000000000..4752ef19b1 --- /dev/null +++ b/tests/prometheus/redis/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("redis") diff --git a/tests/prometheus/redis/test_redis.py b/tests/prometheus/redis/test_redis.py new file mode 100644 index 0000000000..4059c33d48 --- /dev/null +++ b/tests/prometheus/redis/test_redis.py @@ -0,0 +1,77 @@ +import asyncio +from unittest.mock import Mock + +import pytest +from prometheus_client import CollectorRegistry + +from faststream import Context +from faststream.redis import ListSub, RedisBroker +from faststream.redis.prometheus.middleware import RedisPrometheusMiddleware +from tests.brokers.redis.test_consume import TestConsume +from tests.brokers.redis.test_publish import TestPublish +from tests.prometheus.basic import LocalPrometheusTestcase + + +@pytest.mark.redis +class TestPrometheus(LocalPrometheusTestcase): + def get_broker(self, apply_types=False, **kwargs): + return RedisBroker(apply_types=apply_types, **kwargs) + + def get_middleware(self, **kwargs): + return RedisPrometheusMiddleware(**kwargs) + + async def test_metrics_batch( + self, + event: asyncio.Event, + queue: str, + ): + middleware = self.get_middleware(registry=CollectorRegistry()) + metrics_manager_mock = Mock() + middleware._metrics_manager = metrics_manager_mock + + broker = self.get_broker(apply_types=True, middlewares=(middleware,)) + + args, kwargs = self.get_subscriber_params(list=ListSub(queue, batch=True)) + + message = None + + @broker.subscriber(*args, **kwargs) + async def handler(m=Context("message")): + event.set() + + nonlocal message + message = m + + async with broker: + await broker.start() + tasks = ( + asyncio.create_task(broker.publish_batch("hello", "world", list=queue)), + asyncio.create_task(event.wait()), + ) + await asyncio.wait(tasks, timeout=self.timeout) + + assert event.is_set() + self.assert_consume_metrics( + metrics_manager=metrics_manager_mock, message=message, exception_class=None + ) + self.assert_publish_metrics(metrics_manager=metrics_manager_mock) + + +@pytest.mark.redis +class TestPublishWithPrometheus(TestPublish): + def get_broker(self, apply_types: bool = False, **kwargs): + return RedisBroker( + middlewares=(RedisPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) + + +@pytest.mark.redis +class TestConsumeWithPrometheus(TestConsume): + def get_broker(self, apply_types: bool = False, **kwargs): + return RedisBroker( + middlewares=(RedisPrometheusMiddleware(registry=CollectorRegistry()),), + apply_types=apply_types, + **kwargs, + ) diff --git a/tests/prometheus/test_metrics.py b/tests/prometheus/test_metrics.py new file mode 100644 index 0000000000..7f9aa85771 --- /dev/null +++ b/tests/prometheus/test_metrics.py @@ -0,0 +1,644 @@ +import random +from typing import List, Optional +from unittest.mock import ANY + +import pytest +from dirty_equals import IsPositiveFloat, IsStr +from prometheus_client import CollectorRegistry, Histogram, Metric +from prometheus_client.samples import Sample + +from faststream.prometheus.container import MetricsContainer +from faststream.prometheus.manager import MetricsManager +from faststream.prometheus.types import ProcessingStatus, PublishingStatus + + +class TestCaseMetrics: + @staticmethod + def create_metrics_manager( + app_name: Optional[str] = None, + metrics_prefix: Optional[str] = None, + received_messages_size_buckets: Optional[List[float]] = None, + ) -> MetricsManager: + registry = CollectorRegistry() + container = MetricsContainer( + registry, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) + return MetricsManager(container, app_name=app_name) + + @pytest.fixture + def app_name(self, request) -> str: + return "youtube" + + @pytest.fixture + def metrics_prefix(self, request) -> str: + return "fs" + + @pytest.fixture + def broker(self) -> str: + return "rabbit" + + @pytest.fixture + def queue(self) -> str: + return "default.test" + + @pytest.fixture + def messages_amount(self) -> int: + return random.randint(1, 10) + + @pytest.fixture + def exception_type(self) -> str: + return Exception.__name__ + + def test_add_received_message( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + messages_amount: int, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + expected = Metric( + name=f"{metrics_prefix}_received_messages", + documentation="Count of received messages by broker and handler", + unit="", + typ="counter", + ) + expected.samples = [ + Sample( + name=f"{metrics_prefix}_received_messages_total", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=float(messages_amount), + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_received_messages_created", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=IsPositiveFloat, + timestamp=None, + exemplar=None, + ), + ] + + manager.add_received_message( + amount=messages_amount, broker=broker, handler=queue + ) + + metric_values = manager._container.received_messages_total.collect() + + assert metric_values == [expected] + + @pytest.mark.parametrize( + "is_default_buckets", + [ + pytest.param(True, id="with default buckets"), + pytest.param(False, id="with custom buckets"), + ], + ) + def test_observe_received_messages_size( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + is_default_buckets: bool, + ) -> None: + manager_kwargs = { + "app_name": app_name, + "metrics_prefix": metrics_prefix, + } + + custom_buckets = [1.0, 2.0, 3.0, float("inf")] + + if not is_default_buckets: + manager_kwargs["received_messages_size_buckets"] = custom_buckets + + manager = self.create_metrics_manager(**manager_kwargs) + + size = 1 + buckets = ( + MetricsContainer.DEFAULT_SIZE_BUCKETS + if is_default_buckets + else custom_buckets + ) + + expected = Metric( + name=f"{metrics_prefix}_received_messages_size_bytes", + documentation="Histogram of received messages size in bytes by broker and handler", + unit="", + typ="histogram", + ) + expected.samples = [ + *[ + Sample( + name=f"{metrics_prefix}_received_messages_size_bytes_bucket", + labels={ + "app_name": app_name, + "broker": broker, + "handler": queue, + "le": IsStr, + }, + value=1.0, + timestamp=None, + exemplar=None, + ) + for _ in buckets + ], + Sample( + name=f"{metrics_prefix}_received_messages_size_bytes_count", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=1.0, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_received_messages_size_bytes_sum", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=size, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_received_messages_size_bytes_created", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=ANY, + timestamp=None, + exemplar=None, + ), + ] + + manager.observe_received_messages_size(size=size, broker=broker, handler=queue) + + metric_values = manager._container.received_messages_size_bytes.collect() + + assert metric_values == [expected] + + def test_add_received_message_in_process( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + messages_amount: int, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + expected = Metric( + name=f"{metrics_prefix}_received_messages_in_process", + documentation="Gauge of received messages in process by broker and handler", + unit="", + typ="gauge", + ) + expected.samples = [ + Sample( + name=f"{metrics_prefix}_received_messages_in_process", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=float(messages_amount), + timestamp=None, + exemplar=None, + ), + ] + + manager.add_received_message_in_process( + amount=messages_amount, broker=broker, handler=queue + ) + + metric_values = manager._container.received_messages_in_process.collect() + + assert metric_values == [expected] + + def test_remove_received_message_in_process( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + messages_amount: int, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + expected = Metric( + name=f"{metrics_prefix}_received_messages_in_process", + documentation="Gauge of received messages in process by broker and handler", + unit="", + typ="gauge", + ) + expected.samples = [ + Sample( + name=f"{metrics_prefix}_received_messages_in_process", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=float(messages_amount - 1), + timestamp=None, + exemplar=None, + ), + ] + + manager.add_received_message_in_process( + amount=messages_amount, broker=broker, handler=queue + ) + manager.remove_received_message_in_process( + amount=1, broker=broker, handler=queue + ) + + metric_values = manager._container.received_messages_in_process.collect() + + assert metric_values == [expected] + + @pytest.mark.parametrize( + "status", + [ + pytest.param(ProcessingStatus.acked, id="acked status"), + pytest.param(ProcessingStatus.nacked, id="nacked status"), + pytest.param(ProcessingStatus.rejected, id="rejected status"), + pytest.param(ProcessingStatus.skipped, id="skipped status"), + pytest.param(ProcessingStatus.error, id="error status"), + ], + ) + def test_add_received_processed_message( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + messages_amount: int, + status: ProcessingStatus, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + expected = Metric( + name=f"{metrics_prefix}_received_processed_messages", + documentation="Count of received processed messages by broker, handler and status", + unit="", + typ="counter", + ) + expected.samples = [ + Sample( + name=f"{metrics_prefix}_received_processed_messages_total", + labels={ + "app_name": app_name, + "broker": broker, + "handler": queue, + "status": status.value, + }, + value=float(messages_amount), + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_received_processed_messages_created", + labels={ + "app_name": app_name, + "broker": broker, + "handler": queue, + "status": status.value, + }, + value=IsPositiveFloat, + timestamp=None, + exemplar=None, + ), + ] + + manager.add_received_processed_message( + amount=messages_amount, + status=status, + broker=broker, + handler=queue, + ) + + metric_values = manager._container.received_processed_messages_total.collect() + + assert metric_values == [expected] + + def test_observe_received_processed_message_duration( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + duration = 0.001 + + expected = Metric( + name=f"{metrics_prefix}_received_processed_messages_duration_seconds", + documentation="Histogram of received processed messages duration in seconds by broker and handler", + unit="", + typ="histogram", + ) + expected.samples = [ + *[ + Sample( + name=f"{metrics_prefix}_received_processed_messages_duration_seconds_bucket", + labels={ + "app_name": app_name, + "broker": broker, + "handler": queue, + "le": IsStr, + }, + value=1.0, + timestamp=None, + exemplar=None, + ) + for _ in Histogram.DEFAULT_BUCKETS + ], + Sample( + name=f"{metrics_prefix}_received_processed_messages_duration_seconds_count", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=1.0, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_received_processed_messages_duration_seconds_sum", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=duration, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_received_processed_messages_duration_seconds_created", + labels={"app_name": app_name, "broker": broker, "handler": queue}, + value=ANY, + timestamp=None, + exemplar=None, + ), + ] + + manager.observe_received_processed_message_duration( + duration=duration, + broker=broker, + handler=queue, + ) + + metric_values = ( + manager._container.received_processed_messages_duration_seconds.collect() + ) + + assert metric_values == [expected] + + def test_add_received_processed_message_exception( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + exception_type: str, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + expected = Metric( + name=f"{metrics_prefix}_received_processed_messages_exceptions", + documentation="Count of received processed messages exceptions by broker, handler and exception_type", + unit="", + typ="counter", + ) + expected.samples = [ + Sample( + name=f"{metrics_prefix}_received_processed_messages_exceptions_total", + labels={ + "app_name": app_name, + "broker": broker, + "handler": queue, + "exception_type": exception_type, + }, + value=1.0, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_received_processed_messages_exceptions_created", + labels={ + "app_name": app_name, + "broker": broker, + "handler": queue, + "exception_type": exception_type, + }, + value=IsPositiveFloat, + timestamp=None, + exemplar=None, + ), + ] + + manager.add_received_processed_message_exception( + exception_type=exception_type, + broker=broker, + handler=queue, + ) + + metric_values = ( + manager._container.received_processed_messages_exceptions_total.collect() + ) + + assert metric_values == [expected] + + @pytest.mark.parametrize( + "status", + [ + pytest.param(PublishingStatus.success, id="success status"), + pytest.param(PublishingStatus.error, id="error status"), + ], + ) + def test_add_published_message( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + messages_amount: int, + status: PublishingStatus, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + expected = Metric( + name=f"{metrics_prefix}_published_messages", + documentation="Count of published messages by destination and status", + unit="", + typ="counter", + ) + expected.samples = [ + Sample( + name=f"{metrics_prefix}_published_messages_total", + labels={ + "app_name": app_name, + "broker": broker, + "destination": queue, + "status": status.value, + }, + value=1.0, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_published_messages_created", + labels={ + "app_name": app_name, + "broker": broker, + "destination": queue, + "status": status.value, + }, + value=IsPositiveFloat, + timestamp=None, + exemplar=None, + ), + ] + + manager.add_published_message( + status=status, + broker=broker, + destination=queue, + ) + + metric_values = manager._container.published_messages_total.collect() + + assert metric_values == [expected] + + def test_observe_published_message_duration( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + duration = 0.001 + + expected = Metric( + name=f"{metrics_prefix}_published_messages_duration_seconds", + documentation="Histogram of published messages duration in seconds by broker and destination", + unit="", + typ="histogram", + ) + expected.samples = [ + *[ + Sample( + name=f"{metrics_prefix}_published_messages_duration_seconds_bucket", + labels={ + "app_name": app_name, + "broker": broker, + "destination": queue, + "le": IsStr, + }, + value=1.0, + timestamp=None, + exemplar=None, + ) + for _ in Histogram.DEFAULT_BUCKETS + ], + Sample( + name=f"{metrics_prefix}_published_messages_duration_seconds_count", + labels={"app_name": app_name, "broker": broker, "destination": queue}, + value=1.0, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_published_messages_duration_seconds_sum", + labels={"app_name": app_name, "broker": broker, "destination": queue}, + value=duration, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_published_messages_duration_seconds_created", + labels={"app_name": app_name, "broker": broker, "destination": queue}, + value=IsPositiveFloat, + timestamp=None, + exemplar=None, + ), + ] + + manager.observe_published_message_duration( + duration=duration, + broker=broker, + destination=queue, + ) + + metric_values = manager._container.published_messages_duration_seconds.collect() + + assert metric_values == [expected] + + def test_add_published_message_exception( + self, + app_name: str, + metrics_prefix: str, + queue: str, + broker: str, + exception_type: str, + ) -> None: + manager = self.create_metrics_manager( + app_name=app_name, + metrics_prefix=metrics_prefix, + ) + + expected = Metric( + name=f"{metrics_prefix}_published_messages_exceptions", + documentation="Count of published messages exceptions by broker, destination and exception_type", + unit="", + typ="counter", + ) + expected.samples = [ + Sample( + name=f"{metrics_prefix}_published_messages_exceptions_total", + labels={ + "app_name": app_name, + "broker": broker, + "destination": queue, + "exception_type": exception_type, + }, + value=1.0, + timestamp=None, + exemplar=None, + ), + Sample( + name=f"{metrics_prefix}_published_messages_exceptions_created", + labels={ + "app_name": app_name, + "broker": broker, + "destination": queue, + "exception_type": exception_type, + }, + value=IsPositiveFloat, + timestamp=None, + exemplar=None, + ), + ] + + manager.add_published_message_exception( + exception_type=exception_type, + broker=broker, + destination=queue, + ) + + metric_values = manager._container.published_messages_exceptions_total.collect() + + assert metric_values == [expected] From 7c5cc6cb0586ed49e92c34f3bd577a0b457fbea2 Mon Sep 17 00:00:00 2001 From: sheldy <85823514+sheldygg@users.noreply.github.com> Date: Sun, 20 Oct 2024 20:16:40 +0200 Subject: [PATCH 14/48] Add in-progress tutorial to how-to section (#1859) * Add in-progress tutorial to how-to section * Update pre-commit config * chore: fix precommit --------- Co-authored-by: Nikita Pastukhov --- .pre-commit-config.yaml | 3 -- docs/docs/SUMMARY.md | 1 + docs/docs/en/howto/nats/in-progress.md | 39 ++++++++++++++++++++++++++ docs/docs/navigation_template.txt | 1 + 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 docs/docs/en/howto/nats/in-progress.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2852886c6..e4cde3a9c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,6 @@ repos: stages: [pre-commit, pre-merge-commit, manual] entry: "scripts/lint-pre-commit.sh" language: python - language_version: python3.10 types: [python] require_serial: true verbose: true @@ -49,7 +48,6 @@ repos: name: Static analysis entry: "scripts/static-pre-commit.sh" language: python - language_version: python3.10 types: [python] require_serial: true verbose: true @@ -60,7 +58,6 @@ repos: name: Build docs entry: "scripts/build-docs-pre-commit.sh" language: python - language_version: python3.10 files: ^docs require_serial: true verbose: true diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index 86ff1025b7..c220597872 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -98,6 +98,7 @@ search: - [Message Information](nats/message.md) - [How-To](howto/nats/index.md) - [DynaConf](howto/nats/dynaconf.md) + - [In-Progess](howto/nats/in-progress.md) - [Redis](redis/index.md) - [Pub/Sub](redis/pubsub/index.md) - [Subscription](redis/pubsub/subscription.md) diff --git a/docs/docs/en/howto/nats/in-progress.md b/docs/docs/en/howto/nats/in-progress.md new file mode 100644 index 0000000000..82a402ad0b --- /dev/null +++ b/docs/docs/en/howto/nats/in-progress.md @@ -0,0 +1,39 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 10 +--- + +# In-Progress sender + +Nats Jetstream uses the at least once principle, so the message will be delivered until it receives the ACK status (even if your handler takes a long time to process the message), so you can extend the message processing status with a request + +??? example "Full Example" + ```python linenums="1" + import asyncio + + from faststream import Depends, FastStream + from faststream.nats import NatsBroker, NatsMessage + + broker = NatsBroker() + app = FastStream(broker) + + async def progress_sender(message: NatsMessage): + async def in_progress_task(): + while True: + await asyncio.sleep(10.0) + await message.in_progress() + + task = asyncio.create_task(in_progress_task()) + yield + task.cancel() + + @broker.subscriber("test", dependencies=[Depends(progress_sender)]) + async def handler(): + await asyncio.sleep(20.0) + + ``` diff --git a/docs/docs/navigation_template.txt b/docs/docs/navigation_template.txt index e505d5a783..1a1420d7ca 100644 --- a/docs/docs/navigation_template.txt +++ b/docs/docs/navigation_template.txt @@ -98,6 +98,7 @@ search: - [Message Information](nats/message.md) - [How-To](howto/nats/index.md) - [DynaConf](howto/nats/dynaconf.md) + - [In-Progess](howto/nats/in-progress.md) - [Redis](redis/index.md) - [Pub/Sub](redis/pubsub/index.md) - [Subscription](redis/pubsub/subscription.md) From 3beae6475681ebeb55489d636a14c753bfae0822 Mon Sep 17 00:00:00 2001 From: treaditup <97654121+draincoder@users.noreply.github.com> Date: Sun, 20 Oct 2024 21:57:48 +0300 Subject: [PATCH 15/48] docs: add info about grafana dashboard (#1863) --- docs/docs/assets/img/grafana-dashboard.png | Bin 0 -> 433076 bytes docs/docs/assets/img/import-dashboard.png | Bin 0 -> 77236 bytes .../en/getting-started/opentelemetry/index.md | 2 ++ .../en/getting-started/prometheus/index.md | 14 ++++++++++++++ 4 files changed, 16 insertions(+) create mode 100644 docs/docs/assets/img/grafana-dashboard.png create mode 100644 docs/docs/assets/img/import-dashboard.png diff --git a/docs/docs/assets/img/grafana-dashboard.png b/docs/docs/assets/img/grafana-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..c424ea8b668ebc259ec60a36ab5b8be012877db8 GIT binary patch literal 433076 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

7w*8zjrOWu-q4VSauE$| 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 rD-R6tnlS|nTCtvNuFN( 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

-QAuY7Z| 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%0DxI`33T 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

ttcPrhUcZ1(wyw7vrasRku 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�KEMGKav_g3zS`WnpCL4&(ZBlf{VY*7kIM{Fvu8 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�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 literal 0 HcmV?d00001 diff --git a/docs/docs/assets/img/import-dashboard.png b/docs/docs/assets/img/import-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..1731ece99e4650525047775899ef84f6866c4894 GIT binary patch literal 77236 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 z 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

vUr+<0_`?_qm0Qr%O?KU2N; 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 literal 0 HcmV?d00001 diff --git a/docs/docs/en/getting-started/opentelemetry/index.md b/docs/docs/en/getting-started/opentelemetry/index.md index 3a4c163f96..dc8fab945e 100644 --- a/docs/docs/en/getting-started/opentelemetry/index.md +++ b/docs/docs/en/getting-started/opentelemetry/index.md @@ -122,6 +122,8 @@ An example includes: * Three `FastStream` services * Exporting traces to `Grafana Tempo` via `gRPC` * Visualization of traces via `Grafana` +* Collecting metrics and exporting using `Prometheus` +* `Grafana dashboard` for metrics * Examples with custom spans * Configured `docker-compose` with the entire infrastructure diff --git a/docs/docs/en/getting-started/prometheus/index.md b/docs/docs/en/getting-started/prometheus/index.md index 54203ce7df..d7d2f26568 100644 --- a/docs/docs/en/getting-started/prometheus/index.md +++ b/docs/docs/en/getting-started/prometheus/index.md @@ -80,3 +80,17 @@ passing in the registry that was passed to `PrometheusMiddleware`. | status (while publishing) | Message publishing status | `success`, `error` | | destination | Where the message is sent | | | exception_type (while publishing) | Exception type when publishing message | | + +### Grafana dashboard + +You can import the [**Grafana dashboard**](https://grafana.com/grafana/dashboards/22130-faststream-metrics/){.external-link target="_blank"} to visualize the metrics collected by middleware. + +Enter the dashboard **URL** `https://grafana.com/grafana/dashboards/22130-faststream-metrics/` (or just the **ID**, `22130`), and click on **Load**. + +![HTML-page](../../../assets/img/import-dashboard.png){ .on-glb loading=lazy } +`Import dashboard` + +An [example](https://github.com/draincoder/faststream-monitoring){.external-link target="_blank"} application with configured **metrics**, **Prometheus** and **Grafana**. + +![HTML-page](../../../assets/img/grafana-dashboard.png){ .on-glb loading=lazy } +`Grafana dashboard` From 4c7895cb160fa69826b8164b7f5a44fdab352e8d Mon Sep 17 00:00:00 2001 From: "airt-release-notes-updater[bot]" <153718812+airt-release-notes-updater[bot]@users.noreply.github.com> Date: Sun, 20 Oct 2024 23:22:59 +0300 Subject: [PATCH 16/48] Update Release Notes for 0.5.28 (#1864) * Update Release Notes for 0.5.28 * chore: fix precommit --------- Co-authored-by: Lancetnik <44573917+Lancetnik@users.noreply.github.com> Co-authored-by: Nikita Pastukhov --- .secrets.baseline | 4 +-- docs/docs/en/release.md | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 7964330ac3..2c0f438938 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -153,7 +153,7 @@ "filename": "docs/docs/en/release.md", "hashed_secret": "35675e68f4b5af7b995d9205ad0fc43842f16450", "is_verified": false, - "line_number": 1756, + "line_number": 1812, "is_secret": false } ], @@ -178,5 +178,5 @@ } ] }, - "generated_at": "2024-10-15T04:48:28Z" + "generated_at": "2024-10-20T20:04:20Z" } diff --git a/docs/docs/en/release.md b/docs/docs/en/release.md index a6e1c4855c..cab058065b 100644 --- a/docs/docs/en/release.md +++ b/docs/docs/en/release.md @@ -12,6 +12,62 @@ hide: --- # Release Notes +## 0.5.28 + +### What's Changed + +There were a lot of time since [**0.5.7 OpenTelemetry** release](https://github.com/airtai/faststream/releases/tag/0.5.7) and now we completed **Observability** features we planned! **FastStream** supports **Prometheus** metrics in a native way! + +Special thanks to @roma-frolov and @draincoder (again) for it! + +To collect **Prometheus** metrics for your **FastStream** application you just need to install special distribution + +```cmd +pip install faststream[prometheus] +``` + +And use **PrometheusMiddleware**. Also, it could be helpful to use our [**ASGI**](https://faststream.airt.ai/latest/getting-started/asgi/) to serve metrics endpoint in the same app. + +```python +from prometheus_client import CollectorRegistry, make_asgi_app +from faststream.asgi import AsgiFastStream +from faststream.nats import NatsBroker +from faststream.nats.prometheus import NatsPrometheusMiddleware + +registry = CollectorRegistry() + +broker = NatsBroker( + middlewares=( + NatsPrometheusMiddleware(registry=registry), + ) +) + +app = AsgiFastStream( + broker, + asgi_routes=[ + ("/metrics", make_asgi_app(registry)), + ] +) +``` + +Moreover, we have a ready-to-use [**Grafana** dashboard](https://grafana.com/grafana/dashboards/22130-faststream-metrics/) you can just import and use! + +To find more information about **Prometheus** support, just visit [our documentation](https://faststream.airt.ai/latest/getting-started/prometheus/). + +### All changes + +* docs: Correct minimum FastAPI version for lifespan handling by @tim-hutchinson in https://github.com/airtai/faststream/pull/1853 +* add aiogram example by @IvanKirpichnikov in https://github.com/airtai/faststream/pull/1858 +* Feature: Prometheus Middleware by @roma-frolov in https://github.com/airtai/faststream/pull/1791 +* Add in-progress tutorial to how-to section by @sheldygg in https://github.com/airtai/faststream/pull/1859 +* docs: Add info about Grafana dashboard by @draincoder in https://github.com/airtai/faststream/pull/1863 + +### New Contributors + +* @tim-hutchinson made their first contribution in https://github.com/airtai/faststream/pull/1853 + +**Full Changelog**: https://github.com/airtai/faststream/compare/0.5.27...0.5.28 + ## 0.5.27 ### What's Changed From e165f0388c6fd380fca734b5b1e580165e97e818 Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Mon, 21 Oct 2024 07:44:37 +0300 Subject: [PATCH 17/48] feat: add explicit message source enum (#1866) * feat: add explicit message source enum * docs: generate API References * docs: generate API References --------- Co-authored-by: Lancetnik --- docs/docs/SUMMARY.md | 1 + .../en/api/faststream/broker/message/SourceType.md | 11 +++++++++++ faststream/broker/core/usecase.py | 2 ++ faststream/broker/message.py | 9 +++++++++ faststream/confluent/publisher/usecase.py | 3 ++- faststream/kafka/publisher/usecase.py | 3 ++- faststream/nats/publisher/usecase.py | 3 ++- faststream/rabbit/publisher/usecase.py | 3 ++- faststream/redis/publisher/usecase.py | 5 ++++- 9 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 docs/docs/en/api/faststream/broker/message/SourceType.md diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index c220597872..8f63974667 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -376,6 +376,7 @@ search: - [StreamRouter](api/faststream/broker/fastapi/router/StreamRouter.md) - message - [AckStatus](api/faststream/broker/message/AckStatus.md) + - [SourceType](api/faststream/broker/message/SourceType.md) - [StreamMessage](api/faststream/broker/message/StreamMessage.md) - [decode_message](api/faststream/broker/message/decode_message.md) - [encode_message](api/faststream/broker/message/encode_message.md) diff --git a/docs/docs/en/api/faststream/broker/message/SourceType.md b/docs/docs/en/api/faststream/broker/message/SourceType.md new file mode 100644 index 0000000000..fd242902f9 --- /dev/null +++ b/docs/docs/en/api/faststream/broker/message/SourceType.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.broker.message.SourceType diff --git a/faststream/broker/core/usecase.py b/faststream/broker/core/usecase.py index 7069dd2652..7bcf35f708 100644 --- a/faststream/broker/core/usecase.py +++ b/faststream/broker/core/usecase.py @@ -20,6 +20,7 @@ from faststream._compat import is_test_env from faststream.broker.core.logging import LoggingBroker +from faststream.broker.message import SourceType from faststream.broker.middlewares.logging import CriticalLogMiddleware from faststream.broker.proto import SetupAble from faststream.broker.subscriber.proto import SubscriberProto @@ -376,6 +377,7 @@ async def request( parsed_msg: StreamMessage[Any] = await producer._parser(published_msg) parsed_msg._decoded_body = await producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) @abstractmethod diff --git a/faststream/broker/message.py b/faststream/broker/message.py index e06e912593..7c1dcae73a 100644 --- a/faststream/broker/message.py +++ b/faststream/broker/message.py @@ -35,6 +35,14 @@ class AckStatus(str, Enum): rejected = "rejected" +class SourceType(str, Enum): + Consume = "Consume" + """Message consumed by basic subscriber flow.""" + + Response = "Response" + """RPC response consumed.""" + + def gen_cor_id() -> str: """Generate random string to use as ID.""" return str(uuid4()) @@ -60,6 +68,7 @@ class StreamMessage(Generic[MsgType]): processed: bool = field(default=False, init=False) committed: Optional[AckStatus] = field(default=None, init=False) + _source_type: SourceType = field(default=SourceType.Consume) _decoded_body: Optional["DecodedMessage"] = field(default=None, init=False) async def ack(self) -> None: diff --git a/faststream/confluent/publisher/usecase.py b/faststream/confluent/publisher/usecase.py index 60fc8329df..0f7712139f 100644 --- a/faststream/confluent/publisher/usecase.py +++ b/faststream/confluent/publisher/usecase.py @@ -17,7 +17,7 @@ from confluent_kafka import Message from typing_extensions import override -from faststream.broker.message import gen_cor_id +from faststream.broker.message import SourceType, gen_cor_id from faststream.broker.publisher.usecase import PublisherUsecase from faststream.broker.types import MsgType from faststream.exceptions import NOT_CONNECTED_YET @@ -124,6 +124,7 @@ async def request( parsed_msg = await self._producer._parser(published_msg) parsed_msg._decoded_body = await self._producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) raise AssertionError("unreachable") diff --git a/faststream/kafka/publisher/usecase.py b/faststream/kafka/publisher/usecase.py index 709aea898b..aa95525254 100644 --- a/faststream/kafka/publisher/usecase.py +++ b/faststream/kafka/publisher/usecase.py @@ -17,7 +17,7 @@ from aiokafka import ConsumerRecord from typing_extensions import Annotated, Doc, override -from faststream.broker.message import gen_cor_id +from faststream.broker.message import SourceType, gen_cor_id from faststream.broker.publisher.usecase import PublisherUsecase from faststream.broker.types import MsgType from faststream.exceptions import NOT_CONNECTED_YET @@ -177,6 +177,7 @@ async def request( parsed_msg = await self._producer._parser(published_msg) parsed_msg._decoded_body = await self._producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) raise AssertionError("unreachable") diff --git a/faststream/nats/publisher/usecase.py b/faststream/nats/publisher/usecase.py index 83f9a7c0e4..8d74bbbe4b 100644 --- a/faststream/nats/publisher/usecase.py +++ b/faststream/nats/publisher/usecase.py @@ -15,7 +15,7 @@ from nats.aio.msg import Msg from typing_extensions import Annotated, Doc, override -from faststream.broker.message import gen_cor_id +from faststream.broker.message import SourceType, gen_cor_id from faststream.broker.publisher.usecase import PublisherUsecase from faststream.exceptions import NOT_CONNECTED_YET from faststream.utils.functions import return_input @@ -212,6 +212,7 @@ async def request( parsed_msg = await self._producer._parser(published_msg) parsed_msg._decoded_body = await self._producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) raise AssertionError("unreachable") diff --git a/faststream/rabbit/publisher/usecase.py b/faststream/rabbit/publisher/usecase.py index f03b3b4a72..041991542a 100644 --- a/faststream/rabbit/publisher/usecase.py +++ b/faststream/rabbit/publisher/usecase.py @@ -15,7 +15,7 @@ from aio_pika import IncomingMessage from typing_extensions import Annotated, Doc, TypedDict, Unpack, deprecated, override -from faststream.broker.message import gen_cor_id +from faststream.broker.message import SourceType, gen_cor_id from faststream.broker.publisher.usecase import PublisherUsecase from faststream.exceptions import NOT_CONNECTED_YET from faststream.rabbit.schemas import BaseRMQInformation, RabbitQueue @@ -373,6 +373,7 @@ async def request( parsed_msg = await self._producer._parser(published_msg) parsed_msg._decoded_body = await self._producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) raise AssertionError("unreachable") diff --git a/faststream/redis/publisher/usecase.py b/faststream/redis/publisher/usecase.py index cc9a523439..f517dbee5f 100644 --- a/faststream/redis/publisher/usecase.py +++ b/faststream/redis/publisher/usecase.py @@ -7,7 +7,7 @@ from typing_extensions import Annotated, Doc, deprecated, override -from faststream.broker.message import gen_cor_id +from faststream.broker.message import SourceType, gen_cor_id from faststream.broker.publisher.usecase import PublisherUsecase from faststream.exceptions import NOT_CONNECTED_YET from faststream.redis.message import UnifyRedisDict @@ -268,6 +268,7 @@ async def request( parsed_msg = await self._producer._parser(published_msg) parsed_msg._decoded_body = await self._producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) raise AssertionError("unreachable") @@ -481,6 +482,7 @@ async def request( parsed_msg = await self._producer._parser(published_msg) parsed_msg._decoded_body = await self._producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) raise AssertionError("unreachable") @@ -762,6 +764,7 @@ async def request( parsed_msg = await self._producer._parser(published_msg) parsed_msg._decoded_body = await self._producer._decoder(parsed_msg) + parsed_msg._source_type = SourceType.Response return await return_msg(parsed_msg) raise AssertionError("unreachable") From 9a0356ed4ab605afee8a4d6db1940b556b681a3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 12:15:13 +0000 Subject: [PATCH 18/48] chore(deps): bump the pip group with 6 updates (#1868) Bumps the pip group with 6 updates: | Package | From | To | | --- | --- | --- | | [mkdocs-material](https://github.com/squidfunk/mkdocs-material) | `9.5.40` | `9.5.42` | | [mkdocs-macros-plugin](https://github.com/fralau/mkdocs_macros_plugin) | `1.3.5` | `1.3.6` | | [mypy](https://github.com/python/mypy) | `1.12.0` | `1.12.1` | | [ruff](https://github.com/astral-sh/ruff) | `0.6.9` | `0.7.0` | | [semgrep](https://github.com/returntocorp/semgrep) | `1.91.0` | `1.92.0` | | [coverage[toml]](https://github.com/nedbat/coveragepy) | `7.6.1` | `7.6.4` | Updates `mkdocs-material` from 9.5.40 to 9.5.42 - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.40...9.5.42) Updates `mkdocs-macros-plugin` from 1.3.5 to 1.3.6 - [Release notes](https://github.com/fralau/mkdocs_macros_plugin/releases) - [Changelog](https://github.com/fralau/mkdocs-macros-plugin/blob/master/CHANGELOG.md) - [Commits](https://github.com/fralau/mkdocs_macros_plugin/compare/v1.3.5...v1.3.6) Updates `mypy` from 1.12.0 to 1.12.1 - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.12.0...v1.12.1) Updates `ruff` from 0.6.9 to 0.7.0 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.9...0.7.0) Updates `semgrep` from 1.91.0 to 1.92.0 - [Release notes](https://github.com/returntocorp/semgrep/releases) - [Changelog](https://github.com/semgrep/semgrep/blob/develop/CHANGELOG.md) - [Commits](https://github.com/returntocorp/semgrep/compare/v1.91.0...v1.92.0) Updates `coverage[toml]` from 7.6.1 to 7.6.4 - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.6.1...7.6.4) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: mkdocs-macros-plugin dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: mypy dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor dependency-group: pip - dependency-name: semgrep dependency-type: direct:production update-type: version-update:semver-minor dependency-group: pip - dependency-name: coverage[toml] dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7867cfc450..8962231b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ prometheus = ["prometheus-client>=0.20.0,<0.30.0"] optionals = ["faststream[rabbit,kafka,confluent,nats,redis,otel,cli,prometheus]"] devdocs = [ - "mkdocs-material==9.5.40", + "mkdocs-material==9.5.42", "mkdocs-static-i18n==1.2.3", "mdx-include==1.4.2", "mkdocstrings[python]==0.26.2", @@ -97,7 +97,7 @@ devdocs = [ "mkdocs-git-revision-date-localized-plugin==1.2.9", "mike==2.1.3", # versioning "mkdocs-minify-plugin==0.8.0", - "mkdocs-macros-plugin==1.3.5", # includes with variables + "mkdocs-macros-plugin==1.3.6", # includes with variables "mkdocs-glightbox==0.4.0", # img zoom "pillow", # required for mkdocs-glightbo "cairosvg", # required for mkdocs-glightbo @@ -106,7 +106,7 @@ devdocs = [ types = [ "faststream[optionals]", - "mypy==1.12.0", + "mypy==1.12.1", # mypy extensions "types-Deprecated", "types-PyYAML", @@ -121,15 +121,15 @@ types = [ lint = [ "faststream[types]", - "ruff==0.6.9", + "ruff==0.7.0", "bandit==1.7.10", - "semgrep==1.91.0", + "semgrep==1.92.0", "codespell==2.3.0", ] test-core = [ "coverage[toml]==7.6.1; python_version == '3.8'", - "coverage[toml]==7.6.3; python_version >= '3.9'", + "coverage[toml]==7.6.4; python_version >= '3.9'", "pytest==8.3.3", "pytest-asyncio==0.24.0", "dirty-equals==0.8.0", From a6ebb056b6cc360ef8230d26dbdfedda6b661671 Mon Sep 17 00:00:00 2001 From: Pavel Epanov <125682660+pavelepanov@users.noreply.github.com> Date: Thu, 24 Oct 2024 00:55:18 +0500 Subject: [PATCH 19/48] Change uv manuall installation to setup-uv in CI (#1871) * Change uv manuall installation to setup-uv in CI * Delete pip install uv from all jobs * Change setup-uv action's version from 1 to 3 * Add pre-commit's fixing --------- Co-authored-by: Pavel Epanov --- .github/workflows/docs_deploy.yaml | 4 +- .github/workflows/docs_update-references.yaml | 4 +- .../workflows/docs_update-release-notes.yaml | 5 +- .github/workflows/pr_tests.yaml | 57 ++++++++++++++----- .github/workflows/publish_coverage.yaml | 4 +- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/.github/workflows/docs_deploy.yaml b/.github/workflows/docs_deploy.yaml index 4c5e6a43f9..6f7046a6c6 100644 --- a/.github/workflows/docs_deploy.yaml +++ b/.github/workflows/docs_deploy.yaml @@ -19,6 +19,9 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - uses: actions/setup-python@v5 with: python-version: 3.x @@ -28,7 +31,6 @@ jobs: path: .cache - run: | set -ux - python -m pip install uv uv pip install --system -e .[dev] uv pip uninstall --system email-validator # This is to fix broken link in docs - run: ./scripts/build-docs.sh diff --git a/.github/workflows/docs_update-references.yaml b/.github/workflows/docs_update-references.yaml index e83c97a363..2b2bc2732d 100644 --- a/.github/workflows/docs_update-references.yaml +++ b/.github/workflows/docs_update-references.yaml @@ -25,6 +25,9 @@ jobs: with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.head_ref }} + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -35,7 +38,6 @@ jobs: # should install with `-e` run: | set -ux - python -m pip install uv uv pip install --system -e .[dev] - name: Run build docs run: bash scripts/build-docs.sh diff --git a/.github/workflows/docs_update-release-notes.yaml b/.github/workflows/docs_update-release-notes.yaml index b9344fa1cc..9192d23e98 100644 --- a/.github/workflows/docs_update-release-notes.yaml +++ b/.github/workflows/docs_update-release-notes.yaml @@ -20,7 +20,9 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Configure Git user run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" @@ -39,7 +41,6 @@ jobs: - name: Install dependencies run: | - python -m pip install uv uv pip install --system requests - name: Run update_releases.py script diff --git a/.github/workflows/pr_tests.yaml b/.github/workflows/pr_tests.yaml index 5e7259a6fb..e9282d496c 100644 --- a/.github/workflows/pr_tests.yaml +++ b/.github/workflows/pr_tests.yaml @@ -61,6 +61,9 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -73,7 +76,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - name: Install Pydantic v1 if: matrix.pydantic-version == 'pydantic-v1' @@ -102,6 +104,9 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -109,7 +114,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - name: Test run: > @@ -121,6 +125,9 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -128,7 +135,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - name: Test run: > @@ -140,6 +146,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -147,7 +156,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[kafka,test-core,cli] - name: Test run: > @@ -179,6 +187,9 @@ jobs: ALLOW_PLAINTEXT_LISTENER: "true" steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -186,7 +197,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - run: mkdir coverage - name: Test @@ -209,6 +219,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -216,7 +229,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[confluent,test-core,cli] - name: Test run: > @@ -248,6 +260,9 @@ jobs: ALLOW_PLAINTEXT_LISTENER: "true" steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -255,7 +270,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - run: mkdir coverage - name: Test @@ -278,6 +292,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -285,7 +302,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[rabbit,test-core,cli] - name: Test run: > @@ -306,6 +322,9 @@ jobs: - 5672:5672 steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -313,7 +332,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - run: mkdir coverage - name: Test @@ -336,6 +354,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -343,7 +364,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[nats,test-core,cli] - name: Test run: > @@ -364,6 +384,9 @@ jobs: - 4222:4222 steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -371,7 +394,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - run: mkdir coverage - name: Test @@ -394,6 +416,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -401,7 +426,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[redis,test-core,cli] - name: Test run: > @@ -422,6 +446,9 @@ jobs: - 6379:6379 steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - name: Set up Python uses: actions/setup-python@v5 with: @@ -429,7 +456,6 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - python -m pip install uv uv pip install --system .[optionals,testing] - run: mkdir coverage - name: Test @@ -460,7 +486,9 @@ jobs: steps: - uses: actions/checkout@v4 - + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - uses: actions/setup-python@v5 with: python-version: "3.8" @@ -473,7 +501,6 @@ jobs: merge-multiple: true - run: | - python -m pip install uv uv pip install --system coverage[toml] - run: ls -la coverage diff --git a/.github/workflows/publish_coverage.yaml b/.github/workflows/publish_coverage.yaml index 766d5c9c94..755b0cf9ce 100644 --- a/.github/workflows/publish_coverage.yaml +++ b/.github/workflows/publish_coverage.yaml @@ -16,9 +16,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.9" + - uses: astral-sh/setup-uv@v3 + with: + version: "latest" - run: | - python -m pip install uv uv pip install --system smokeshow - uses: dawidd6/action-download-artifact@v6 # nosemgrep From 0d6c9ee6b7703efab04387c51c72876e25ad91a7 Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Fri, 25 Oct 2024 20:10:08 +0300 Subject: [PATCH 20/48] refactor: make Task and Concurrent mixins broker-agnostic (#1873) * refactor: make Task and Concurrent mixins broker-agnostic * docs: generate API References * chore: fix precommit * lint: ignore nats-py error --------- Co-authored-by: Lancetnik --- docs/docs/SUMMARY.md | 3 + .../subscriber/mixins/ConcurrentMixin.md | 11 ++ .../broker/subscriber/mixins/TasksMixin.md | 11 ++ faststream/broker/subscriber/mixins.py | 81 ++++++++++++++ faststream/nats/subscriber/usecase.py | 103 ++++-------------- 5 files changed, 125 insertions(+), 84 deletions(-) create mode 100644 docs/docs/en/api/faststream/broker/subscriber/mixins/ConcurrentMixin.md create mode 100644 docs/docs/en/api/faststream/broker/subscriber/mixins/TasksMixin.md create mode 100644 faststream/broker/subscriber/mixins.py diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index 8f63974667..0c51c2d6a0 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -416,6 +416,9 @@ search: - subscriber - call_item - [HandlerItem](api/faststream/broker/subscriber/call_item/HandlerItem.md) + - mixins + - [ConcurrentMixin](api/faststream/broker/subscriber/mixins/ConcurrentMixin.md) + - [TasksMixin](api/faststream/broker/subscriber/mixins/TasksMixin.md) - proto - [SubscriberProto](api/faststream/broker/subscriber/proto/SubscriberProto.md) - usecase diff --git a/docs/docs/en/api/faststream/broker/subscriber/mixins/ConcurrentMixin.md b/docs/docs/en/api/faststream/broker/subscriber/mixins/ConcurrentMixin.md new file mode 100644 index 0000000000..994f224aea --- /dev/null +++ b/docs/docs/en/api/faststream/broker/subscriber/mixins/ConcurrentMixin.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.broker.subscriber.mixins.ConcurrentMixin diff --git a/docs/docs/en/api/faststream/broker/subscriber/mixins/TasksMixin.md b/docs/docs/en/api/faststream/broker/subscriber/mixins/TasksMixin.md new file mode 100644 index 0000000000..6d483bef85 --- /dev/null +++ b/docs/docs/en/api/faststream/broker/subscriber/mixins/TasksMixin.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.broker.subscriber.mixins.TasksMixin diff --git a/faststream/broker/subscriber/mixins.py b/faststream/broker/subscriber/mixins.py new file mode 100644 index 0000000000..24b0fd7e46 --- /dev/null +++ b/faststream/broker/subscriber/mixins.py @@ -0,0 +1,81 @@ +import asyncio +from typing import ( + TYPE_CHECKING, + Any, + Coroutine, + List, +) + +import anyio + +from .usecase import SubscriberUsecase + +if TYPE_CHECKING: + from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + from nats.aio.msg import Msg + + +class TasksMixin(SubscriberUsecase[Any]): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.tasks: List[asyncio.Task[Any]] = [] + + def add_task(self, coro: Coroutine[Any, Any, Any]) -> None: + self.tasks.append(asyncio.create_task(coro)) + + async def close(self) -> None: + """Clean up handler subscription, cancel consume task in graceful mode.""" + await super().close() + + for task in self.tasks: + if not task.done(): + task.cancel() + + self.tasks = [] + + +class ConcurrentMixin(TasksMixin): + send_stream: "MemoryObjectSendStream[Msg]" + receive_stream: "MemoryObjectReceiveStream[Msg]" + + def __init__( + self, + *, + max_workers: int, + **kwargs: Any, + ) -> None: + self.max_workers = max_workers + + self.send_stream, self.receive_stream = anyio.create_memory_object_stream( + max_buffer_size=max_workers + ) + self.limiter = anyio.Semaphore(max_workers) + + super().__init__(**kwargs) + + def start_consume_task(self) -> None: + self.add_task(self._serve_consume_queue()) + + async def _serve_consume_queue( + self, + ) -> None: + """Endless task consuming messages from in-memory queue. + + Suitable to batch messages by amount, timestamps, etc and call `consume` for this batches. + """ + async with anyio.create_task_group() as tg: + async for msg in self.receive_stream: + tg.start_soon(self._consume_msg, msg) + + async def _consume_msg( + self, + msg: "Msg", + ) -> None: + """Proxy method to call `self.consume` with semaphore block.""" + async with self.limiter: + await self.consume(msg) + + async def _put_msg(self, msg: "Msg") -> None: + """Proxy method to put msg into in-memory queue with semaphore block.""" + async with self.limiter: + await self.send_stream.send(msg) diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index e7b3e0ce01..efbe0342b9 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -1,4 +1,3 @@ -import asyncio from abc import abstractmethod from contextlib import suppress from typing import ( @@ -6,7 +5,6 @@ Any, Awaitable, Callable, - Coroutine, Dict, Generic, Iterable, @@ -25,10 +23,12 @@ from typing_extensions import Annotated, Doc, override from faststream.broker.publisher.fake import FakePublisher +from faststream.broker.subscriber.mixins import ConcurrentMixin, TasksMixin from faststream.broker.subscriber.usecase import SubscriberUsecase from faststream.broker.types import MsgType from faststream.broker.utils import process_msg from faststream.exceptions import NOT_CONNECTED_YET +from faststream.nats.message import NatsMessage from faststream.nats.parser import ( BatchParser, JsParser, @@ -44,7 +44,6 @@ from faststream.utils.context.repository import context if TYPE_CHECKING: - from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from nats.aio.client import Client from nats.aio.msg import Msg from nats.aio.subscription import Subscription @@ -60,7 +59,7 @@ CustomCallable, ) from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer - from faststream.nats.message import NatsKvMessage, NatsMessage, NatsObjMessage + from faststream.nats.message import NatsKvMessage, NatsObjMessage from faststream.nats.schemas import JStream, KvWatch, ObjWatch, PullSub from faststream.types import AnyDict, Decorator, LoggerProto, SendableMessage @@ -320,73 +319,6 @@ def get_log_context( ) -class _TasksMixin(LogicSubscriber[Any, Any]): - def __init__(self, **kwargs: Any) -> None: - self.tasks: List[asyncio.Task[Any]] = [] - - super().__init__(**kwargs) - - def add_task(self, coro: Coroutine[Any, Any, Any]) -> None: - self.tasks.append(asyncio.create_task(coro)) - - async def close(self) -> None: - """Clean up handler subscription, cancel consume task in graceful mode.""" - await super().close() - - for task in self.tasks: - if not task.done(): - task.cancel() - - self.tasks = [] - - -class _ConcurrentMixin(_TasksMixin): - send_stream: "MemoryObjectSendStream[Msg]" - receive_stream: "MemoryObjectReceiveStream[Msg]" - - def __init__( - self, - *, - max_workers: int, - **kwargs: Any, - ) -> None: - self.max_workers = max_workers - - self.send_stream, self.receive_stream = anyio.create_memory_object_stream( - max_buffer_size=max_workers - ) - self.limiter = anyio.Semaphore(max_workers) - - super().__init__(**kwargs) - - def start_consume_task(self) -> None: - self.add_task(self._serve_consume_queue()) - - async def _serve_consume_queue( - self, - ) -> None: - """Endless task consuming messages from in-memory queue. - - Suitable to batch messages by amount, timestamps, etc and call `consume` for this batches. - """ - async with anyio.create_task_group() as tg: - async for msg in self.receive_stream: - tg.start_soon(self._consume_msg, msg) - - async def _consume_msg( - self, - msg: "Msg", - ) -> None: - """Proxy method to call `self.consume` with semaphore block.""" - async with self.limiter: - await self.consume(msg) - - async def _put_msg(self, msg: "Msg") -> None: - """Proxy method to put msg into in-memory queue with semaphore block.""" - async with self.limiter: - await self.send_stream.send(msg) - - class CoreSubscriber(_DefaultSubscriber["Client", "Msg"]): subscription: Optional["Subscription"] _fetch_sub: Optional["Subscription"] @@ -499,7 +431,7 @@ def get_log_context( class ConcurrentCoreSubscriber( - _ConcurrentMixin, + ConcurrentMixin, CoreSubscriber, ): def __init__( @@ -692,7 +624,7 @@ async def _create_subscription( class ConcurrentPushStreamSubscriber( - _ConcurrentMixin, + ConcurrentMixin, _StreamSubscriber, ): subscription: Optional["JetStreamContext.PushSubscription"] @@ -760,7 +692,7 @@ async def _create_subscription( class PullStreamSubscriber( - _TasksMixin, + TasksMixin, _StreamSubscriber, ): subscription: Optional["JetStreamContext.PullSubscription"] @@ -845,7 +777,7 @@ async def _consume_pull( class ConcurrentPullStreamSubscriber( - _ConcurrentMixin, + ConcurrentMixin, PullStreamSubscriber, ): def __init__( @@ -910,7 +842,7 @@ async def _create_subscription( class BatchPullStreamSubscriber( - _TasksMixin, + TasksMixin, _DefaultSubscriber["JetStreamContext", List["Msg"]], ): """Batch-message consumer class.""" @@ -990,11 +922,14 @@ async def get_one( except TimeoutError: return None - msg: NatsMessage = await process_msg( - msg=raw_message, - middlewares=self._broker_middlewares, - parser=self._parser, - decoder=self._decoder, + msg = cast( + NatsMessage, + await process_msg( + msg=raw_message, + middlewares=self._broker_middlewares, + parser=self._parser, + decoder=self._decoder, + ), ) return msg @@ -1031,7 +966,7 @@ async def _consume_pull(self) -> None: class KeyValueWatchSubscriber( - _TasksMixin, + TasksMixin, LogicSubscriber["KVBucketDeclarer", "KeyValue.Entry"], ): subscription: Optional["UnsubscribeAdapter[KeyValue.KeyWatcher]"] @@ -1188,7 +1123,7 @@ def get_log_context( class ObjStoreWatchSubscriber( - _TasksMixin, + TasksMixin, LogicSubscriber["OSBucketDeclarer", ObjectInfo], ): subscription: Optional["UnsubscribeAdapter[ObjectStore.ObjectWatcher]"] @@ -1305,7 +1240,7 @@ async def _consume_watch(self) -> None: with suppress(TimeoutError): message = cast( Optional["ObjectInfo"], - await obj_watch.updates(self.obj_watch.timeout), + await obj_watch.updates(self.obj_watch.timeout), # type: ignore[no-untyped-call] ) if message: From be596f0fc41386a0768545a30a5e915bbcca982a Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Sat, 26 Oct 2024 10:57:21 +0300 Subject: [PATCH 21/48] refactor: use CMD to call publisher (#1857) * refactor: use CMD to call publisher * fix: add missing pre-commit changes * refactor: add an ability to check RPC response * refactor: use PublishType * refactor: new NatsFakePublisher * refactor: use PublishCmd in request * fix: correct publisher.publish * fix: correct Nats JS request * lint: polish annotations * feat: add RabbitPublishCommand * refactor: add basic publish & request publisher methods * refactor: remove add_header Response method * refactor: add KafkaPublishCommand * refactor: pass context to middleware directly * refactor: Confluent, Redis publish commands * refactor: break ISP, add publish_batch to ProducerProto * refactor: create basic fake publisher * refactor: do not call LoggingMiddleware for RPC responses * refactor: new NatsOtelMiddleware * refactor: new RabbitOtelMiddleware * refactor: new RedisOtelMiddleware * refactor: new KafkaOtelMiddleware * refactor: new ConfluentOtelMiddleware * feat: add PublishCmd add_headers method * refactor: actual PrometheusMiddleware * fix: correct AsyncAPI * fix: add missing pre-commit changes * chore: remove 3.8 from CI * docs: generate API References * chore: fix CI * chore: fix CI * fix: set miltilock at start only --------- Co-authored-by: Lancetnik --- .github/workflows/pr_tests.yaml | 5 +- docs/docs/SUMMARY.md | 87 +++- .../prometheus/KafkaPrometheusMiddleware.md} | 2 +- .../middleware/KafkaPrometheusMiddleware.md | 11 + .../BaseConfluentMetricsSettingsProvider.md | 11 + .../BatchConfluentMetricsSettingsProvider.md | 11 + .../ConfluentMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../publisher/fake/KafkaFakePublisher.md} | 2 +- .../confluent/response/KafkaPublishCommand.md | 11 + .../FeatureNotSupportedException.md | 11 + .../prometheus/KafkaPrometheusMiddleware.md | 11 + .../middleware/KafkaPrometheusMiddleware.md | 11 + .../BaseKafkaMetricsSettingsProvider.md | 11 + .../BatchKafkaMetricsSettingsProvider.md | 11 + .../provider/KafkaMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../publisher/fake/KafkaFakePublisher.md | 11 + .../response/KafkaPublishCommand.md} | 2 +- .../prometheus/NatsPrometheusMiddleware.md | 11 + .../middleware/NatsPrometheusMiddleware.md | 11 + .../BaseNatsMetricsSettingsProvider.md | 11 + .../BatchNatsMetricsSettingsProvider.md | 11 + .../provider/NatsMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../publisher/fake/NatsFakePublisher.md} | 2 +- .../nats/response/NatsPublishCommand.md | 11 + .../api/faststream/prometheus/ConsumeAttrs.md | 11 + .../prometheus/MetricsSettingsProvider.md | 11 + .../prometheus/PrometheusMiddleware.md | 11 + .../prometheus/container/MetricsContainer.md | 11 + .../prometheus/manager/MetricsManager.md | 11 + .../middleware/PrometheusMiddleware.md | 11 + .../provider/MetricsSettingsProvider.md | 11 + .../prometheus/types/ConsumeAttrs.md | 11 + .../prometheus/types/ProcessingStatus.md | 11 + .../prometheus/types/PublishingStatus.md | 11 + .../prometheus/RabbitPrometheusMiddleware.md | 11 + .../middleware/RabbitPrometheusMiddleware.md | 11 + .../provider/RabbitMetricsSettingsProvider.md | 11 + .../publisher/fake/RabbitFakePublisher.md | 11 + .../publisher/options/MessageOptions.md | 11 + .../publisher/options/PublishOptions.md | 11 + .../rabbit/response/RabbitPublishCommand.md | 11 + .../prometheus/RedisPrometheusMiddleware.md | 11 + .../middleware/RedisPrometheusMiddleware.md | 11 + .../BaseRedisMetricsSettingsProvider.md | 11 + .../BatchRedisMetricsSettingsProvider.md | 11 + .../provider/RedisMetricsSettingsProvider.md | 11 + .../provider/settings_provider_factory.md | 11 + .../publisher/fake/RedisFakePublisher.md | 11 + .../redis/response/DestinationType.md | 11 + .../redis/response/RedisPublishCommand.md | 11 + .../api/faststream/response/PublishCommand.md | 11 + .../en/api/faststream/response/PublishType.md | 11 + .../response/publish_type/PublishType.md | 11 + .../response/response/PublishCommand.md | 11 + docs/docs/en/howto/nats/dynaconf.md | 2 +- docs/docs/en/howto/nats/index.md | 2 +- faststream/_internal/broker/broker.py | 48 ++- faststream/_internal/publisher/fake.py | 68 ++- faststream/_internal/publisher/proto.py | 32 +- faststream/_internal/publisher/usecase.py | 76 +++- faststream/_internal/subscriber/usecase.py | 10 +- faststream/_internal/subscriber/utils.py | 8 +- faststream/_internal/testing/broker.py | 2 + faststream/_internal/types.py | 22 +- faststream/confluent/annotations.py | 2 - faststream/confluent/broker/broker.py | 94 +++-- .../confluent/opentelemetry/provider.py | 21 +- faststream/confluent/prometheus/middleware.py | 6 +- faststream/confluent/prometheus/provider.py | 10 +- faststream/confluent/publisher/fake.py | 27 ++ faststream/confluent/publisher/producer.py | 78 ++-- faststream/confluent/publisher/usecase.py | 202 +++------ faststream/confluent/response.py | 100 ++++- faststream/confluent/subscriber/usecase.py | 14 +- faststream/confluent/testing.py | 103 ++--- faststream/exceptions.py | 2 +- faststream/kafka/annotations.py | 2 - faststream/kafka/broker/broker.py | 49 +-- faststream/kafka/opentelemetry/provider.py | 21 +- faststream/kafka/prometheus/middleware.py | 6 +- faststream/kafka/prometheus/provider.py | 10 +- faststream/kafka/publisher/fake.py | 27 ++ faststream/kafka/publisher/producer.py | 85 ++-- faststream/kafka/publisher/usecase.py | 217 +++------- faststream/kafka/response.py | 104 ++++- faststream/kafka/subscriber/usecase.py | 14 +- faststream/kafka/testing.py | 99 ++--- faststream/middlewares/base.py | 34 +- faststream/middlewares/exception.py | 124 +++--- faststream/middlewares/logging.py | 92 +++-- faststream/nats/annotations.py | 8 - faststream/nats/broker/broker.py | 63 ++- faststream/nats/opentelemetry/provider.py | 15 +- faststream/nats/prometheus/middleware.py | 6 +- faststream/nats/prometheus/provider.py | 12 +- faststream/nats/publisher/fake.py | 27 ++ faststream/nats/publisher/producer.py | 96 ++--- faststream/nats/publisher/usecase.py | 118 ++---- faststream/nats/response.py | 77 +++- faststream/nats/subscriber/usecase.py | 26 +- faststream/nats/testing.py | 52 +-- faststream/opentelemetry/middleware.py | 127 +++--- faststream/opentelemetry/provider.py | 7 +- faststream/prometheus/__init__.py | 4 +- faststream/prometheus/consts.py | 2 +- faststream/prometheus/middleware.py | 110 ++--- faststream/prometheus/provider.py | 9 +- faststream/rabbit/annotations.py | 2 - faststream/rabbit/broker/broker.py | 36 +- faststream/rabbit/opentelemetry/provider.py | 27 +- faststream/rabbit/parser.py | 26 +- faststream/rabbit/prometheus/middleware.py | 6 +- faststream/rabbit/prometheus/provider.py | 18 +- faststream/rabbit/publisher/fake.py | 30 ++ faststream/rabbit/publisher/options.py | 28 ++ faststream/rabbit/publisher/producer.py | 151 ++----- faststream/rabbit/publisher/specified.py | 10 +- faststream/rabbit/publisher/usecase.py | 255 ++++-------- faststream/rabbit/response.py | 121 ++++-- faststream/rabbit/schemas/proto.py | 2 +- faststream/rabbit/subscriber/usecase.py | 15 +- faststream/rabbit/testing.py | 101 +---- faststream/redis/annotations.py | 2 - faststream/redis/broker/broker.py | 43 +- faststream/redis/opentelemetry/provider.py | 13 +- faststream/redis/prometheus/middleware.py | 6 +- faststream/redis/prometheus/provider.py | 23 +- faststream/redis/publisher/fake.py | 27 ++ faststream/redis/publisher/producer.py | 113 ++--- faststream/redis/publisher/usecase.py | 388 +++++------------- faststream/redis/response.py | 124 +++++- faststream/redis/schemas/proto.py | 3 +- faststream/redis/subscriber/usecase.py | 14 +- faststream/redis/testing.py | 80 ++-- faststream/response/__init__.py | 5 +- faststream/response/publish_type.py | 12 + faststream/response/response.py | 51 ++- .../subscription/test_annotated.py | 3 +- .../asyncapi/rabbit/v2_6_0/test_publisher.py | 2 +- tests/brokers/base/consume.py | 3 +- tests/brokers/base/middlewares.py | 36 +- tests/brokers/base/requests.py | 6 +- tests/brokers/base/testclient.py | 3 +- tests/brokers/confluent/test_consume.py | 4 +- .../brokers/confluent/test_publish_command.py | 46 +++ tests/brokers/kafka/test_consume.py | 4 +- tests/brokers/kafka/test_publish_command.py | 46 +++ tests/brokers/nats/test_consume.py | 4 +- tests/brokers/rabbit/test_consume.py | 6 +- tests/brokers/rabbit/test_publish.py | 18 +- tests/brokers/rabbit/test_test_client.py | 6 +- tests/brokers/redis/test_publish_command.py | 46 +++ tests/brokers/test_pushback.py | 5 +- tests/brokers/test_response.py | 19 +- tests/cli/rabbit/test_app.py | 5 +- tests/cli/test_publish.py | 81 ++-- tests/prometheus/basic.py | 2 +- tests/tools.py | 4 +- tests/utils/test_ast.py | 4 +- 162 files changed, 3014 insertions(+), 2304 deletions(-) rename docs/docs/en/api/faststream/{opentelemetry/middleware/BaseTelemetryMiddleware.md => confluent/prometheus/KafkaPrometheusMiddleware.md} (63%) create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md rename docs/docs/en/api/faststream/{middlewares/exception/BaseExceptionMiddleware.md => confluent/publisher/fake/KafkaFakePublisher.md} (64%) create mode 100644 docs/docs/en/api/faststream/confluent/response/KafkaPublishCommand.md create mode 100644 docs/docs/en/api/faststream/exceptions/FeatureNotSupportedException.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md create mode 100644 docs/docs/en/api/faststream/kafka/publisher/fake/KafkaFakePublisher.md rename docs/docs/en/api/faststream/{exceptions/OperationForbiddenError.md => kafka/response/KafkaPublishCommand.md} (68%) create mode 100644 docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md rename docs/docs/en/api/faststream/{middlewares/logging/LoggingMiddleware.md => nats/publisher/fake/NatsFakePublisher.md} (67%) create mode 100644 docs/docs/en/api/faststream/nats/response/NatsPublishCommand.md create mode 100644 docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md create mode 100644 docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/prometheus/PrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md create mode 100644 docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md create mode 100644 docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md create mode 100644 docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md create mode 100644 docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md create mode 100644 docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/rabbit/publisher/fake/RabbitFakePublisher.md create mode 100644 docs/docs/en/api/faststream/rabbit/publisher/options/MessageOptions.md create mode 100644 docs/docs/en/api/faststream/rabbit/publisher/options/PublishOptions.md create mode 100644 docs/docs/en/api/faststream/rabbit/response/RabbitPublishCommand.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md create mode 100644 docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md create mode 100644 docs/docs/en/api/faststream/redis/publisher/fake/RedisFakePublisher.md create mode 100644 docs/docs/en/api/faststream/redis/response/DestinationType.md create mode 100644 docs/docs/en/api/faststream/redis/response/RedisPublishCommand.md create mode 100644 docs/docs/en/api/faststream/response/PublishCommand.md create mode 100644 docs/docs/en/api/faststream/response/PublishType.md create mode 100644 docs/docs/en/api/faststream/response/publish_type/PublishType.md create mode 100644 docs/docs/en/api/faststream/response/response/PublishCommand.md create mode 100644 faststream/confluent/publisher/fake.py create mode 100644 faststream/kafka/publisher/fake.py create mode 100644 faststream/nats/publisher/fake.py create mode 100644 faststream/rabbit/publisher/fake.py create mode 100644 faststream/rabbit/publisher/options.py create mode 100644 faststream/redis/publisher/fake.py create mode 100644 faststream/response/publish_type.py create mode 100644 tests/brokers/confluent/test_publish_command.py create mode 100644 tests/brokers/kafka/test_publish_command.py create mode 100644 tests/brokers/redis/test_publish_command.py diff --git a/.github/workflows/pr_tests.yaml b/.github/workflows/pr_tests.yaml index e9282d496c..58e75a252b 100644 --- a/.github/workflows/pr_tests.yaml +++ b/.github/workflows/pr_tests.yaml @@ -31,7 +31,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: | - 3.8 3.9 3.10 - name: Set $PY environment variable @@ -55,7 +54,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] pydantic-version: ["pydantic-v1", "pydantic-v2"] fail-fast: false @@ -491,7 +490,7 @@ jobs: version: "latest" - uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.12" - name: Get coverage files uses: actions/download-artifact@v4 diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index e7018afc71..8f8c5f0cba 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -294,9 +294,20 @@ search: - [telemetry_attributes_provider_factory](api/faststream/confluent/opentelemetry/provider/telemetry_attributes_provider_factory.md) - parser - [AsyncConfluentParser](api/faststream/confluent/parser/AsyncConfluentParser.md) + - prometheus + - [KafkaPrometheusMiddleware](api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md) + - middleware + - [KafkaPrometheusMiddleware](api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md) + - provider + - [BaseConfluentMetricsSettingsProvider](api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md) + - [BatchConfluentMetricsSettingsProvider](api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md) + - [ConfluentMetricsSettingsProvider](api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/confluent/prometheus/provider/settings_provider_factory.md) - publisher - factory - [create_publisher](api/faststream/confluent/publisher/factory/create_publisher.md) + - fake + - [KafkaFakePublisher](api/faststream/confluent/publisher/fake/KafkaFakePublisher.md) - producer - [AsyncConfluentFastProducer](api/faststream/confluent/publisher/producer/AsyncConfluentFastProducer.md) - specified @@ -308,6 +319,7 @@ search: - [DefaultPublisher](api/faststream/confluent/publisher/usecase/DefaultPublisher.md) - [LogicPublisher](api/faststream/confluent/publisher/usecase/LogicPublisher.md) - response + - [KafkaPublishCommand](api/faststream/confluent/response/KafkaPublishCommand.md) - [KafkaResponse](api/faststream/confluent/response/KafkaResponse.md) - router - [KafkaPublisher](api/faststream/confluent/router/KafkaPublisher.md) @@ -341,11 +353,11 @@ search: - [AckMessage](api/faststream/exceptions/AckMessage.md) - [ContextError](api/faststream/exceptions/ContextError.md) - [FastStreamException](api/faststream/exceptions/FastStreamException.md) + - [FeatureNotSupportedException](api/faststream/exceptions/FeatureNotSupportedException.md) - [HandlerException](api/faststream/exceptions/HandlerException.md) - [IgnoredException](api/faststream/exceptions/IgnoredException.md) - [IncorrectState](api/faststream/exceptions/IncorrectState.md) - [NackMessage](api/faststream/exceptions/NackMessage.md) - - [OperationForbiddenError](api/faststream/exceptions/OperationForbiddenError.md) - [RejectMessage](api/faststream/exceptions/RejectMessage.md) - [SetupError](api/faststream/exceptions/SetupError.md) - [SkipMessage](api/faststream/exceptions/SkipMessage.md) @@ -392,9 +404,20 @@ search: - parser - [AioKafkaBatchParser](api/faststream/kafka/parser/AioKafkaBatchParser.md) - [AioKafkaParser](api/faststream/kafka/parser/AioKafkaParser.md) + - prometheus + - [KafkaPrometheusMiddleware](api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md) + - middleware + - [KafkaPrometheusMiddleware](api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md) + - provider + - [BaseKafkaMetricsSettingsProvider](api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md) + - [BatchKafkaMetricsSettingsProvider](api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md) + - [KafkaMetricsSettingsProvider](api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/kafka/prometheus/provider/settings_provider_factory.md) - publisher - factory - [create_publisher](api/faststream/kafka/publisher/factory/create_publisher.md) + - fake + - [KafkaFakePublisher](api/faststream/kafka/publisher/fake/KafkaFakePublisher.md) - producer - [AioKafkaFastProducer](api/faststream/kafka/publisher/producer/AioKafkaFastProducer.md) - specified @@ -406,6 +429,7 @@ search: - [DefaultPublisher](api/faststream/kafka/publisher/usecase/DefaultPublisher.md) - [LogicPublisher](api/faststream/kafka/publisher/usecase/LogicPublisher.md) - response + - [KafkaPublishCommand](api/faststream/kafka/response/KafkaPublishCommand.md) - [KafkaResponse](api/faststream/kafka/response/KafkaResponse.md) - router - [KafkaPublisher](api/faststream/kafka/router/KafkaPublisher.md) @@ -453,12 +477,10 @@ search: - base - [BaseMiddleware](api/faststream/middlewares/base/BaseMiddleware.md) - exception - - [BaseExceptionMiddleware](api/faststream/middlewares/exception/BaseExceptionMiddleware.md) - [ExceptionMiddleware](api/faststream/middlewares/exception/ExceptionMiddleware.md) - [ignore_handler](api/faststream/middlewares/exception/ignore_handler.md) - logging - [CriticalLogMiddleware](api/faststream/middlewares/logging/CriticalLogMiddleware.md) - - [LoggingMiddleware](api/faststream/middlewares/logging/LoggingMiddleware.md) - nats - [AckPolicy](api/faststream/nats/AckPolicy.md) - [ConsumerConfig](api/faststream/nats/ConsumerConfig.md) @@ -527,9 +549,20 @@ search: - [NatsBaseParser](api/faststream/nats/parser/NatsBaseParser.md) - [NatsParser](api/faststream/nats/parser/NatsParser.md) - [ObjParser](api/faststream/nats/parser/ObjParser.md) + - prometheus + - [NatsPrometheusMiddleware](api/faststream/nats/prometheus/NatsPrometheusMiddleware.md) + - middleware + - [NatsPrometheusMiddleware](api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md) + - provider + - [BaseNatsMetricsSettingsProvider](api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md) + - [BatchNatsMetricsSettingsProvider](api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md) + - [NatsMetricsSettingsProvider](api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/nats/prometheus/provider/settings_provider_factory.md) - publisher - factory - [create_publisher](api/faststream/nats/publisher/factory/create_publisher.md) + - fake + - [NatsFakePublisher](api/faststream/nats/publisher/fake/NatsFakePublisher.md) - producer - [NatsFastProducer](api/faststream/nats/publisher/producer/NatsFastProducer.md) - [NatsJSFastProducer](api/faststream/nats/publisher/producer/NatsJSFastProducer.md) @@ -538,6 +571,7 @@ search: - usecase - [LogicPublisher](api/faststream/nats/publisher/usecase/LogicPublisher.md) - response + - [NatsPublishCommand](api/faststream/nats/response/NatsPublishCommand.md) - [NatsResponse](api/faststream/nats/response/NatsResponse.md) - router - [NatsPublisher](api/faststream/nats/router/NatsPublisher.md) @@ -603,7 +637,6 @@ search: - consts - [MessageAction](api/faststream/opentelemetry/consts/MessageAction.md) - middleware - - [BaseTelemetryMiddleware](api/faststream/opentelemetry/middleware/BaseTelemetryMiddleware.md) - [TelemetryMiddleware](api/faststream/opentelemetry/middleware/TelemetryMiddleware.md) - provider - [TelemetrySettingsProvider](api/faststream/opentelemetry/provider/TelemetrySettingsProvider.md) @@ -618,6 +651,22 @@ search: - [Context](api/faststream/params/params/Context.md) - [Header](api/faststream/params/params/Header.md) - [Path](api/faststream/params/params/Path.md) + - prometheus + - [ConsumeAttrs](api/faststream/prometheus/ConsumeAttrs.md) + - [MetricsSettingsProvider](api/faststream/prometheus/MetricsSettingsProvider.md) + - [PrometheusMiddleware](api/faststream/prometheus/PrometheusMiddleware.md) + - container + - [MetricsContainer](api/faststream/prometheus/container/MetricsContainer.md) + - manager + - [MetricsManager](api/faststream/prometheus/manager/MetricsManager.md) + - middleware + - [PrometheusMiddleware](api/faststream/prometheus/middleware/PrometheusMiddleware.md) + - provider + - [MetricsSettingsProvider](api/faststream/prometheus/provider/MetricsSettingsProvider.md) + - types + - [ConsumeAttrs](api/faststream/prometheus/types/ConsumeAttrs.md) + - [ProcessingStatus](api/faststream/prometheus/types/ProcessingStatus.md) + - [PublishingStatus](api/faststream/prometheus/types/PublishingStatus.md) - rabbit - [ExchangeType](api/faststream/rabbit/ExchangeType.md) - [RabbitBroker](api/faststream/rabbit/RabbitBroker.md) @@ -655,9 +704,20 @@ search: - [RabbitTelemetrySettingsProvider](api/faststream/rabbit/opentelemetry/provider/RabbitTelemetrySettingsProvider.md) - parser - [AioPikaParser](api/faststream/rabbit/parser/AioPikaParser.md) + - prometheus + - [RabbitPrometheusMiddleware](api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md) + - middleware + - [RabbitPrometheusMiddleware](api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md) + - provider + - [RabbitMetricsSettingsProvider](api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md) - publisher - factory - [create_publisher](api/faststream/rabbit/publisher/factory/create_publisher.md) + - fake + - [RabbitFakePublisher](api/faststream/rabbit/publisher/fake/RabbitFakePublisher.md) + - options + - [MessageOptions](api/faststream/rabbit/publisher/options/MessageOptions.md) + - [PublishOptions](api/faststream/rabbit/publisher/options/PublishOptions.md) - producer - [AioPikaFastProducer](api/faststream/rabbit/publisher/producer/AioPikaFastProducer.md) - specified @@ -667,6 +727,7 @@ search: - [PublishKwargs](api/faststream/rabbit/publisher/usecase/PublishKwargs.md) - [RequestPublishKwargs](api/faststream/rabbit/publisher/usecase/RequestPublishKwargs.md) - response + - [RabbitPublishCommand](api/faststream/rabbit/response/RabbitPublishCommand.md) - [RabbitResponse](api/faststream/rabbit/response/RabbitResponse.md) - router - [RabbitPublisher](api/faststream/rabbit/router/RabbitPublisher.md) @@ -755,9 +816,20 @@ search: - [RedisPubSubParser](api/faststream/redis/parser/RedisPubSubParser.md) - [RedisStreamParser](api/faststream/redis/parser/RedisStreamParser.md) - [SimpleParser](api/faststream/redis/parser/SimpleParser.md) + - prometheus + - [RedisPrometheusMiddleware](api/faststream/redis/prometheus/RedisPrometheusMiddleware.md) + - middleware + - [RedisPrometheusMiddleware](api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md) + - provider + - [BaseRedisMetricsSettingsProvider](api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md) + - [BatchRedisMetricsSettingsProvider](api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md) + - [RedisMetricsSettingsProvider](api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md) + - [settings_provider_factory](api/faststream/redis/prometheus/provider/settings_provider_factory.md) - publisher - factory - [create_publisher](api/faststream/redis/publisher/factory/create_publisher.md) + - fake + - [RedisFakePublisher](api/faststream/redis/publisher/fake/RedisFakePublisher.md) - producer - [RedisFastProducer](api/faststream/redis/publisher/producer/RedisFastProducer.md) - specified @@ -773,6 +845,8 @@ search: - [LogicPublisher](api/faststream/redis/publisher/usecase/LogicPublisher.md) - [StreamPublisher](api/faststream/redis/publisher/usecase/StreamPublisher.md) - response + - [DestinationType](api/faststream/redis/response/DestinationType.md) + - [RedisPublishCommand](api/faststream/redis/response/RedisPublishCommand.md) - [RedisResponse](api/faststream/redis/response/RedisResponse.md) - router - [RedisPublisher](api/faststream/redis/router/RedisPublisher.md) @@ -819,9 +893,14 @@ search: - [Visitor](api/faststream/redis/testing/Visitor.md) - [build_message](api/faststream/redis/testing/build_message.md) - response + - [PublishCommand](api/faststream/response/PublishCommand.md) + - [PublishType](api/faststream/response/PublishType.md) - [Response](api/faststream/response/Response.md) - [ensure_response](api/faststream/response/ensure_response.md) + - publish_type + - [PublishType](api/faststream/response/publish_type/PublishType.md) - response + - [PublishCommand](api/faststream/response/response/PublishCommand.md) - [Response](api/faststream/response/response/Response.md) - utils - [ensure_response](api/faststream/response/utils/ensure_response.md) diff --git a/docs/docs/en/api/faststream/opentelemetry/middleware/BaseTelemetryMiddleware.md b/docs/docs/en/api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md similarity index 63% rename from docs/docs/en/api/faststream/opentelemetry/middleware/BaseTelemetryMiddleware.md rename to docs/docs/en/api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md index 64a7b4a501..e84e84acc3 100644 --- a/docs/docs/en/api/faststream/opentelemetry/middleware/BaseTelemetryMiddleware.md +++ b/docs/docs/en/api/faststream/confluent/prometheus/KafkaPrometheusMiddleware.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.opentelemetry.middleware.BaseTelemetryMiddleware +::: faststream.confluent.prometheus.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md b/docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md new file mode 100644 index 0000000000..6603893f74 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/middleware/KafkaPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.middleware.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md new file mode 100644 index 0000000000..27c186c098 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/BaseConfluentMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.BaseConfluentMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md new file mode 100644 index 0000000000..f784a64e9f --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/BatchConfluentMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.BatchConfluentMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md new file mode 100644 index 0000000000..65f0a8348e --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/ConfluentMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.ConfluentMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..78358f46e3 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/api/faststream/middlewares/exception/BaseExceptionMiddleware.md b/docs/docs/en/api/faststream/confluent/publisher/fake/KafkaFakePublisher.md similarity index 64% rename from docs/docs/en/api/faststream/middlewares/exception/BaseExceptionMiddleware.md rename to docs/docs/en/api/faststream/confluent/publisher/fake/KafkaFakePublisher.md index 54f8031f0a..019fbf855f 100644 --- a/docs/docs/en/api/faststream/middlewares/exception/BaseExceptionMiddleware.md +++ b/docs/docs/en/api/faststream/confluent/publisher/fake/KafkaFakePublisher.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.middlewares.exception.BaseExceptionMiddleware +::: faststream.confluent.publisher.fake.KafkaFakePublisher diff --git a/docs/docs/en/api/faststream/confluent/response/KafkaPublishCommand.md b/docs/docs/en/api/faststream/confluent/response/KafkaPublishCommand.md new file mode 100644 index 0000000000..2a4efcf180 --- /dev/null +++ b/docs/docs/en/api/faststream/confluent/response/KafkaPublishCommand.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.confluent.response.KafkaPublishCommand diff --git a/docs/docs/en/api/faststream/exceptions/FeatureNotSupportedException.md b/docs/docs/en/api/faststream/exceptions/FeatureNotSupportedException.md new file mode 100644 index 0000000000..bbf1f32d2b --- /dev/null +++ b/docs/docs/en/api/faststream/exceptions/FeatureNotSupportedException.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.exceptions.FeatureNotSupportedException diff --git a/docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md b/docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md new file mode 100644 index 0000000000..c2ffd5356a --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/KafkaPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md b/docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md new file mode 100644 index 0000000000..451b7080c0 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/middleware/KafkaPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.middleware.KafkaPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md new file mode 100644 index 0000000000..0fd044f694 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/BaseKafkaMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.BaseKafkaMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md new file mode 100644 index 0000000000..9bd01d5e71 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/BatchKafkaMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.BatchKafkaMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md new file mode 100644 index 0000000000..ae7c490da8 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/KafkaMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.KafkaMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..1393fd9065 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/api/faststream/kafka/publisher/fake/KafkaFakePublisher.md b/docs/docs/en/api/faststream/kafka/publisher/fake/KafkaFakePublisher.md new file mode 100644 index 0000000000..6bacca904e --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/publisher/fake/KafkaFakePublisher.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.publisher.fake.KafkaFakePublisher diff --git a/docs/docs/en/api/faststream/exceptions/OperationForbiddenError.md b/docs/docs/en/api/faststream/kafka/response/KafkaPublishCommand.md similarity index 68% rename from docs/docs/en/api/faststream/exceptions/OperationForbiddenError.md rename to docs/docs/en/api/faststream/kafka/response/KafkaPublishCommand.md index e34e86542b..4852098fcc 100644 --- a/docs/docs/en/api/faststream/exceptions/OperationForbiddenError.md +++ b/docs/docs/en/api/faststream/kafka/response/KafkaPublishCommand.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.exceptions.OperationForbiddenError +::: faststream.kafka.response.KafkaPublishCommand diff --git a/docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md b/docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md new file mode 100644 index 0000000000..d9b179b0c4 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/NatsPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.NatsPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md b/docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md new file mode 100644 index 0000000000..7202731048 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/middleware/NatsPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.middleware.NatsPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md b/docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md new file mode 100644 index 0000000000..80742833bc --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/BaseNatsMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.BaseNatsMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md b/docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md new file mode 100644 index 0000000000..163ebb7bc6 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/BatchNatsMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.BatchNatsMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md b/docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md new file mode 100644 index 0000000000..e5515a4cc5 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/NatsMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.NatsMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..aeaa7b26e0 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/api/faststream/middlewares/logging/LoggingMiddleware.md b/docs/docs/en/api/faststream/nats/publisher/fake/NatsFakePublisher.md similarity index 67% rename from docs/docs/en/api/faststream/middlewares/logging/LoggingMiddleware.md rename to docs/docs/en/api/faststream/nats/publisher/fake/NatsFakePublisher.md index 62e6dfa604..df23cc8045 100644 --- a/docs/docs/en/api/faststream/middlewares/logging/LoggingMiddleware.md +++ b/docs/docs/en/api/faststream/nats/publisher/fake/NatsFakePublisher.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.middlewares.logging.LoggingMiddleware +::: faststream.nats.publisher.fake.NatsFakePublisher diff --git a/docs/docs/en/api/faststream/nats/response/NatsPublishCommand.md b/docs/docs/en/api/faststream/nats/response/NatsPublishCommand.md new file mode 100644 index 0000000000..148119ba8a --- /dev/null +++ b/docs/docs/en/api/faststream/nats/response/NatsPublishCommand.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.response.NatsPublishCommand diff --git a/docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md b/docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md new file mode 100644 index 0000000000..ad8e536b7a --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/ConsumeAttrs.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.ConsumeAttrs diff --git a/docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md b/docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md new file mode 100644 index 0000000000..0f7405e44d --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/MetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.MetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/prometheus/PrometheusMiddleware.md b/docs/docs/en/api/faststream/prometheus/PrometheusMiddleware.md new file mode 100644 index 0000000000..c340a0cb23 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/PrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.PrometheusMiddleware diff --git a/docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md b/docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md new file mode 100644 index 0000000000..009d88d263 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/container/MetricsContainer.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.container.MetricsContainer diff --git a/docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md b/docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md new file mode 100644 index 0000000000..b1a897c717 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/manager/MetricsManager.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.manager.MetricsManager diff --git a/docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md b/docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md new file mode 100644 index 0000000000..2902586e38 --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/middleware/PrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.middleware.PrometheusMiddleware diff --git a/docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md b/docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md new file mode 100644 index 0000000000..3511a21a5b --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/provider/MetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.provider.MetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md b/docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md new file mode 100644 index 0000000000..d9196cab8d --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/types/ConsumeAttrs.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.types.ConsumeAttrs diff --git a/docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md b/docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md new file mode 100644 index 0000000000..98b6710bcd --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/types/ProcessingStatus.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.types.ProcessingStatus diff --git a/docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md b/docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md new file mode 100644 index 0000000000..4e7435fbea --- /dev/null +++ b/docs/docs/en/api/faststream/prometheus/types/PublishingStatus.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.prometheus.types.PublishingStatus diff --git a/docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md b/docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md new file mode 100644 index 0000000000..2c4308fabd --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/prometheus/RabbitPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.prometheus.RabbitPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md b/docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md new file mode 100644 index 0000000000..45163c998a --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/prometheus/middleware/RabbitPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.prometheus.middleware.RabbitPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md b/docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md new file mode 100644 index 0000000000..6d63301b34 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/prometheus/provider/RabbitMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.prometheus.provider.RabbitMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/rabbit/publisher/fake/RabbitFakePublisher.md b/docs/docs/en/api/faststream/rabbit/publisher/fake/RabbitFakePublisher.md new file mode 100644 index 0000000000..60879c8e3a --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/publisher/fake/RabbitFakePublisher.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.publisher.fake.RabbitFakePublisher diff --git a/docs/docs/en/api/faststream/rabbit/publisher/options/MessageOptions.md b/docs/docs/en/api/faststream/rabbit/publisher/options/MessageOptions.md new file mode 100644 index 0000000000..eaa454588a --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/publisher/options/MessageOptions.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.publisher.options.MessageOptions diff --git a/docs/docs/en/api/faststream/rabbit/publisher/options/PublishOptions.md b/docs/docs/en/api/faststream/rabbit/publisher/options/PublishOptions.md new file mode 100644 index 0000000000..c80cc9e937 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/publisher/options/PublishOptions.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.publisher.options.PublishOptions diff --git a/docs/docs/en/api/faststream/rabbit/response/RabbitPublishCommand.md b/docs/docs/en/api/faststream/rabbit/response/RabbitPublishCommand.md new file mode 100644 index 0000000000..4c4bb224b6 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/response/RabbitPublishCommand.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.response.RabbitPublishCommand diff --git a/docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md b/docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md new file mode 100644 index 0000000000..01b23fe4f1 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/RedisPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.RedisPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md b/docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md new file mode 100644 index 0000000000..c29cc91130 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/middleware/RedisPrometheusMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.middleware.RedisPrometheusMiddleware diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md b/docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md new file mode 100644 index 0000000000..243414331b --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/BaseRedisMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.BaseRedisMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md b/docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md new file mode 100644 index 0000000000..33d1d2d3a1 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/BatchRedisMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.BatchRedisMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md b/docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md new file mode 100644 index 0000000000..a7f5f3abe8 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/RedisMetricsSettingsProvider.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.RedisMetricsSettingsProvider diff --git a/docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md b/docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md new file mode 100644 index 0000000000..aa4812f1e2 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/prometheus/provider/settings_provider_factory.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.prometheus.provider.settings_provider_factory diff --git a/docs/docs/en/api/faststream/redis/publisher/fake/RedisFakePublisher.md b/docs/docs/en/api/faststream/redis/publisher/fake/RedisFakePublisher.md new file mode 100644 index 0000000000..eb00559657 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/publisher/fake/RedisFakePublisher.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.publisher.fake.RedisFakePublisher diff --git a/docs/docs/en/api/faststream/redis/response/DestinationType.md b/docs/docs/en/api/faststream/redis/response/DestinationType.md new file mode 100644 index 0000000000..4eda1ad154 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/response/DestinationType.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.response.DestinationType diff --git a/docs/docs/en/api/faststream/redis/response/RedisPublishCommand.md b/docs/docs/en/api/faststream/redis/response/RedisPublishCommand.md new file mode 100644 index 0000000000..14e21c799e --- /dev/null +++ b/docs/docs/en/api/faststream/redis/response/RedisPublishCommand.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.response.RedisPublishCommand diff --git a/docs/docs/en/api/faststream/response/PublishCommand.md b/docs/docs/en/api/faststream/response/PublishCommand.md new file mode 100644 index 0000000000..8ca17ac376 --- /dev/null +++ b/docs/docs/en/api/faststream/response/PublishCommand.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.response.PublishCommand diff --git a/docs/docs/en/api/faststream/response/PublishType.md b/docs/docs/en/api/faststream/response/PublishType.md new file mode 100644 index 0000000000..57d3cbddd7 --- /dev/null +++ b/docs/docs/en/api/faststream/response/PublishType.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.response.PublishType diff --git a/docs/docs/en/api/faststream/response/publish_type/PublishType.md b/docs/docs/en/api/faststream/response/publish_type/PublishType.md new file mode 100644 index 0000000000..2ac2fcd51c --- /dev/null +++ b/docs/docs/en/api/faststream/response/publish_type/PublishType.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.response.publish_type.PublishType diff --git a/docs/docs/en/api/faststream/response/response/PublishCommand.md b/docs/docs/en/api/faststream/response/response/PublishCommand.md new file mode 100644 index 0000000000..b247a7e5d8 --- /dev/null +++ b/docs/docs/en/api/faststream/response/response/PublishCommand.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.response.response.PublishCommand diff --git a/docs/docs/en/howto/nats/dynaconf.md b/docs/docs/en/howto/nats/dynaconf.md index 5c68913c80..a618b0b67f 100644 --- a/docs/docs/en/howto/nats/dynaconf.md +++ b/docs/docs/en/howto/nats/dynaconf.md @@ -91,4 +91,4 @@ async def handle_filled_buy( @app.after_startup async def after_startup(): await broker.publish({"order_id": str(uuid4())}, "order_service.order.filled.buy") - ``` \ No newline at end of file + ``` diff --git a/docs/docs/en/howto/nats/index.md b/docs/docs/en/howto/nats/index.md index 4e24c17ab2..74c92279ed 100644 --- a/docs/docs/en/howto/nats/index.md +++ b/docs/docs/en/howto/nats/index.md @@ -14,4 +14,4 @@ search: We (**airt** team) hope that this section will be maintained by the community. Please feel free to add any examples/sections or edit existing ones. If you haven't found the solution in the docs yet, this is a great opportunity to add a new article here! -To add a new page to this section, simply add a new **Markdown** file to the [`docs/docs/en/howto/nats`](https://github.com/airtai/faststream/tree/main/docs/docs/en/howto/nats){.external-link target="_blank"} directory and update the [`navigation_template.txt`](https://github.com/airtai/faststream/blob/main/docs/docs/navigation_template.txt){.external-link target="_blank"} file. \ No newline at end of file +To add a new page to this section, simply add a new **Markdown** file to the [`docs/docs/en/howto/nats`](https://github.com/airtai/faststream/tree/main/docs/docs/en/howto/nats){.external-link target="_blank"} directory and update the [`navigation_template.txt`](https://github.com/airtai/faststream/blob/main/docs/docs/navigation_template.txt){.external-link target="_blank"} file. diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index f0b73b5be8..03d7887cba 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -15,6 +15,7 @@ from typing_extensions import Doc, Self from faststream._internal._compat import is_test_env +from faststream._internal.context.repository import context from faststream._internal.setup import ( EmptyState, FastDependsData, @@ -49,6 +50,7 @@ ProducerProto, PublisherProto, ) + from faststream.response.response import PublishCommand from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict @@ -317,13 +319,11 @@ async def close( self.running = False - async def publish( + async def _basic_publish( self, - msg: Any, + cmd: "PublishCommand", *, producer: Optional["ProducerProto"], - correlation_id: Optional[str] = None, - **kwargs: Any, ) -> Optional[Any]: """Publish message directly.""" assert producer, NOT_CONNECTED_YET # nosec B101 @@ -331,39 +331,49 @@ async def publish( publish = producer.publish for m in self._middlewares: - publish = partial(m(None).publish_scope, publish) + publish = partial(m(None, context=context).publish_scope, publish) - return await publish(msg, correlation_id=correlation_id, **kwargs) + return await publish(cmd) - async def request( + async def _basic_publish_batch( self, - msg: Any, + cmd: "PublishCommand", + *, + producer: Optional["ProducerProto"], + ) -> None: + """Publish a messages batch directly.""" + assert producer, NOT_CONNECTED_YET # nosec B101 + + publish = producer.publish_batch + + for m in self._middlewares: + publish = partial(m(None, context=context).publish_scope, publish) + + await publish(cmd) + + async def _basic_request( + self, + cmd: "PublishCommand", *, producer: Optional["ProducerProto"], - correlation_id: Optional[str] = None, - **kwargs: Any, ) -> Any: """Publish message directly.""" assert producer, NOT_CONNECTED_YET # nosec B101 request = producer.request for m in self._middlewares: - request = partial(m(None).publish_scope, request) + request = partial(m(None, context=context).publish_scope, request) - published_msg = await request( - msg, - correlation_id=correlation_id, - **kwargs, - ) + published_msg = await request(cmd) - message: Any = await process_msg( + response_msg: Any = await process_msg( msg=published_msg, middlewares=self._middlewares, parser=producer._parser, decoder=producer._decoder, + source_type=SourceType.Response, ) - message._source_type = SourceType.Response - return message + return response_msg @abstractmethod async def ping(self, timeout: Optional[float]) -> bool: diff --git a/faststream/_internal/publisher/fake.py b/faststream/_internal/publisher/fake.py index 2c5a4eaa7e..e1d498d86c 100644 --- a/faststream/_internal/publisher/fake.py +++ b/faststream/_internal/publisher/fake.py @@ -1,61 +1,60 @@ +from abc import abstractmethod from collections.abc import Iterable from functools import partial -from itertools import chain from typing import TYPE_CHECKING, Any, Optional from faststream._internal.basic_types import SendableMessage from faststream._internal.publisher.proto import BasePublisherProto if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict, AsyncFunc + from faststream._internal.basic_types import AsyncFunc + from faststream._internal.publisher.proto import ProducerProto from faststream._internal.types import PublisherMiddleware + from faststream.response.response import PublishCommand class FakePublisher(BasePublisherProto): - """Publisher Interface implementation to use as RPC or REPLY TO publisher.""" + """Publisher Interface implementation to use as RPC or REPLY TO answer publisher.""" def __init__( self, - method: "AsyncFunc", *, - publish_kwargs: "AnyDict", - middlewares: Iterable["PublisherMiddleware"] = (), + producer: "ProducerProto", ) -> None: """Initialize an object.""" - self.method = method - self.publish_kwargs = publish_kwargs - self.middlewares = middlewares + self._producer = producer - async def publish( - self, - message: SendableMessage, - /, - *, - correlation_id: Optional[str] = None, - ) -> Optional[Any]: - msg = "You can't use `FakePublisher` directly." - raise NotImplementedError(msg) + @abstractmethod + def patch_command(self, cmd: "PublishCommand") -> "PublishCommand": + raise NotImplementedError async def _publish( self, - message: "SendableMessage", + cmd: "PublishCommand", *, - correlation_id: Optional[str] = None, - _extra_middlewares: Iterable["PublisherMiddleware"] = (), - **kwargs: Any, + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> Any: - """Publish a message.""" - publish_kwargs = { - "correlation_id": correlation_id, - **self.publish_kwargs, - **kwargs, - } + """This method should be called in subscriber flow only.""" + cmd = self.patch_command(cmd) - call: AsyncFunc = self.method - for m in chain(_extra_middlewares, self.middlewares): + call: AsyncFunc = self._producer.publish + for m in _extra_middlewares: call = partial(m, call) - return await call(message, **publish_kwargs) + return await call(cmd) + + async def publish( + self, + message: SendableMessage, + /, + *, + correlation_id: Optional[str] = None, + ) -> Optional[Any]: + msg = ( + f"`{self.__class__.__name__}` can be used only to publish " + "a response for `reply-to` or `RPC` messages." + ) + raise NotImplementedError(msg) async def request( self, @@ -63,12 +62,9 @@ async def request( /, *, correlation_id: Optional[str] = None, - _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> Any: msg = ( - "`FakePublisher` can be used only to publish " + f"`{self.__class__.__name__}` can be used only to publish " "a response for `reply-to` or `RPC` messages." ) - raise NotImplementedError( - msg, - ) + raise NotImplementedError(msg) diff --git a/faststream/_internal/publisher/proto.py b/faststream/_internal/publisher/proto.py index d2094b0e46..4037e1d285 100644 --- a/faststream/_internal/publisher/proto.py +++ b/faststream/_internal/publisher/proto.py @@ -6,6 +6,7 @@ from faststream._internal.proto import Endpoint from faststream._internal.types import MsgType +from faststream.response.response import PublishCommand from faststream.specification.base.proto import EndpointProto if TYPE_CHECKING: @@ -17,6 +18,7 @@ PublisherMiddleware, T_HandlerReturn, ) + from faststream.response.response import PublishCommand class ProducerProto(Protocol): @@ -24,27 +26,20 @@ class ProducerProto(Protocol): _decoder: "AsyncCallable" @abstractmethod - async def publish( - self, - message: "SendableMessage", - /, - *, - correlation_id: Optional[str] = None, - ) -> Optional[Any]: + async def publish(self, cmd: "PublishCommand") -> Optional[Any]: """Publishes a message asynchronously.""" ... @abstractmethod - async def request( - self, - message: "SendableMessage", - /, - *, - correlation_id: Optional[str] = None, - ) -> Any: + async def request(self, cmd: "PublishCommand") -> Any: """Publishes a message synchronously.""" ... + @abstractmethod + async def publish_batch(self, cmd: "PublishCommand") -> None: + """Publishes a messages batch asynchronously.""" + ... + class BasePublisherProto(Protocol): @abstractmethod @@ -64,12 +59,10 @@ async def publish( @abstractmethod async def _publish( self, - message: "SendableMessage", - /, + cmd: "PublishCommand", *, - correlation_id: Optional[str] = None, - _extra_middlewares: Iterable["PublisherMiddleware"] = (), - ) -> Optional[Any]: + _extra_middlewares: Iterable["PublisherMiddleware"], + ) -> None: """Private method to publish a message. Should be called inside `publish` method or as a step of `consume` scope. @@ -83,7 +76,6 @@ async def request( /, *, correlation_id: Optional[str] = None, - _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> Optional[Any]: """Publishes a message synchronously.""" ... diff --git a/faststream/_internal/publisher/usecase.py b/faststream/_internal/publisher/usecase.py index b52ea7c387..fbd735a783 100644 --- a/faststream/_internal/publisher/usecase.py +++ b/faststream/_internal/publisher/usecase.py @@ -1,5 +1,7 @@ -from collections.abc import Iterable +from collections.abc import Awaitable, Iterable +from functools import partial from inspect import unwrap +from itertools import chain from typing import ( TYPE_CHECKING, Annotated, @@ -13,13 +15,17 @@ from fast_depends.core import CallModel, build_call_model from typing_extensions import Doc, override +from faststream._internal.context.repository import context from faststream._internal.publisher.proto import PublisherProto from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper +from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import ( MsgType, P_HandlerParams, T_HandlerReturn, ) +from faststream.exceptions import NOT_CONNECTED_YET +from faststream.message.source_type import SourceType from faststream.specification.asyncapi.message import get_response_schema from faststream.specification.asyncapi.utils import to_camelcase @@ -30,6 +36,7 @@ BrokerMiddleware, PublisherMiddleware, ) + from faststream.response.response import PublishCommand class PublisherUsecase(PublisherProto[MsgType]): @@ -130,6 +137,73 @@ def __call__( self.calls.append(handler_call._original_call) return handler_call + async def _basic_publish( + self, + cmd: "PublishCommand", + *, + _extra_middlewares: Iterable["PublisherMiddleware"], + ) -> Any: + assert self._producer, NOT_CONNECTED_YET # nosec B101 + + pub: Callable[..., Awaitable[Any]] = self._producer.publish + + for pub_m in chain( + ( + _extra_middlewares + or ( + m(None, context=context).publish_scope + for m in self._broker_middlewares + ) + ), + self._middlewares, + ): + pub = partial(pub_m, pub) + + await pub(cmd) + + async def _basic_request( + self, + cmd: "PublishCommand", + ) -> Optional[Any]: + assert self._producer, NOT_CONNECTED_YET # nosec B101 + + request = self._producer.request + + for pub_m in chain( + (m(None, context=context).publish_scope for m in self._broker_middlewares), + self._middlewares, + ): + request = partial(pub_m, request) + + published_msg = await request(cmd) + + response_msg: Any = await process_msg( + msg=published_msg, + middlewares=self._broker_middlewares, + parser=self._producer._parser, + decoder=self._producer._decoder, + source_type=SourceType.Response, + ) + return response_msg + + async def _basic_publish_batch( + self, + cmd: "PublishCommand", + *, + _extra_middlewares: Iterable["PublisherMiddleware"], + ) -> Optional[Any]: + assert self._producer, NOT_CONNECTED_YET # nosec B101 + + pub = self._producer.publish_batch + + for pub_m in chain( + (m(None, context=context).publish_scope for m in self._broker_middlewares), + self._middlewares, + ): + pub = partial(pub_m, pub) + + await pub(cmd) + def get_payloads(self) -> list[tuple["AnyDict", str]]: payloads: list[tuple[AnyDict, str]] = [] diff --git a/faststream/_internal/subscriber/usecase.py b/faststream/_internal/subscriber/usecase.py index 75f4063a10..6eda9add2e 100644 --- a/faststream/_internal/subscriber/usecase.py +++ b/faststream/_internal/subscriber/usecase.py @@ -151,8 +151,6 @@ def _setup( # type: ignore[override] # dependant args state: "SetupState", ) -> None: - self.lock = MultiLock() - self._producer = producer self.graceful_timeout = graceful_timeout self.extra_context = extra_context @@ -191,6 +189,8 @@ def _setup( # type: ignore[override] @abstractmethod async def start(self) -> None: """Start the handler.""" + self.lock = MultiLock() + self.running = True @abstractmethod @@ -329,7 +329,7 @@ async def process_message(self, msg: MsgType) -> "Response": # enter all middlewares middlewares: list[BaseMiddleware] = [] for base_m in self._broker_middlewares: - middleware = base_m(msg) + middleware = base_m(msg, context=context) middlewares.append(middleware) await middleware.__aenter__() @@ -377,9 +377,7 @@ async def process_message(self, msg: MsgType) -> "Response": h.handler._publishers, ): await p._publish( - result_msg.body, - **result_msg.as_publish_kwargs(), - # publisher middlewares + result_msg.as_publish_command(), _extra_middlewares=(m.publish_scope for m in middlewares), ) diff --git a/faststream/_internal/subscriber/utils.py b/faststream/_internal/subscriber/utils.py index f3099b6490..5b6d0c990e 100644 --- a/faststream/_internal/subscriber/utils.py +++ b/faststream/_internal/subscriber/utils.py @@ -15,12 +15,14 @@ import anyio from typing_extensions import Literal, Self, overload +from faststream._internal.context.repository import context from faststream._internal.subscriber.acknowledgement_watcher import ( WatcherContext, get_watcher, ) from faststream._internal.types import MsgType from faststream._internal.utils.functions import fake_context, return_input, to_async +from faststream.message.source_type import SourceType if TYPE_CHECKING: from types import TracebackType @@ -41,6 +43,7 @@ async def process_msg( middlewares: Iterable["BrokerMiddleware[MsgType]"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], + source_type: SourceType = SourceType.Consume, ) -> None: ... @@ -50,6 +53,7 @@ async def process_msg( middlewares: Iterable["BrokerMiddleware[MsgType]"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], + source_type: SourceType = SourceType.Consume, ) -> "StreamMessage[MsgType]": ... @@ -58,6 +62,7 @@ async def process_msg( middlewares: Iterable["BrokerMiddleware[MsgType]"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], + source_type: SourceType = SourceType.Consume, ) -> Optional["StreamMessage[MsgType]"]: if msg is None: return None @@ -69,11 +74,12 @@ async def process_msg( ] = return_input for m in middlewares: - mid = m(msg) + mid = m(msg, context=context) await stack.enter_async_context(mid) return_msg = partial(mid.consume_scope, return_msg) parsed_msg = await parser(msg) + parsed_msg._source_type = source_type parsed_msg._decoded_body = await decoder(parsed_msg) return await return_msg(parsed_msg) diff --git a/faststream/_internal/testing/broker.py b/faststream/_internal/testing/broker.py index 7e33fd6ab5..f074b55bbc 100644 --- a/faststream/_internal/testing/broker.py +++ b/faststream/_internal/testing/broker.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock from faststream._internal.broker.broker import BrokerUsecase +from faststream._internal.subscriber.utils import MultiLock from faststream._internal.testing.app import TestApp from faststream._internal.testing.ast import is_contains_context_name from faststream._internal.utils.functions import sync_fake_context @@ -159,6 +160,7 @@ async def publisher_response_subscriber(msg: Any) -> None: for subscriber in broker._subscribers: subscriber.running = True + subscriber.lock = MultiLock() def _fake_close( self, diff --git a/faststream/_internal/types.py b/faststream/_internal/types.py index f7c47c9461..fad8c62f95 100644 --- a/faststream/_internal/types.py +++ b/faststream/_internal/types.py @@ -10,9 +10,11 @@ from typing_extensions import ParamSpec, TypeAlias -from faststream._internal.basic_types import AsyncFunc, AsyncFuncAny +from faststream._internal.basic_types import AsyncFuncAny +from faststream._internal.context.repository import ContextRepo from faststream.message import StreamMessage from faststream.middlewares import BaseMiddleware +from faststream.response.response import PublishCommand MsgType = TypeVar("MsgType") StreamMsg = TypeVar("StreamMsg", bound=StreamMessage[Any]) @@ -64,7 +66,18 @@ ] -BrokerMiddleware: TypeAlias = Callable[[Optional[MsgType]], BaseMiddleware] +class BrokerMiddleware(Protocol[MsgType]): + """Middleware builder interface.""" + + def __call__( + self, + msg: Optional[MsgType], + /, + *, + context: ContextRepo, + ) -> BaseMiddleware: ... + + SubscriberMiddleware: TypeAlias = Callable[ [AsyncFuncAny, MsgType], MsgType, @@ -76,7 +89,6 @@ class PublisherMiddleware(Protocol): def __call__( self, - call_next: AsyncFunc, - *__args: Any, - **__kwargs: Any, + call_next: Callable[[PublishCommand], Awaitable[PublishCommand]], + msg: PublishCommand, ) -> Any: ... diff --git a/faststream/confluent/annotations.py b/faststream/confluent/annotations.py index e3c1f82af9..3ee4a9bf6f 100644 --- a/faststream/confluent/annotations.py +++ b/faststream/confluent/annotations.py @@ -5,7 +5,6 @@ from faststream.confluent.broker import KafkaBroker as KB from faststream.confluent.message import KafkaMessage as KM from faststream.confluent.publisher.producer import AsyncConfluentFastProducer -from faststream.params import NoCast __all__ = ( "ContextRepo", @@ -13,7 +12,6 @@ "KafkaMessage", "KafkaProducer", "Logger", - "NoCast", ) KafkaMessage = Annotated[KM, Context("message")] diff --git a/faststream/confluent/broker/broker.py b/faststream/confluent/broker/broker.py index a2e28a0670..4451d0e8c0 100644 --- a/faststream/confluent/broker/broker.py +++ b/faststream/confluent/broker/broker.py @@ -23,10 +23,11 @@ from faststream.confluent.client import AsyncConfluentConsumer, AsyncConfluentProducer from faststream.confluent.config import ConfluentFastConfig from faststream.confluent.publisher.producer import AsyncConfluentFastProducer +from faststream.confluent.response import KafkaPublishCommand from faststream.confluent.schemas.params import ConsumerConnectionParams from faststream.confluent.security import parse_security -from faststream.exceptions import NOT_CONNECTED_YET from faststream.message import gen_cor_id +from faststream.response.publish_type import PublishType from .logging import make_kafka_logger_state from .registrator import KafkaRegistrator @@ -39,7 +40,6 @@ from faststream._internal.basic_types import ( AnyDict, - AsyncFunc, Decorator, LoggerProto, SendableMessage, @@ -49,6 +49,7 @@ CustomCallable, ) from faststream.confluent.config import ConfluentConfig + from faststream.confluent.message import KafkaMessage from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict @@ -474,64 +475,90 @@ def _subscriber_setup_extra(self) -> "AnyDict": @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - topic: str, - key: Optional[bytes] = None, + message: Annotated[ + "SendableMessage", + Doc("Message body to send."), + ], + topic: Annotated[ + str, + Doc("Topic where the message will be published."), + ], + *, + key: Union[bytes, str, None] = None, partition: Optional[int] = None, timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - *, - reply_to: str = "", - no_confirm: bool = False, - # extra options to be compatible with test client - **kwargs: Any, + headers: Annotated[ + Optional[dict[str, str]], + Doc("Message headers to store metainformation."), + ] = None, + correlation_id: Annotated[ + Optional[str], + Doc( + "Manual message **correlation_id** setter. " + "**correlation_id** is a useful option to trace messages.", + ), + ] = None, + reply_to: Annotated[ + str, + Doc("Reply message topic name to send response."), + ] = "", + no_confirm: Annotated[ + bool, + Doc("Do not wait for Kafka publish confirmation."), + ] = False, ) -> None: - correlation_id = correlation_id or gen_cor_id() + """Publish message directly. - await super().publish( + This method allows you to publish message in not AsyncAPI-documented way. You can use it in another frameworks + applications or to publish messages from time to time. + + Please, use `@broker.publisher(...)` or `broker.publisher(...).publish(...)` instead in a regular way. + """ + cmd = KafkaPublishCommand( message, - producer=self._producer, topic=topic, key=key, partition=partition, timestamp_ms=timestamp_ms, headers=headers, - correlation_id=correlation_id, reply_to=reply_to, no_confirm=no_confirm, - **kwargs, + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, ) + return await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] self, message: "SendableMessage", topic: str, - key: Optional[bytes] = None, + *, + key: Union[bytes, str, None] = None, partition: Optional[int] = None, timestamp_ms: Optional[int] = None, headers: Optional[dict[str, str]] = None, correlation_id: Optional[str] = None, timeout: float = 0.5, - ) -> Optional[Any]: - correlation_id = correlation_id or gen_cor_id() - - return await super().request( + ) -> "KafkaMessage": + cmd = KafkaPublishCommand( message, - producer=self._producer, topic=topic, key=key, partition=partition, timestamp_ms=timestamp_ms, headers=headers, - correlation_id=correlation_id, timeout=timeout, + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Request, ) + msg: KafkaMessage = await super()._basic_request(cmd, producer=self._producer) + return msg + async def publish_batch( self, - *msgs: "SendableMessage", + *messages: "SendableMessage", topic: str, partition: Optional[int] = None, timestamp_ms: Optional[int] = None, @@ -540,25 +567,20 @@ async def publish_batch( correlation_id: Optional[str] = None, no_confirm: bool = False, ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - correlation_id = correlation_id or gen_cor_id() - - call: AsyncFunc = self._producer.publish_batch - for m in self._middlewares: - call = partial(m(None).publish_scope, call) - - await call( - *msgs, + cmd = KafkaPublishCommand( + *messages, topic=topic, partition=partition, timestamp_ms=timestamp_ms, headers=headers, reply_to=reply_to, - correlation_id=correlation_id, no_confirm=no_confirm, + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, ) + await self._basic_publish_batch(cmd, producer=self._producer) + @override async def ping(self, timeout: Optional[float]) -> bool: sleep_time = (timeout or 10) / 10 diff --git a/faststream/confluent/opentelemetry/provider.py b/faststream/confluent/opentelemetry/provider.py index 8ebeea51d7..07031b5449 100644 --- a/faststream/confluent/opentelemetry/provider.py +++ b/faststream/confluent/opentelemetry/provider.py @@ -11,6 +11,7 @@ from confluent_kafka import Message from faststream._internal.basic_types import AnyDict + from faststream.confluent.response import KafkaPublishCommand from faststream.message import StreamMessage @@ -20,29 +21,29 @@ class BaseConfluentTelemetrySettingsProvider(TelemetrySettingsProvider[MsgType]) def __init__(self) -> None: self.messaging_system = "kafka" - def get_publish_attrs_from_kwargs( + def get_publish_attrs_from_cmd( self, - kwargs: "AnyDict", + cmd: "KafkaPublishCommand", ) -> "AnyDict": attrs = { SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, - SpanAttributes.MESSAGING_DESTINATION_NAME: kwargs["topic"], - SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: kwargs["correlation_id"], + SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.destination, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, } - if (partition := kwargs.get("partition")) is not None: - attrs[SpanAttributes.MESSAGING_KAFKA_DESTINATION_PARTITION] = partition + if cmd.partition is not None: + attrs[SpanAttributes.MESSAGING_KAFKA_DESTINATION_PARTITION] = cmd.partition - if (key := kwargs.get("key")) is not None: - attrs[SpanAttributes.MESSAGING_KAFKA_MESSAGE_KEY] = key + if cmd.key is not None: + attrs[SpanAttributes.MESSAGING_KAFKA_MESSAGE_KEY] = cmd.key return attrs def get_publish_destination_name( self, - kwargs: "AnyDict", + cmd: "KafkaPublishCommand", ) -> str: - return cast(str, kwargs["topic"]) + return cmd.destination class ConfluentTelemetrySettingsProvider( diff --git a/faststream/confluent/prometheus/middleware.py b/faststream/confluent/prometheus/middleware.py index 5aed2ce984..d294522330 100644 --- a/faststream/confluent/prometheus/middleware.py +++ b/faststream/confluent/prometheus/middleware.py @@ -1,15 +1,15 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Optional +from faststream._internal.constants import EMPTY from faststream.confluent.prometheus.provider import settings_provider_factory -from faststream.prometheus.middleware import BasePrometheusMiddleware -from faststream.types import EMPTY +from faststream.prometheus.middleware import PrometheusMiddleware if TYPE_CHECKING: from prometheus_client import CollectorRegistry -class KafkaPrometheusMiddleware(BasePrometheusMiddleware): +class KafkaPrometheusMiddleware(PrometheusMiddleware): def __init__( self, *, diff --git a/faststream/confluent/prometheus/provider.py b/faststream/confluent/prometheus/provider.py index d0e52375a5..e9e91a4587 100644 --- a/faststream/confluent/prometheus/provider.py +++ b/faststream/confluent/prometheus/provider.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Union, cast -from faststream.broker.message import MsgType, StreamMessage +from faststream.message.message import MsgType, StreamMessage from faststream.prometheus import ( ConsumeAttrs, MetricsSettingsProvider, @@ -10,7 +10,7 @@ if TYPE_CHECKING: from confluent_kafka import Message - from faststream.types import AnyDict + from faststream.confluent.response import KafkaPublishCommand class BaseConfluentMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): @@ -19,11 +19,11 @@ class BaseConfluentMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): def __init__(self) -> None: self.messaging_system = "kafka" - def get_publish_destination_name_from_kwargs( + def get_publish_destination_name_from_cmd( self, - kwargs: "AnyDict", + cmd: "KafkaPublishCommand", ) -> str: - return cast(str, kwargs["topic"]) + return cmd.destination class ConfluentMetricsSettingsProvider(BaseConfluentMetricsSettingsProvider["Message"]): diff --git a/faststream/confluent/publisher/fake.py b/faststream/confluent/publisher/fake.py new file mode 100644 index 0000000000..82d97ab682 --- /dev/null +++ b/faststream/confluent/publisher/fake.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING, Union + +from faststream._internal.publisher.fake import FakePublisher +from faststream.confluent.response import KafkaPublishCommand + +if TYPE_CHECKING: + from faststream._internal.publisher.proto import ProducerProto + from faststream.response.response import PublishCommand + + +class KafkaFakePublisher(FakePublisher): + """Publisher Interface implementation to use as RPC or REPLY TO answer publisher.""" + + def __init__( + self, + producer: "ProducerProto", + topic: str, + ) -> None: + super().__init__(producer=producer) + self.topic = topic + + def patch_command( + self, cmd: Union["PublishCommand", "KafkaPublishCommand"] + ) -> "KafkaPublishCommand": + real_cmd = KafkaPublishCommand.from_cmd(cmd) + real_cmd.destination = self.topic + return real_cmd diff --git a/faststream/confluent/publisher/producer.py b/faststream/confluent/publisher/producer.py index a4d9d9cf29..0ca1a00217 100644 --- a/faststream/confluent/publisher/producer.py +++ b/faststream/confluent/publisher/producer.py @@ -5,13 +5,13 @@ from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func from faststream.confluent.parser import AsyncConfluentParser -from faststream.exceptions import OperationForbiddenError +from faststream.exceptions import FeatureNotSupportedException from faststream.message import encode_message if TYPE_CHECKING: - from faststream._internal.basic_types import SendableMessage from faststream._internal.types import CustomCallable from faststream.confluent.client import AsyncConfluentProducer + from faststream.confluent.response import KafkaPublishCommand class AsyncConfluentFastProducer(ProducerProto): @@ -30,71 +30,42 @@ def __init__( self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) + async def stop(self) -> None: + await self._producer.stop() + @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - topic: str, - *, - key: Optional[bytes] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: str = "", - reply_to: str = "", - no_confirm: bool = False, + cmd: "KafkaPublishCommand", ) -> None: """Publish a message to a topic.""" - message, content_type = encode_message(message) + message, content_type = encode_message(cmd.body) headers_to_send = { "content-type": content_type or "", - "correlation_id": correlation_id, - **(headers or {}), + **cmd.headers_to_publish(), } - if reply_to: - headers_to_send["reply_to"] = headers_to_send.get( - "reply_to", - reply_to, - ) - await self._producer.send( - topic=topic, + topic=cmd.destination, value=message, - key=key, - partition=partition, - timestamp_ms=timestamp_ms, + key=cmd.key, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, headers=[(i, (j or "").encode()) for i, j in headers_to_send.items()], - no_confirm=no_confirm, + no_confirm=cmd.no_confirm, ) - async def stop(self) -> None: - await self._producer.stop() - async def publish_batch( self, - *msgs: "SendableMessage", - topic: str, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - correlation_id: str = "", - no_confirm: bool = False, + cmd: "KafkaPublishCommand", ) -> None: """Publish a batch of messages to a topic.""" batch = self._producer.create_batch() - headers_to_send = {"correlation_id": correlation_id, **(headers or {})} - - if reply_to: - headers_to_send["reply_to"] = headers_to_send.get( - "reply_to", - reply_to, - ) + headers_to_send = cmd.headers_to_publish() - for msg in msgs: + for msg in cmd.batch_bodies: message, content_type = encode_message(msg) if content_type: @@ -108,20 +79,21 @@ async def publish_batch( batch.append( key=None, value=message, - timestamp=timestamp_ms, + timestamp=cmd.timestamp_ms, headers=[(i, j.encode()) for i, j in final_headers.items()], ) await self._producer.send_batch( batch, - topic, - partition=partition, - no_confirm=no_confirm, + cmd.destination, + partition=cmd.partition, + no_confirm=cmd.no_confirm, ) @override - async def request(self, *args: Any, **kwargs: Any) -> Optional[Any]: + async def request( + self, + cmd: "KafkaPublishCommand", + ) -> Optional[Any]: msg = "Kafka doesn't support `request` method without test client." - raise OperationForbiddenError( - msg, - ) + raise FeatureNotSupportedException(msg) diff --git a/faststream/confluent/publisher/usecase.py b/faststream/confluent/publisher/usecase.py index 26169478ab..c623bba944 100644 --- a/faststream/confluent/publisher/usecase.py +++ b/faststream/confluent/publisher/usecase.py @@ -1,29 +1,26 @@ -from collections.abc import Awaitable, Iterable -from functools import partial -from itertools import chain +from collections.abc import Iterable from typing import ( TYPE_CHECKING, Any, - Callable, Optional, Union, - cast, ) from confluent_kafka import Message from typing_extensions import override from faststream._internal.publisher.usecase import PublisherUsecase -from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import MsgType -from faststream.exceptions import NOT_CONNECTED_YET -from faststream.message import SourceType, gen_cor_id +from faststream.confluent.response import KafkaPublishCommand +from faststream.message import gen_cor_id +from faststream.response.publish_type import PublishType if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict, AsyncFunc, SendableMessage + from faststream._internal.basic_types import SendableMessage from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.confluent.message import KafkaMessage from faststream.confluent.publisher.producer import AsyncConfluentFastProducer + from faststream.response.response import PublishCommand class LogicPublisher(PublisherUsecase[MsgType]): @@ -60,7 +57,7 @@ def __init__( self.topic = topic self.partition = partition self.reply_to = reply_to - self.headers = headers + self.headers = headers or {} self._producer = None @@ -79,42 +76,20 @@ async def request( headers: Optional[dict[str, str]] = None, correlation_id: Optional[str] = None, timeout: float = 0.5, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> "KafkaMessage": - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - kwargs: AnyDict = { - "key": key, - # basic args - "timeout": timeout, - "timestamp_ms": timestamp_ms, - "topic": topic or self.topic, - "partition": partition or self.partition, - "headers": headers or self.headers, - "correlation_id": correlation_id or gen_cor_id(), - } - - request: Callable[..., Awaitable[Any]] = self._producer.request - - for pub_m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - request = partial(pub_m, request) - - published_msg = await request(message, **kwargs) - - msg: KafkaMessage = await process_msg( - msg=published_msg, - middlewares=self._broker_middlewares, - parser=self._producer._parser, - decoder=self._producer._decoder, + cmd = KafkaPublishCommand( + message, + topic=topic or self.topic, + key=key, + partition=partition or self.partition, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + timestamp_ms=timestamp_ms, + timeout=timeout, + _publish_type=PublishType.Request, ) - msg._source_type = SourceType.Response + + msg: KafkaMessage = await self._basic_request(cmd) return msg @@ -122,7 +97,7 @@ class DefaultPublisher(LogicPublisher[Message]): def __init__( self, *, - key: Optional[bytes], + key: Union[bytes, str, None], topic: str, partition: Optional[int], headers: Optional[dict[str, str]], @@ -167,61 +142,38 @@ async def publish( reply_to: str = "", no_confirm: bool = False, ) -> None: - return await self._publish( + cmd = KafkaPublishCommand( message, - topic=topic, - key=key, - partition=partition, + topic=topic or self.topic, + key=key or self.key, + partition=partition or self.partition, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id, - reply_to=reply_to, no_confirm=no_confirm, - _extra_middlewares=(), + _publish_type=PublishType.Publish, ) + await self._basic_publish(cmd, _extra_middlewares=()) @override async def _publish( self, - message: "SendableMessage", - topic: str = "", + cmd: Union["PublishCommand", "KafkaPublishCommand"], *, - key: Optional[bytes] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - reply_to: str = "", - no_confirm: bool = False, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 + """This method should be called in subscriber flow only.""" + cmd = KafkaPublishCommand.from_cmd(cmd) - kwargs: AnyDict = { - "key": key or self.key, - # basic args - "no_confirm": no_confirm, - "topic": topic or self.topic, - "partition": partition or self.partition, - "timestamp_ms": timestamp_ms, - "headers": headers or self.headers, - "reply_to": reply_to or self.reply_to, - "correlation_id": correlation_id or gen_cor_id(), - } + cmd.destination = self.topic + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - call: Callable[..., Awaitable[None]] = self._producer.publish + cmd.partition = cmd.partition or self.partition + cmd.key = cmd.key or self.key - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) - - return await call(message, **kwargs) + await self._basic_publish(cmd, _extra_middlewares=_extra_middlewares) @override async def request( @@ -235,11 +187,9 @@ async def request( headers: Optional[dict[str, str]] = None, correlation_id: Optional[str] = None, timeout: float = 0.5, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), ) -> "KafkaMessage": return await super().request( - message=message, + message, topic=topic, key=key or self.key, partition=partition, @@ -247,7 +197,6 @@ async def request( headers=headers, correlation_id=correlation_id, timeout=timeout, - _extra_middlewares=_extra_middlewares, ) @@ -255,8 +204,7 @@ class BatchPublisher(LogicPublisher[tuple[Message, ...]]): @override async def publish( self, - message: Union["SendableMessage", Iterable["SendableMessage"]], - *extra_messages: "SendableMessage", + *messages: "SendableMessage", topic: str = "", partition: Optional[int] = None, timestamp_ms: Optional[int] = None, @@ -265,61 +213,35 @@ async def publish( reply_to: str = "", no_confirm: bool = False, ) -> None: - return await self._publish( - message, - *extra_messages, - topic=topic, - partition=partition, + cmd = KafkaPublishCommand( + *messages, + key=None, + topic=topic or self.topic, + partition=partition or self.partition, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id, - reply_to=reply_to, no_confirm=no_confirm, - _extra_middlewares=(), + _publish_type=PublishType.Publish, ) + await self._basic_publish_batch(cmd, _extra_middlewares=()) + @override async def _publish( self, - message: Union["SendableMessage", Iterable["SendableMessage"]], - *extra_messages: "SendableMessage", - topic: str = "", - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - reply_to: str = "", - no_confirm: bool = False, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), + cmd: Union["PublishCommand", "KafkaPublishCommand"], + *, + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - msgs: Iterable[SendableMessage] - if extra_messages: - msgs = (cast("SendableMessage", message), *extra_messages) - else: - msgs = cast(Iterable["SendableMessage"], message) - - kwargs: AnyDict = { - "topic": topic or self.topic, - "no_confirm": no_confirm, - "partition": partition or self.partition, - "timestamp_ms": timestamp_ms, - "headers": headers or self.headers, - "reply_to": reply_to or self.reply_to, - "correlation_id": correlation_id or gen_cor_id(), - } + """This method should be called in subscriber flow only.""" + cmd = KafkaPublishCommand.from_cmd(cmd, batch=True) - call: AsyncFunc = self._producer.publish_batch + cmd.destination = self.topic + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) + cmd.partition = cmd.partition or self.partition - await call(*msgs, **kwargs) + await self._basic_publish_batch(cmd, _extra_middlewares=_extra_middlewares) diff --git a/faststream/confluent/response.py b/faststream/confluent/response.py index 3d04d6ab35..2a4f84e21a 100644 --- a/faststream/confluent/response.py +++ b/faststream/confluent/response.py @@ -1,8 +1,10 @@ -from typing import TYPE_CHECKING, Optional +from collections.abc import Sequence +from typing import TYPE_CHECKING, Optional, Union from typing_extensions import override -from faststream.response import Response +from faststream.response.publish_type import PublishType +from faststream.response.response import PublishCommand, Response if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, SendableMessage @@ -34,3 +36,97 @@ def as_publish_kwargs(self) -> "AnyDict": "timestamp_ms": self.timestamp_ms, "key": self.key, } + + @override + def as_publish_command(self) -> "KafkaPublishCommand": + return KafkaPublishCommand( + self.body, + headers=self.headers, + correlation_id=self.correlation_id, + _publish_type=PublishType.Reply, + # Kafka specific + topic="", + key=self.key, + timestamp_ms=self.timestamp_ms, + ) + + +class KafkaPublishCommand(PublishCommand): + def __init__( + self, + message: "SendableMessage", + /, + *messages: "SendableMessage", + topic: str, + _publish_type: PublishType, + key: Union[bytes, str, None] = None, + partition: Optional[int] = None, + timestamp_ms: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + correlation_id: Optional[str] = None, + reply_to: str = "", + no_confirm: bool = False, + timeout: float = 0.5, + ) -> None: + super().__init__( + message, + destination=topic, + reply_to=reply_to, + correlation_id=correlation_id, + headers=headers, + _publish_type=_publish_type, + ) + self.extra_bodies = messages + + self.key = key + self.partition = partition + self.timestamp_ms = timestamp_ms + self.no_confirm = no_confirm + + # request option + self.timeout = timeout + + @property + def batch_bodies(self) -> tuple["SendableMessage", ...]: + if self.body: + return (self.body, *self.extra_bodies) + return self.extra_bodies + + @classmethod + def from_cmd( + cls, + cmd: Union["PublishCommand", "KafkaPublishCommand"], + *, + batch: bool = False, + ) -> "KafkaPublishCommand": + if isinstance(cmd, KafkaPublishCommand): + # NOTE: Should return a copy probably. + return cmd + + body, extra_bodies = cmd.body, [] + if batch and isinstance(body, Sequence) and not isinstance(body, str): + if body: + body, extra_bodies = body[0], body[1:] + else: + body = None + + return cls( + body, + *extra_bodies, + topic=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + _publish_type=cmd.publish_type, + ) + + def headers_to_publish(self) -> dict[str, str]: + headers = {} + + if self.correlation_id: + headers["correlation_id"] = self.correlation_id + + if self.reply_to: + headers["reply_to"] = self.reply_to + + return headers | self.headers diff --git a/faststream/confluent/subscriber/usecase.py b/faststream/confluent/subscriber/usecase.py index 1f88912fdc..be1ce66dfa 100644 --- a/faststream/confluent/subscriber/usecase.py +++ b/faststream/confluent/subscriber/usecase.py @@ -12,18 +12,18 @@ from confluent_kafka import KafkaException, Message from typing_extensions import override -from faststream._internal.publisher.fake import FakePublisher from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import MsgType from faststream.confluent.parser import AsyncConfluentParser +from faststream.confluent.publisher.fake import KafkaFakePublisher from faststream.confluent.schemas import TopicPartition if TYPE_CHECKING: from fast_depends.dependencies import Depends from faststream._internal.basic_types import AnyDict, LoggerProto - from faststream._internal.publisher.proto import ProducerProto + from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto from faststream._internal.setup import SetupState from faststream._internal.types import ( AsyncCallable, @@ -182,16 +182,14 @@ async def get_one( def _make_response_publisher( self, message: "StreamMessage[Any]", - ) -> Sequence[FakePublisher]: + ) -> Sequence["BasePublisherProto"]: if self._producer is None: return () return ( - FakePublisher( - self._producer.publish, - publish_kwargs={ - "topic": message.reply_to, - }, + KafkaFakePublisher( + self._producer, + topic=message.reply_to, ), ) diff --git a/faststream/confluent/testing.py b/faststream/confluent/testing.py index c0a3cfa7bc..973a7fb58d 100644 --- a/faststream/confluent/testing.py +++ b/faststream/confluent/testing.py @@ -26,6 +26,7 @@ from faststream._internal.basic_types import SendableMessage from faststream._internal.setup.logger import LoggerState from faststream.confluent.publisher.specified import SpecificationPublisher + from faststream.confluent.response import KafkaPublishCommand from faststream.confluent.subscriber.usecase import LogicSubscriber __all__ = ("TestKafkaBroker",) @@ -104,111 +105,89 @@ def _setup(self, logger_stater: "LoggerState") -> None: @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - topic: str, - key: Optional[bytes] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - *, - no_confirm: bool = False, - reply_to: str = "", + cmd: "KafkaPublishCommand", ) -> None: """Publish a message to the Kafka broker.""" incoming = build_message( - message=message, - topic=topic, - key=key, - partition=partition, - timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id or gen_cor_id(), - reply_to=reply_to, + message=cmd.body, + topic=cmd.destination, + key=cmd.key, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, + headers=cmd.headers, + correlation_id=cmd.correlation_id, + reply_to=cmd.reply_to, ) for handler in _find_handler( self.broker._subscribers, - topic, - partition, + cmd.destination, + cmd.partition, ): msg_to_send = ( [incoming] if isinstance(handler, BatchSubscriber) else incoming ) - await self._execute_handler(msg_to_send, topic, handler) + await self._execute_handler(msg_to_send, cmd.destination, handler) async def publish_batch( self, - *msgs: "SendableMessage", - topic: str, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - correlation_id: Optional[str] = None, - no_confirm: bool = False, + cmd: "KafkaPublishCommand", ) -> None: """Publish a batch of messages to the Kafka broker.""" for handler in _find_handler( self.broker._subscribers, - topic, - partition, + cmd.destination, + cmd.partition, ): messages = ( build_message( message=message, - topic=topic, - partition=partition, - timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id or gen_cor_id(), - reply_to=reply_to, + topic=cmd.destination, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, + headers=cmd.headers, + correlation_id=cmd.correlation_id, + reply_to=cmd.reply_to, ) - for message in msgs + for message in cmd.batch_bodies ) if isinstance(handler, BatchSubscriber): - await self._execute_handler(list(messages), topic, handler) + await self._execute_handler(list(messages), cmd.destination, handler) else: for m in messages: - await self._execute_handler(m, topic, handler) + await self._execute_handler(m, cmd.destination, handler) @override async def request( # type: ignore[override] self, - message: "SendableMessage", - topic: str, - key: Optional[bytes] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - *, - timeout: Optional[float] = 0.5, + cmd: "KafkaPublishCommand", ) -> "MockConfluentMessage": incoming = build_message( - message=message, - topic=topic, - key=key, - partition=partition, - timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id or gen_cor_id(), + message=cmd.body, + topic=cmd.destination, + key=cmd.key, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, + headers=cmd.headers, + correlation_id=cmd.correlation_id, ) for handler in _find_handler( self.broker._subscribers, - topic, - partition, + cmd.destination, + cmd.partition, ): msg_to_send = ( [incoming] if isinstance(handler, BatchSubscriber) else incoming ) - with anyio.fail_after(timeout): - return await self._execute_handler(msg_to_send, topic, handler) + with anyio.fail_after(cmd.timeout): + return await self._execute_handler( + msg_to_send, cmd.destination, handler + ) raise SubscriberNotFound @@ -282,7 +261,7 @@ def build_message( message: "SendableMessage", topic: str, *, - correlation_id: str, + correlation_id: Optional[str] = None, partition: Optional[int] = None, timestamp_ms: Optional[int] = None, key: Optional[bytes] = None, @@ -294,7 +273,7 @@ def build_message( k = key or b"" headers = { "content-type": content_type or "", - "correlation_id": correlation_id, + "correlation_id": correlation_id or gen_cor_id(), "reply_to": reply_to, **(headers or {}), } diff --git a/faststream/exceptions.py b/faststream/exceptions.py index 4b009adde2..4aabd52ae8 100644 --- a/faststream/exceptions.py +++ b/faststream/exceptions.py @@ -115,7 +115,7 @@ def __str__(self) -> str: ) -class OperationForbiddenError(FastStreamException, NotImplementedError): +class FeatureNotSupportedException(FastStreamException, NotImplementedError): # noqa: N818 """Raises at planned NotImplemented operation call.""" diff --git a/faststream/kafka/annotations.py b/faststream/kafka/annotations.py index 1f5c70d524..607be0ea8e 100644 --- a/faststream/kafka/annotations.py +++ b/faststream/kafka/annotations.py @@ -7,7 +7,6 @@ from faststream.kafka.broker import KafkaBroker as KB from faststream.kafka.message import KafkaMessage as KM from faststream.kafka.publisher.producer import AioKafkaFastProducer -from faststream.params import NoCast __all__ = ( "ContextRepo", @@ -15,7 +14,6 @@ "KafkaMessage", "KafkaProducer", "Logger", - "NoCast", ) Consumer = Annotated[AIOKafkaConsumer, Context("handler_.consumer")] diff --git a/faststream/kafka/broker/broker.py b/faststream/kafka/broker/broker.py index aa77e9c408..ce2969b12b 100644 --- a/faststream/kafka/broker/broker.py +++ b/faststream/kafka/broker/broker.py @@ -24,9 +24,11 @@ from faststream._internal.utils.data import filter_by_dict from faststream.exceptions import NOT_CONNECTED_YET from faststream.kafka.publisher.producer import AioKafkaFastProducer +from faststream.kafka.response import KafkaPublishCommand from faststream.kafka.schemas.params import ConsumerConnectionParams from faststream.kafka.security import parse_security from faststream.message import gen_cor_id +from faststream.response.publish_type import PublishType from .logging import make_kafka_logger_state from .registrator import KafkaRegistrator @@ -44,7 +46,6 @@ from faststream._internal.basic_types import ( AnyDict, - AsyncFunc, Decorator, LoggerProto, SendableMessage, @@ -53,6 +54,7 @@ BrokerMiddleware, CustomCallable, ) + from faststream.kafka.message import KafkaMessage from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict @@ -724,8 +726,6 @@ async def publish( # type: ignore[override] bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, - # extra options to be compatible with test client - **kwargs: Any, ) -> None: """Publish message directly. @@ -734,21 +734,19 @@ async def publish( # type: ignore[override] Please, use `@broker.publisher(...)` or `broker.publisher(...).publish(...)` instead in a regular way. """ - correlation_id = correlation_id or gen_cor_id() - - await super().publish( + cmd = KafkaPublishCommand( message, - producer=self._producer, topic=topic, key=key, partition=partition, timestamp_ms=timestamp_ms, headers=headers, - correlation_id=correlation_id, reply_to=reply_to, no_confirm=no_confirm, - **kwargs, + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, ) + return await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] @@ -809,31 +807,32 @@ async def request( # type: ignore[override] float, Doc("Timeout to send RPC request."), ] = 0.5, - ) -> Optional[Any]: - correlation_id = correlation_id or gen_cor_id() - - return await super().request( + ) -> "KafkaMessage": + cmd = KafkaPublishCommand( message, - producer=self._producer, topic=topic, key=key, partition=partition, timestamp_ms=timestamp_ms, headers=headers, - correlation_id=correlation_id, timeout=timeout, + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Request, ) + msg: KafkaMessage = await super()._basic_request(cmd, producer=self._producer) + return msg + async def publish_batch( self, - *msgs: Annotated[ + *messages: Annotated[ "SendableMessage", Doc("Messages bodies to send."), ], topic: Annotated[ str, Doc("Topic where the message will be published."), - ], + ] = "", partition: Annotated[ Optional[int], Doc( @@ -874,24 +873,20 @@ async def publish_batch( ) -> None: assert self._producer, NOT_CONNECTED_YET # nosec B101 - correlation_id = correlation_id or gen_cor_id() - - call: AsyncFunc = self._producer.publish_batch - - for m in self._middlewares: - call = partial(m(None).publish_scope, call) - - await call( - *msgs, + cmd = KafkaPublishCommand( + *messages, topic=topic, partition=partition, timestamp_ms=timestamp_ms, headers=headers, reply_to=reply_to, - correlation_id=correlation_id, no_confirm=no_confirm, + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, ) + await self._basic_publish_batch(cmd, producer=self._producer) + @override async def ping(self, timeout: Optional[float]) -> bool: sleep_time = (timeout or 10) / 10 diff --git a/faststream/kafka/opentelemetry/provider.py b/faststream/kafka/opentelemetry/provider.py index f9a43f54f6..cd2118ed33 100644 --- a/faststream/kafka/opentelemetry/provider.py +++ b/faststream/kafka/opentelemetry/provider.py @@ -11,6 +11,7 @@ from aiokafka import ConsumerRecord from faststream._internal.basic_types import AnyDict + from faststream.kafka.response import KafkaPublishCommand from faststream.message import StreamMessage @@ -20,29 +21,29 @@ class BaseKafkaTelemetrySettingsProvider(TelemetrySettingsProvider[MsgType]): def __init__(self) -> None: self.messaging_system = "kafka" - def get_publish_attrs_from_kwargs( + def get_publish_attrs_from_cmd( self, - kwargs: "AnyDict", + cmd: "KafkaPublishCommand", ) -> "AnyDict": attrs = { SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, - SpanAttributes.MESSAGING_DESTINATION_NAME: kwargs["topic"], - SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: kwargs["correlation_id"], + SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.destination, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, } - if (partition := kwargs.get("partition")) is not None: - attrs[SpanAttributes.MESSAGING_KAFKA_DESTINATION_PARTITION] = partition + if cmd.partition is not None: + attrs[SpanAttributes.MESSAGING_KAFKA_DESTINATION_PARTITION] = cmd.partition - if (key := kwargs.get("key")) is not None: - attrs[SpanAttributes.MESSAGING_KAFKA_MESSAGE_KEY] = key + if cmd.key is not None: + attrs[SpanAttributes.MESSAGING_KAFKA_MESSAGE_KEY] = cmd.key return attrs def get_publish_destination_name( self, - kwargs: "AnyDict", + cmd: "KafkaPublishCommand", ) -> str: - return cast(str, kwargs["topic"]) + return cmd.destination class KafkaTelemetrySettingsProvider( diff --git a/faststream/kafka/prometheus/middleware.py b/faststream/kafka/prometheus/middleware.py index 8624b37a0f..fd5948945a 100644 --- a/faststream/kafka/prometheus/middleware.py +++ b/faststream/kafka/prometheus/middleware.py @@ -1,15 +1,15 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Optional +from faststream._internal.constants import EMPTY from faststream.kafka.prometheus.provider import settings_provider_factory -from faststream.prometheus.middleware import BasePrometheusMiddleware -from faststream.types import EMPTY +from faststream.prometheus.middleware import PrometheusMiddleware if TYPE_CHECKING: from prometheus_client import CollectorRegistry -class KafkaPrometheusMiddleware(BasePrometheusMiddleware): +class KafkaPrometheusMiddleware(PrometheusMiddleware): def __init__( self, *, diff --git a/faststream/kafka/prometheus/provider.py b/faststream/kafka/prometheus/provider.py index 7193745c10..9ea5ffbd3c 100644 --- a/faststream/kafka/prometheus/provider.py +++ b/faststream/kafka/prometheus/provider.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Union, cast -from faststream.broker.message import MsgType, StreamMessage +from faststream.message.message import MsgType, StreamMessage from faststream.prometheus import ( MetricsSettingsProvider, ) @@ -9,8 +9,8 @@ if TYPE_CHECKING: from aiokafka import ConsumerRecord + from faststream.kafka.response import KafkaPublishCommand from faststream.prometheus import ConsumeAttrs - from faststream.types import AnyDict class BaseKafkaMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): @@ -19,11 +19,11 @@ class BaseKafkaMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): def __init__(self) -> None: self.messaging_system = "kafka" - def get_publish_destination_name_from_kwargs( + def get_publish_destination_name_from_cmd( self, - kwargs: "AnyDict", + cmd: "KafkaPublishCommand", ) -> str: - return cast(str, kwargs["topic"]) + return cmd.destination class KafkaMetricsSettingsProvider(BaseKafkaMetricsSettingsProvider["ConsumerRecord"]): diff --git a/faststream/kafka/publisher/fake.py b/faststream/kafka/publisher/fake.py new file mode 100644 index 0000000000..92ecbabcb8 --- /dev/null +++ b/faststream/kafka/publisher/fake.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING, Union + +from faststream._internal.publisher.fake import FakePublisher +from faststream.kafka.response import KafkaPublishCommand + +if TYPE_CHECKING: + from faststream._internal.publisher.proto import ProducerProto + from faststream.response.response import PublishCommand + + +class KafkaFakePublisher(FakePublisher): + """Publisher Interface implementation to use as RPC or REPLY TO answer publisher.""" + + def __init__( + self, + producer: "ProducerProto", + topic: str, + ) -> None: + super().__init__(producer=producer) + self.topic = topic + + def patch_command( + self, cmd: Union["PublishCommand", "KafkaPublishCommand"] + ) -> "KafkaPublishCommand": + real_cmd = KafkaPublishCommand.from_cmd(cmd) + real_cmd.destination = self.topic + return real_cmd diff --git a/faststream/kafka/publisher/producer.py b/faststream/kafka/publisher/producer.py index 93441fb2bd..661d899118 100644 --- a/faststream/kafka/publisher/producer.py +++ b/faststream/kafka/publisher/producer.py @@ -1,10 +1,10 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Optional from typing_extensions import override from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func -from faststream.exceptions import OperationForbiddenError +from faststream.exceptions import FeatureNotSupportedException from faststream.kafka.message import KafkaMessage from faststream.kafka.parser import AioKafkaParser from faststream.message import encode_message @@ -12,8 +12,8 @@ if TYPE_CHECKING: from aiokafka import AIOKafkaProducer - from faststream._internal.basic_types import SendableMessage from faststream._internal.types import CustomCallable + from faststream.kafka.response import KafkaPublishCommand class AioKafkaFastProducer(ProducerProto): @@ -35,73 +35,45 @@ def __init__( self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) + async def stop(self) -> None: + await self._producer.stop() + @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - topic: str, - *, - correlation_id: str, - key: Union[bytes, Any, None] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - no_confirm: bool = False, + cmd: "KafkaPublishCommand", ) -> None: """Publish a message to a topic.""" - message, content_type = encode_message(message) + message, content_type = encode_message(cmd.body) headers_to_send = { "content-type": content_type or "", - "correlation_id": correlation_id, - **(headers or {}), + **cmd.headers_to_publish(), } - if reply_to: - headers_to_send["reply_to"] = headers_to_send.get( - "reply_to", - reply_to, - ) - send_future = await self._producer.send( - topic=topic, + topic=cmd.destination, value=message, - key=key, - partition=partition, - timestamp_ms=timestamp_ms, + key=cmd.key, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, headers=[(i, (j or "").encode()) for i, j in headers_to_send.items()], ) - if not no_confirm: - await send_future - async def stop(self) -> None: - await self._producer.stop() + if not cmd.no_confirm: + await send_future async def publish_batch( self, - *msgs: "SendableMessage", - correlation_id: str, - topic: str, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - no_confirm: bool = False, + cmd: "KafkaPublishCommand", ) -> None: """Publish a batch of messages to a topic.""" batch = self._producer.create_batch() - headers_to_send = {"correlation_id": correlation_id, **(headers or {})} - - if reply_to: - headers_to_send["reply_to"] = headers_to_send.get( - "reply_to", - reply_to, - ) + headers_to_send = cmd.headers_to_publish() - for msg in msgs: - message, content_type = encode_message(msg) + for body in cmd.batch_bodies: + message, content_type = encode_message(body) if content_type: final_headers = { @@ -114,17 +86,22 @@ async def publish_batch( batch.append( key=None, value=message, - timestamp=timestamp_ms, + timestamp=cmd.timestamp_ms, headers=[(i, j.encode()) for i, j in final_headers.items()], ) - send_future = await self._producer.send_batch(batch, topic, partition=partition) - if not no_confirm: + send_future = await self._producer.send_batch( + batch, + cmd.destination, + partition=cmd.partition, + ) + if not cmd.no_confirm: await send_future @override - async def request(self, *args: Any, **kwargs: Any) -> Optional[Any]: + async def request( + self, + cmd: "KafkaPublishCommand", + ) -> Any: msg = "Kafka doesn't support `request` method without test client." - raise OperationForbiddenError( - msg, - ) + raise FeatureNotSupportedException(msg) diff --git a/faststream/kafka/publisher/usecase.py b/faststream/kafka/publisher/usecase.py index f9c326367f..495d539cea 100644 --- a/faststream/kafka/publisher/usecase.py +++ b/faststream/kafka/publisher/usecase.py @@ -1,30 +1,28 @@ -from collections.abc import Awaitable, Iterable -from functools import partial -from itertools import chain +from collections.abc import Iterable from typing import ( TYPE_CHECKING, Annotated, Any, - Callable, Optional, Union, - cast, ) from aiokafka import ConsumerRecord from typing_extensions import Doc, override from faststream._internal.publisher.usecase import PublisherUsecase -from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import MsgType -from faststream.exceptions import NOT_CONNECTED_YET -from faststream.message import SourceType, gen_cor_id +from faststream.kafka.message import KafkaMessage +from faststream.kafka.response import KafkaPublishCommand +from faststream.message import gen_cor_id +from faststream.response.publish_type import PublishType if TYPE_CHECKING: - from faststream._internal.basic_types import AsyncFunc, SendableMessage + from faststream._internal.basic_types import SendableMessage from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.kafka.message import KafkaMessage from faststream.kafka.publisher.producer import AioKafkaFastProducer + from faststream.response.response import PublishCommand class LogicPublisher(PublisherUsecase[MsgType]): @@ -61,7 +59,7 @@ def __init__( self.topic = topic self.partition = partition self.reply_to = reply_to - self.headers = headers + self.headers = headers or {} self._producer = None @@ -127,48 +125,20 @@ async def request( float, Doc("Timeout to send RPC request."), ] = 0.5, - # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), ) -> "KafkaMessage": - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - topic = topic or self.topic - partition = partition or self.partition - headers = headers or self.headers - correlation_id = correlation_id or gen_cor_id() - - request: Callable[..., Awaitable[Any]] = self._producer.request - - for pub_m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - request = partial(pub_m, request) - - published_msg = await request( + cmd = KafkaPublishCommand( message, - topic=topic, + topic=topic or self.topic, key=key, - partition=partition, - headers=headers, - timeout=timeout, - correlation_id=correlation_id, + partition=partition or self.partition, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, + timeout=timeout, + _publish_type=PublishType.Request, ) - msg: KafkaMessage = await process_msg( - msg=published_msg, - middlewares=self._broker_middlewares, - parser=self._producer._parser, - decoder=self._producer._decoder, - ) - msg._source_type = SourceType.Response + msg: KafkaMessage = await self._basic_request(cmd) return msg @@ -176,7 +146,7 @@ class DefaultPublisher(LogicPublisher[ConsumerRecord]): def __init__( self, *, - key: Optional[bytes], + key: Union[bytes, str, None], topic: str, partition: Optional[int], headers: Optional[dict[str, str]], @@ -271,66 +241,38 @@ async def publish( Doc("Do not wait for Kafka publish confirmation."), ] = False, ) -> None: - return await self._publish( + cmd = KafkaPublishCommand( message, - topic=topic, - key=key, - partition=partition, + topic=topic or self.topic, + key=key or self.key, + partition=partition or self.partition, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id, - reply_to=reply_to, no_confirm=no_confirm, - _extra_middlewares=(), + _publish_type=PublishType.Publish, ) + await self._basic_publish(cmd, _extra_middlewares=()) @override async def _publish( self, - message: "SendableMessage", - topic: str = "", + cmd: Union["PublishCommand", "KafkaPublishCommand"], *, - key: Union[bytes, Any, None] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - reply_to: str = "", - no_confirm: bool = False, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 + """This method should be called in subscriber flow only.""" + cmd = KafkaPublishCommand.from_cmd(cmd) - topic = topic or self.topic - key = key or self.key - partition = partition or self.partition - headers = headers or self.headers - reply_to = reply_to or self.reply_to - correlation_id = correlation_id or gen_cor_id() + cmd.destination = self.topic + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - call: Callable[..., Awaitable[None]] = self._producer.publish + cmd.partition = cmd.partition or self.partition + cmd.key = cmd.key or self.key - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) - - await call( - message, - topic=topic, - key=key, - partition=partition, - headers=headers, - reply_to=reply_to, - correlation_id=correlation_id, - timestamp_ms=timestamp_ms, - no_confirm=no_confirm, - ) + await self._basic_publish(cmd, _extra_middlewares=_extra_middlewares) @override async def request( @@ -391,14 +333,9 @@ async def request( float, Doc("Timeout to send RPC request."), ] = 0.5, - # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), ) -> "KafkaMessage": return await super().request( - message=message, + message, topic=topic, key=key or self.key, partition=partition, @@ -406,7 +343,6 @@ async def request( headers=headers, correlation_id=correlation_id, timeout=timeout, - _extra_middlewares=_extra_middlewares, ) @@ -414,11 +350,7 @@ class BatchPublisher(LogicPublisher[tuple["ConsumerRecord", ...]]): @override async def publish( self, - message: Annotated[ - Union["SendableMessage", Iterable["SendableMessage"]], - Doc("One message or iterable messages bodies to send."), - ], - *extra_messages: Annotated[ + *messages: Annotated[ "SendableMessage", Doc("Messages bodies to send."), ], @@ -464,66 +396,35 @@ async def publish( Doc("Do not wait for Kafka publish confirmation."), ] = False, ) -> None: - return await self._publish( - message, - *extra_messages, - topic=topic, - partition=partition, + cmd = KafkaPublishCommand( + *messages, + key=None, + topic=topic or self.topic, + partition=partition or self.partition, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, - headers=headers, - reply_to=reply_to, - correlation_id=correlation_id, no_confirm=no_confirm, - _extra_middlewares=(), + _publish_type=PublishType.Publish, ) + await self._basic_publish_batch(cmd, _extra_middlewares=()) + @override async def _publish( self, - message: Union["SendableMessage", Iterable["SendableMessage"]], - *extra_messages: "SendableMessage", - topic: str = "", - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - correlation_id: Optional[str] = None, - no_confirm: bool = False, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), + cmd: Union["PublishCommand", "KafkaPublishCommand"], + *, + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 + """This method should be called in subscriber flow only.""" + cmd = KafkaPublishCommand.from_cmd(cmd, batch=True) - msgs: Iterable[SendableMessage] - if extra_messages: - msgs = (cast("SendableMessage", message), *extra_messages) - else: - msgs = cast(Iterable["SendableMessage"], message) + cmd.destination = self.topic + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - topic = topic or self.topic - partition = partition or self.partition - headers = headers or self.headers - reply_to = reply_to or self.reply_to - correlation_id = correlation_id or gen_cor_id() + cmd.partition = cmd.partition or self.partition - call: AsyncFunc = self._producer.publish_batch - - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) - - await call( - *msgs, - topic=topic, - partition=partition, - headers=headers, - reply_to=reply_to, - correlation_id=correlation_id, - timestamp_ms=timestamp_ms, - no_confirm=no_confirm, - ) + await self._basic_publish_batch(cmd, _extra_middlewares=_extra_middlewares) diff --git a/faststream/kafka/response.py b/faststream/kafka/response.py index 3d04d6ab35..ac3cd1019d 100644 --- a/faststream/kafka/response.py +++ b/faststream/kafka/response.py @@ -1,8 +1,10 @@ -from typing import TYPE_CHECKING, Optional +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Optional, Union from typing_extensions import override -from faststream.response import Response +from faststream.response.publish_type import PublishType +from faststream.response.response import PublishCommand, Response if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, SendableMessage @@ -28,9 +30,95 @@ def __init__( self.key = key @override - def as_publish_kwargs(self) -> "AnyDict": - return { - **super().as_publish_kwargs(), - "timestamp_ms": self.timestamp_ms, - "key": self.key, - } + def as_publish_command(self) -> "KafkaPublishCommand": + return KafkaPublishCommand( + self.body, + headers=self.headers, + correlation_id=self.correlation_id, + _publish_type=PublishType.Reply, + # Kafka specific + topic="", + key=self.key, + timestamp_ms=self.timestamp_ms, + ) + + +class KafkaPublishCommand(PublishCommand): + def __init__( + self, + message: "SendableMessage", + /, + *messages: "SendableMessage", + topic: str, + _publish_type: PublishType, + key: Union[bytes, Any, None] = None, + partition: Optional[int] = None, + timestamp_ms: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + correlation_id: Optional[str] = None, + reply_to: str = "", + no_confirm: bool = False, + timeout: float = 0.5, + ) -> None: + super().__init__( + message, + destination=topic, + reply_to=reply_to, + correlation_id=correlation_id, + headers=headers, + _publish_type=_publish_type, + ) + self.extra_bodies = messages + + self.key = key + self.partition = partition + self.timestamp_ms = timestamp_ms + self.no_confirm = no_confirm + + # request option + self.timeout = timeout + + @property + def batch_bodies(self) -> tuple["SendableMessage", ...]: + if self.body: + return (self.body, *self.extra_bodies) + return self.extra_bodies + + @classmethod + def from_cmd( + cls, + cmd: Union["PublishCommand", "KafkaPublishCommand"], + *, + batch: bool = False, + ) -> "KafkaPublishCommand": + if isinstance(cmd, KafkaPublishCommand): + # NOTE: Should return a copy probably. + return cmd + + body, extra_bodies = cmd.body, [] + if batch and isinstance(body, Sequence) and not isinstance(body, str): + if body: + body, extra_bodies = body[0], body[1:] + else: + body = None + + return cls( + body, + *extra_bodies, + topic=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + _publish_type=cmd.publish_type, + ) + + def headers_to_publish(self) -> dict[str, str]: + headers = {} + + if self.correlation_id: + headers["correlation_id"] = self.correlation_id + + if self.reply_to: + headers["reply_to"] = self.reply_to + + return headers | self.headers diff --git a/faststream/kafka/subscriber/usecase.py b/faststream/kafka/subscriber/usecase.py index 7a38b79055..4d12aa9abd 100644 --- a/faststream/kafka/subscriber/usecase.py +++ b/faststream/kafka/subscriber/usecase.py @@ -14,7 +14,6 @@ from aiokafka.errors import ConsumerStoppedError, KafkaError from typing_extensions import override -from faststream._internal.publisher.fake import FakePublisher from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import ( @@ -26,6 +25,7 @@ from faststream._internal.utils.path import compile_path from faststream.kafka.message import KafkaAckableMessage, KafkaMessage from faststream.kafka.parser import AioKafkaBatchParser, AioKafkaParser +from faststream.kafka.publisher.fake import KafkaFakePublisher if TYPE_CHECKING: from aiokafka import AIOKafkaConsumer, ConsumerRecord @@ -33,7 +33,7 @@ from fast_depends.dependencies import Depends from faststream._internal.basic_types import AnyDict, LoggerProto - from faststream._internal.publisher.proto import ProducerProto + from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto from faststream._internal.setup import SetupState from faststream.message import StreamMessage @@ -203,16 +203,14 @@ async def get_one( def _make_response_publisher( self, message: "StreamMessage[Any]", - ) -> Sequence[FakePublisher]: + ) -> Sequence["BasePublisherProto"]: if self._producer is None: return () return ( - FakePublisher( - self._producer.publish, - publish_kwargs={ - "topic": message.reply_to, - }, + KafkaFakePublisher( + self._producer, + topic=message.reply_to, ), ) diff --git a/faststream/kafka/testing.py b/faststream/kafka/testing.py index ce35bbd1b5..a73e18ade8 100755 --- a/faststream/kafka/testing.py +++ b/faststream/kafka/testing.py @@ -28,6 +28,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage from faststream.kafka.publisher.specified import SpecificationPublisher + from faststream.kafka.response import KafkaPublishCommand from faststream.kafka.subscriber.usecase import LogicSubscriber __all__ = ("TestKafkaBroker",) @@ -99,113 +100,91 @@ def __init__(self, broker: KafkaBroker) -> None: @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - topic: str, - key: Optional[bytes] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - *, - reply_to: str = "", - no_confirm: bool = False, + cmd: "KafkaPublishCommand", ) -> None: """Publish a message to the Kafka broker.""" incoming = build_message( - message=message, - topic=topic, - key=key, - partition=partition, - timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id, - reply_to=reply_to, + message=cmd.body, + topic=cmd.destination, + key=cmd.key, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, + headers=cmd.headers, + correlation_id=cmd.correlation_id, + reply_to=cmd.reply_to, ) for handler in _find_handler( self.broker._subscribers, - topic, - partition, + cmd.destination, + cmd.partition, ): msg_to_send = ( [incoming] if isinstance(handler, BatchSubscriber) else incoming ) - await self._execute_handler(msg_to_send, topic, handler) + await self._execute_handler(msg_to_send, cmd.destination, handler) @override async def request( # type: ignore[override] self, - message: "SendableMessage", - topic: str, - key: Optional[bytes] = None, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - *, - timeout: Optional[float] = 0.5, + cmd: "KafkaPublishCommand", ) -> "ConsumerRecord": incoming = build_message( - message=message, - topic=topic, - key=key, - partition=partition, - timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id, + message=cmd.body, + topic=cmd.destination, + key=cmd.key, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, + headers=cmd.headers, + correlation_id=cmd.correlation_id, ) for handler in _find_handler( self.broker._subscribers, - topic, - partition, + cmd.destination, + cmd.partition, ): msg_to_send = ( [incoming] if isinstance(handler, BatchSubscriber) else incoming ) - with anyio.fail_after(timeout): - return await self._execute_handler(msg_to_send, topic, handler) + with anyio.fail_after(cmd.timeout): + return await self._execute_handler( + msg_to_send, cmd.destination, handler + ) raise SubscriberNotFound async def publish_batch( self, - *msgs: "SendableMessage", - topic: str, - partition: Optional[int] = None, - timestamp_ms: Optional[int] = None, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - correlation_id: Optional[str] = None, - no_confirm: bool = False, + cmd: "KafkaPublishCommand", ) -> None: """Publish a batch of messages to the Kafka broker.""" for handler in _find_handler( self.broker._subscribers, - topic, - partition, + cmd.destination, + cmd.partition, ): messages = ( build_message( message=message, - topic=topic, - partition=partition, - timestamp_ms=timestamp_ms, - headers=headers, - correlation_id=correlation_id, - reply_to=reply_to, + topic=cmd.destination, + partition=cmd.partition, + timestamp_ms=cmd.timestamp_ms, + headers=cmd.headers, + correlation_id=cmd.correlation_id, + reply_to=cmd.reply_to, ) - for message in msgs + for message in cmd.batch_bodies ) if isinstance(handler, BatchSubscriber): - await self._execute_handler(list(messages), topic, handler) + await self._execute_handler(list(messages), cmd.destination, handler) else: for m in messages: - await self._execute_handler(m, topic, handler) + await self._execute_handler(m, cmd.destination, handler) async def _execute_handler( self, diff --git a/faststream/middlewares/base.py b/faststream/middlewares/base.py index a9ff8642ba..49ea3526d9 100644 --- a/faststream/middlewares/base.py +++ b/faststream/middlewares/base.py @@ -1,19 +1,29 @@ -from typing import TYPE_CHECKING, Any, Optional +from collections.abc import Awaitable +from typing import TYPE_CHECKING, Any, Callable, Optional from typing_extensions import Self if TYPE_CHECKING: from types import TracebackType - from faststream._internal.basic_types import AsyncFunc, AsyncFuncAny + from faststream._internal.basic_types import AsyncFuncAny + from faststream._internal.context.repository import ContextRepo from faststream.message import StreamMessage + from faststream.response.response import PublishCommand class BaseMiddleware: """A base middleware class.""" - def __init__(self, msg: Optional[Any] = None) -> None: + def __init__( + self, + msg: Optional[Any], + /, + *, + context: "ContextRepo", + ) -> None: self.msg = msg + self.context = context async def on_receive(self) -> None: """Hook to call on message receive.""" @@ -73,10 +83,8 @@ async def consume_scope( async def on_publish( self, - msg: Any, - *args: Any, - **kwargs: Any, - ) -> Any: + msg: "PublishCommand", + ) -> "PublishCommand": """Asynchronously handle a publish event.""" return msg @@ -90,19 +98,13 @@ async def after_publish( async def publish_scope( self, - call_next: "AsyncFunc", - msg: Any, - *args: Any, - **kwargs: Any, + call_next: Callable[["PublishCommand"], Awaitable[Any]], + cmd: "PublishCommand", ) -> Any: """Publish a message and return an async iterator.""" err: Optional[Exception] = None try: - result = await call_next( - await self.on_publish(msg, *args, **kwargs), - *args, - **kwargs, - ) + result = await call_next(await self.on_publish(cmd)) except Exception as e: err = e diff --git a/faststream/middlewares/exception.py b/faststream/middlewares/exception.py index 2732037945..8530bb8a77 100644 --- a/faststream/middlewares/exception.py +++ b/faststream/middlewares/exception.py @@ -23,6 +23,7 @@ from types import TracebackType from faststream._internal.basic_types import AsyncFuncAny + from faststream._internal.context.repository import ContextRepo from faststream.message import StreamMessage @@ -48,61 +49,6 @@ ] -class BaseExceptionMiddleware(BaseMiddleware): - def __init__( - self, - handlers: CastedHandlers, - publish_handlers: CastedPublishingHandlers, - msg: Optional[Any] = None, - ) -> None: - super().__init__(msg) - self._handlers = handlers - self._publish_handlers = publish_handlers - - async def consume_scope( - self, - call_next: "AsyncFuncAny", - msg: "StreamMessage[Any]", - ) -> Any: - try: - return await call_next(await self.on_consume(msg)) - - except Exception as exc: - exc_type = type(exc) - - for handler_type, handler in self._publish_handlers: - if issubclass(exc_type, handler_type): - return await handler(exc) - - raise - - async def after_processed( - self, - exc_type: Optional[type[BaseException]] = None, - exc_val: Optional[BaseException] = None, - exc_tb: Optional["TracebackType"] = None, - ) -> Optional[bool]: - if exc_type: - for handler_type, handler in self._handlers: - if issubclass(exc_type, handler_type): - # TODO: remove it after context will be moved to middleware - # In case parser/decoder error occurred - scope: AbstractContextManager[Any] - if not context.get_local("message"): - scope = context.scope("message", self.msg) - else: - scope = sync_fake_context() - - with scope: - await handler(exc_val) - - return True - - return False - - return None - - class ExceptionMiddleware: __slots__ = ("_handlers", "_publish_handlers") @@ -195,14 +141,78 @@ def default_wrapper( return default_wrapper - def __call__(self, msg: Optional[Any]) -> BaseExceptionMiddleware: + def __call__( + self, + msg: Optional[Any], + /, + *, + context: "ContextRepo", + ) -> "_BaseExceptionMiddleware": """Real middleware runtime constructor.""" - return BaseExceptionMiddleware( + return _BaseExceptionMiddleware( handlers=self._handlers, publish_handlers=self._publish_handlers, + context=context, msg=msg, ) +class _BaseExceptionMiddleware(BaseMiddleware): + def __init__( + self, + *, + handlers: CastedHandlers, + publish_handlers: CastedPublishingHandlers, + context: "ContextRepo", + msg: Optional[Any], + ) -> None: + super().__init__(msg, context=context) + self._handlers = handlers + self._publish_handlers = publish_handlers + + async def consume_scope( + self, + call_next: "AsyncFuncAny", + msg: "StreamMessage[Any]", + ) -> Any: + try: + return await call_next(await self.on_consume(msg)) + + except Exception as exc: + exc_type = type(exc) + + for handler_type, handler in self._publish_handlers: + if issubclass(exc_type, handler_type): + return await handler(exc) + + raise + + async def after_processed( + self, + exc_type: Optional[type[BaseException]] = None, + exc_val: Optional[BaseException] = None, + exc_tb: Optional["TracebackType"] = None, + ) -> Optional[bool]: + if exc_type: + for handler_type, handler in self._handlers: + if issubclass(exc_type, handler_type): + # TODO: remove it after context will be moved to middleware + # In case parser/decoder error occurred + scope: AbstractContextManager[Any] + if not context.get_local("message"): + scope = context.scope("message", self.msg) + else: + scope = sync_fake_context() + + with scope: + await handler(exc_val) + + return True + + return False + + return None + + async def ignore_handler(exception: IgnoredException) -> NoReturn: raise exception diff --git a/faststream/middlewares/logging.py b/faststream/middlewares/logging.py index 57ff030a94..f24d507266 100644 --- a/faststream/middlewares/logging.py +++ b/faststream/middlewares/logging.py @@ -2,41 +2,67 @@ from typing import TYPE_CHECKING, Any, Optional from faststream._internal.context.repository import context -from faststream._internal.setup.logger import LoggerState from faststream.exceptions import IgnoredException +from faststream.message.source_type import SourceType from .base import BaseMiddleware if TYPE_CHECKING: from types import TracebackType + from faststream._internal.basic_types import AsyncFuncAny + from faststream._internal.context.repository import ContextRepo + from faststream._internal.setup.logger import LoggerState from faststream.message import StreamMessage class CriticalLogMiddleware: - def __init__(self, logger: LoggerState) -> None: + def __init__(self, logger: "LoggerState") -> None: """Initialize the class.""" self.logger = logger - def __call__(self, msg: Optional[Any] = None) -> Any: - return LoggingMiddleware(logger=self.logger) + def __call__( + self, + msg: Optional[Any], + /, + *, + context: "ContextRepo", + ) -> "_LoggingMiddleware": + return _LoggingMiddleware( + logger=self.logger, + msg=msg, + context=context, + ) -class LoggingMiddleware(BaseMiddleware): +class _LoggingMiddleware(BaseMiddleware): """A middleware class for logging critical errors.""" - def __init__(self, logger: LoggerState) -> None: + def __init__( + self, + *, + logger: "LoggerState", + context: "ContextRepo", + msg: Optional[Any], + ) -> None: + super().__init__(msg, context=context) self.logger = logger + self._source_type = SourceType.Consume - async def on_consume( + async def consume_scope( self, + call_next: "AsyncFuncAny", msg: "StreamMessage[Any]", ) -> "StreamMessage[Any]": - self.logger.log( - "Received", - extra=context.get_local("log_context", {}), - ) - return await super().on_consume(msg) + source_type = self._source_type = msg._source_type + + if source_type is not SourceType.Response: + self.logger.log( + "Received", + extra=context.get_local("log_context", {}), + ) + + return await call_next(msg) async def __aexit__( self, @@ -45,26 +71,28 @@ async def __aexit__( exc_tb: Optional["TracebackType"] = None, ) -> bool: """Asynchronously called after processing.""" - c = context.get_local("log_context", {}) - - if exc_type: - if issubclass(exc_type, IgnoredException): - self.logger.log( - log_level=logging.INFO, - message=exc_val, - extra=c, - ) - else: - self.logger.log( - log_level=logging.ERROR, - message=f"{exc_type.__name__}: {exc_val}", - exc_info=exc_val, - extra=c, - ) - - self.logger.log(message="Processed", extra=c) - - await super().after_processed(exc_type, exc_val, exc_tb) + if self._source_type is not SourceType.Response: + c = context.get_local("log_context", {}) + + if exc_type: + if issubclass(exc_type, IgnoredException): + self.logger.log( + log_level=logging.INFO, + message=exc_val, + extra=c, + ) + + else: + self.logger.log( + log_level=logging.ERROR, + message=f"{exc_type.__name__}: {exc_val}", + exc_info=exc_val, + extra=c, + ) + + self.logger.log(message="Processed", extra=c) + + await super().__aexit__(exc_type, exc_val, exc_tb) # Exception was not processed return False diff --git a/faststream/nats/annotations.py b/faststream/nats/annotations.py index 9bd2e29066..5aa74aa8d0 100644 --- a/faststream/nats/annotations.py +++ b/faststream/nats/annotations.py @@ -8,12 +8,7 @@ from faststream.annotations import ContextRepo, Logger from faststream.nats.broker import NatsBroker as _Broker from faststream.nats.message import NatsMessage as _Message -from faststream.nats.publisher.producer import ( - NatsFastProducer as _CoreProducer, - NatsJSFastProducer as _JsProducer, -) from faststream.nats.subscriber.usecase import OBJECT_STORAGE_CONTEXT_KEY -from faststream.params import NoCast __all__ = ( "Client", @@ -22,7 +17,6 @@ "Logger", "NatsBroker", "NatsMessage", - "NoCast", "ObjectStorage", ) @@ -31,5 +25,3 @@ NatsBroker = Annotated[_Broker, Context("broker")] Client = Annotated[_NatsClient, Context("broker._connection")] JsClient = Annotated[_JetStream, Context("broker._stream")] -NatsProducer = Annotated[_CoreProducer, Context("broker._producer")] -NatsJsProducer = Annotated[_JsProducer, Context("broker._js_producer")] diff --git a/faststream/nats/broker/broker.py b/faststream/nats/broker/broker.py index b9ef1afdfb..09aed61b54 100644 --- a/faststream/nats/broker/broker.py +++ b/faststream/nats/broker/broker.py @@ -35,8 +35,10 @@ from faststream.message import gen_cor_id from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer from faststream.nats.publisher.producer import NatsFastProducer, NatsJSFastProducer +from faststream.nats.response import NatsPublishCommand from faststream.nats.security import parse_security from faststream.nats.subscriber.specified import SpecificationSubscriber +from faststream.response.publish_type import PublishType from .logging import make_nats_logger_state from .registrator import NatsRegistrator @@ -724,30 +726,21 @@ async def publish( # type: ignore[override] Please, use `@broker.publisher(...)` or `broker.publisher(...).publish(...)` instead in a regular way. """ - publish_kwargs: AnyDict = { - "subject": subject, - "headers": headers, - "reply_to": reply_to, - } + cmd = NatsPublishCommand( + message=message, + correlation_id=correlation_id or gen_cor_id(), + subject=subject, + headers=headers, + reply_to=reply_to, + stream=stream, + timeout=timeout, + _publish_type=PublishType.Publish, + ) producer: Optional[ProducerProto] - if stream is None: - producer = self._producer - else: - producer = self._js_producer - publish_kwargs.update( - { - "stream": stream, - "timeout": timeout, - }, - ) + producer = self._producer if stream is None else self._js_producer - await super().publish( - message, - producer=producer, - correlation_id=correlation_id or gen_cor_id(), - **publish_kwargs, - ) + await super()._basic_publish(cmd, producer=producer) @override async def request( # type: ignore[override] @@ -789,26 +782,20 @@ async def request( # type: ignore[override] Doc("Timeout to send message to NATS."), ] = 0.5, ) -> "NatsMessage": - publish_kwargs = { - "subject": subject, - "headers": headers, - "timeout": timeout, - } + cmd = NatsPublishCommand( + message=message, + correlation_id=correlation_id or gen_cor_id(), + subject=subject, + headers=headers, + timeout=timeout, + stream=stream, + _publish_type=PublishType.Request, + ) producer: Optional[ProducerProto] - if stream is None: - producer = self._producer + producer = self._producer if stream is None else self._js_producer - else: - producer = self._js_producer - publish_kwargs.update({"stream": stream}) - - msg: NatsMessage = await super().request( - message, - producer=producer, - correlation_id=correlation_id or gen_cor_id(), - **publish_kwargs, - ) + msg: NatsMessage = await super()._basic_request(cmd, producer=producer) return msg @override diff --git a/faststream/nats/opentelemetry/provider.py b/faststream/nats/opentelemetry/provider.py index 93364d7bb2..32d9d2d4e1 100644 --- a/faststream/nats/opentelemetry/provider.py +++ b/faststream/nats/opentelemetry/provider.py @@ -4,7 +4,6 @@ from nats.aio.msg import Msg from opentelemetry.semconv.trace import SpanAttributes -from faststream.__about__ import SERVICE_NAME from faststream._internal.types import MsgType from faststream.opentelemetry import TelemetrySettingsProvider from faststream.opentelemetry.consts import MESSAGING_DESTINATION_PUBLISH_NAME @@ -12,6 +11,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict from faststream.message import StreamMessage + from faststream.nats.response import NatsPublishCommand class BaseNatsTelemetrySettingsProvider(TelemetrySettingsProvider[MsgType]): @@ -20,22 +20,21 @@ class BaseNatsTelemetrySettingsProvider(TelemetrySettingsProvider[MsgType]): def __init__(self) -> None: self.messaging_system = "nats" - def get_publish_attrs_from_kwargs( + def get_publish_attrs_from_cmd( self, - kwargs: "AnyDict", + cmd: "NatsPublishCommand", ) -> "AnyDict": return { SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, - SpanAttributes.MESSAGING_DESTINATION_NAME: kwargs["subject"], - SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: kwargs["correlation_id"], + SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.destination, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, } def get_publish_destination_name( self, - kwargs: "AnyDict", + cmd: "NatsPublishCommand", ) -> str: - subject: str = kwargs.get("subject", SERVICE_NAME) - return subject + return cmd.destination class NatsTelemetrySettingsProvider(BaseNatsTelemetrySettingsProvider["Msg"]): diff --git a/faststream/nats/prometheus/middleware.py b/faststream/nats/prometheus/middleware.py index e517fa89a7..9620cd651c 100644 --- a/faststream/nats/prometheus/middleware.py +++ b/faststream/nats/prometheus/middleware.py @@ -1,15 +1,15 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Optional +from faststream._internal.constants import EMPTY from faststream.nats.prometheus.provider import settings_provider_factory -from faststream.prometheus.middleware import BasePrometheusMiddleware -from faststream.types import EMPTY +from faststream.prometheus.middleware import PrometheusMiddleware if TYPE_CHECKING: from prometheus_client import CollectorRegistry -class NatsPrometheusMiddleware(BasePrometheusMiddleware): +class NatsPrometheusMiddleware(PrometheusMiddleware): def __init__( self, *, diff --git a/faststream/nats/prometheus/provider.py b/faststream/nats/prometheus/provider.py index 7e01ce51e8..6b5585eeb9 100644 --- a/faststream/nats/prometheus/provider.py +++ b/faststream/nats/prometheus/provider.py @@ -1,16 +1,16 @@ from collections.abc import Sequence -from typing import TYPE_CHECKING, Union, cast +from typing import TYPE_CHECKING, Union from nats.aio.msg import Msg -from faststream.broker.message import MsgType, StreamMessage +from faststream.message.message import MsgType, StreamMessage from faststream.prometheus import ( ConsumeAttrs, MetricsSettingsProvider, ) if TYPE_CHECKING: - from faststream.types import AnyDict + from faststream.nats.response import NatsPublishCommand class BaseNatsMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): @@ -19,11 +19,11 @@ class BaseNatsMetricsSettingsProvider(MetricsSettingsProvider[MsgType]): def __init__(self) -> None: self.messaging_system = "nats" - def get_publish_destination_name_from_kwargs( + def get_publish_destination_name_from_cmd( self, - kwargs: "AnyDict", + cmd: "NatsPublishCommand", ) -> str: - return cast(str, kwargs["subject"]) + return cmd.destination class NatsMetricsSettingsProvider(BaseNatsMetricsSettingsProvider["Msg"]): diff --git a/faststream/nats/publisher/fake.py b/faststream/nats/publisher/fake.py new file mode 100644 index 0000000000..7c70536e34 --- /dev/null +++ b/faststream/nats/publisher/fake.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING, Union + +from faststream._internal.publisher.fake import FakePublisher +from faststream.nats.response import NatsPublishCommand + +if TYPE_CHECKING: + from faststream._internal.publisher.proto import ProducerProto + from faststream.response.response import PublishCommand + + +class NatsFakePublisher(FakePublisher): + """Publisher Interface implementation to use as RPC or REPLY TO answer publisher.""" + + def __init__( + self, + producer: "ProducerProto", + subject: str, + ) -> None: + super().__init__(producer=producer) + self.subject = subject + + def patch_command( + self, cmd: Union["PublishCommand", "NatsPublishCommand"] + ) -> "NatsPublishCommand": + real_cmd = NatsPublishCommand.from_cmd(cmd) + real_cmd.destination = self.subject + return real_cmd diff --git a/faststream/nats/publisher/producer.py b/faststream/nats/publisher/producer.py index 05d3a81402..2af4fdfc48 100644 --- a/faststream/nats/publisher/producer.py +++ b/faststream/nats/publisher/producer.py @@ -7,6 +7,7 @@ from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func +from faststream.exceptions import FeatureNotSupportedException from faststream.message import encode_message from faststream.nats.parser import NatsParser @@ -15,11 +16,11 @@ from nats.aio.msg import Msg from nats.js import JetStreamContext - from faststream._internal.basic_types import SendableMessage from faststream._internal.types import ( AsyncCallable, CustomCallable, ) + from faststream.nats.response import NatsPublishCommand class NatsFastProducer(ProducerProto): @@ -44,54 +45,49 @@ def __init__( @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - subject: str, - *, - correlation_id: str, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - **kwargs: Any, # suprress stream option + cmd: "NatsPublishCommand", ) -> None: - payload, content_type = encode_message(message) + payload, content_type = encode_message(cmd.body) headers_to_send = { "content-type": content_type or "", - "correlation_id": correlation_id, - **(headers or {}), + **cmd.headers_to_publish(), } await self._connection.publish( - subject=subject, + subject=cmd.destination, payload=payload, - reply=reply_to, + reply=cmd.reply_to, headers=headers_to_send, ) @override async def request( # type: ignore[override] self, - message: "SendableMessage", - subject: str, - *, - correlation_id: str, - headers: Optional[dict[str, str]] = None, - timeout: float = 0.5, + cmd: "NatsPublishCommand", ) -> "Msg": - payload, content_type = encode_message(message) + payload, content_type = encode_message(cmd.body) headers_to_send = { "content-type": content_type or "", - "correlation_id": correlation_id, - **(headers or {}), + **cmd.headers_to_publish(), } return await self._connection.request( - subject=subject, + subject=cmd.destination, payload=payload, headers=headers_to_send, - timeout=timeout, + timeout=cmd.timeout, ) + @override + async def publish_batch( + self, + cmd: "NatsPublishCommand", + ) -> None: + msg = "NATS doesn't support publishing in batches." + raise FeatureNotSupportedException(msg) + class NatsJSFastProducer(ProducerProto): """A class to represent a NATS JetStream producer.""" @@ -115,32 +111,21 @@ def __init__( @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - subject: str, - *, - correlation_id: str, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - stream: Optional[str] = None, - timeout: Optional[float] = None, + cmd: "NatsPublishCommand", ) -> Optional[Any]: - payload, content_type = encode_message(message) + payload, content_type = encode_message(cmd.body) headers_to_send = { "content-type": content_type or "", - "correlation_id": correlation_id, - **(headers or {}), + **cmd.headers_to_publish(js=True), } - if reply_to: - headers_to_send.update({"reply_to": reply_to}) - await self._connection.publish( - subject=subject, + subject=cmd.destination, payload=payload, headers=headers_to_send, - stream=stream, - timeout=timeout, + stream=cmd.stream, + timeout=cmd.timeout, ) return None @@ -148,15 +133,9 @@ async def publish( # type: ignore[override] @override async def request( # type: ignore[override] self, - message: "SendableMessage", - subject: str, - *, - correlation_id: str, - headers: Optional[dict[str, str]] = None, - stream: Optional[str] = None, - timeout: float = 0.5, + cmd: "NatsPublishCommand", ) -> "Msg": - payload, content_type = encode_message(message) + payload, content_type = encode_message(cmd.body) reply_to = self._connection._nc.new_inbox() future: asyncio.Future[Msg] = asyncio.Future() @@ -165,18 +144,17 @@ async def request( # type: ignore[override] headers_to_send = { "content-type": content_type or "", - "correlation_id": correlation_id, "reply_to": reply_to, - **(headers or {}), + **cmd.headers_to_publish(js=False), } - with anyio.fail_after(timeout): + with anyio.fail_after(cmd.timeout): await self._connection.publish( - subject=subject, + subject=cmd.destination, payload=payload, headers=headers_to_send, - stream=stream, - timeout=timeout, + stream=cmd.stream, + timeout=cmd.timeout, ) msg = await future @@ -191,3 +169,11 @@ async def request( # type: ignore[override] raise nats.errors.NoRespondersError return msg + + @override + async def publish_batch( + self, + cmd: "NatsPublishCommand", + ) -> None: + msg = "NATS doesn't support publishing in batches." + raise FeatureNotSupportedException(msg) diff --git a/faststream/nats/publisher/usecase.py b/faststream/nats/publisher/usecase.py index 169014e9fc..880479546c 100644 --- a/faststream/nats/publisher/usecase.py +++ b/faststream/nats/publisher/usecase.py @@ -1,11 +1,8 @@ -from collections.abc import Awaitable, Iterable -from functools import partial -from itertools import chain +from collections.abc import Iterable from typing import ( TYPE_CHECKING, Annotated, Any, - Callable, Optional, Union, ) @@ -14,16 +11,17 @@ from typing_extensions import Doc, override from faststream._internal.publisher.usecase import PublisherUsecase -from faststream._internal.subscriber.utils import process_msg -from faststream.exceptions import NOT_CONNECTED_YET -from faststream.message import SourceType, gen_cor_id +from faststream.message import gen_cor_id +from faststream.nats.response import NatsPublishCommand +from faststream.response.publish_type import PublishType if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict, SendableMessage + from faststream._internal.basic_types import SendableMessage from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.nats.message import NatsMessage from faststream.nats.publisher.producer import NatsFastProducer, NatsJSFastProducer from faststream.nats.schemas import JStream + from faststream.response.response import PublishCommand class LogicPublisher(PublisherUsecase[Msg]): @@ -62,7 +60,7 @@ def __init__( self.subject = subject self.stream = stream self.timeout = timeout - self.headers = headers + self.headers = headers or {} self.reply_to = reply_to @override @@ -94,55 +92,37 @@ async def publish( Can be omitted without any effect. timeout (float, optional): Timeout to send message to NATS in seconds (default is `None`). """ - return await self._publish( + cmd = NatsPublishCommand( message, - subject=subject, - headers=headers, - reply_to=reply_to, - correlation_id=correlation_id, - stream=stream, - timeout=timeout, - _extra_middlewares=(), + subject=subject or self.subject, + headers=self.headers | (headers or {}), + reply_to=reply_to or self.reply_to, + correlation_id=correlation_id or gen_cor_id(), + stream=stream or getattr(self.stream, "name", None), + timeout=timeout or self.timeout, + _publish_type=PublishType.Publish, ) + return await self._basic_publish(cmd, _extra_middlewares=()) @override async def _publish( self, - message: "SendableMessage", - subject: str = "", + cmd: Union["PublishCommand", "NatsPublishCommand"], *, - headers: Optional[dict[str, str]] = None, - reply_to: str = "", - correlation_id: Optional[str] = None, - stream: Optional[str] = None, - timeout: Optional[float] = None, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 + """This method should be called in subscriber flow only.""" + cmd = NatsPublishCommand.from_cmd(cmd) - kwargs: AnyDict = { - "subject": subject or self.subject, - "headers": headers or self.headers, - "reply_to": reply_to or self.reply_to, - "correlation_id": correlation_id or gen_cor_id(), - } + cmd.destination = self.subject + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - if stream := stream or getattr(self.stream, "name", None): - kwargs.update({"stream": stream, "timeout": timeout or self.timeout}) + if self.stream: + cmd.stream = self.stream.name + cmd.timeout = self.timeout - call: Callable[..., Awaitable[Any]] = self._producer.publish - - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) - - await call(message, **kwargs) + return await self._basic_publish(cmd, _extra_middlewares=_extra_middlewares) @override async def request( @@ -177,44 +157,18 @@ async def request( float, Doc("Timeout to send message to NATS."), ] = 0.5, - # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), ) -> "NatsMessage": - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - kwargs: AnyDict = { - "subject": subject or self.subject, - "headers": headers or self.headers, - "timeout": timeout or self.timeout, - "correlation_id": correlation_id or gen_cor_id(), - } - - request: Callable[..., Awaitable[Any]] = self._producer.request - - for pub_m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - request = partial(pub_m, request) - - published_msg = await request( - message, - **kwargs, + cmd = NatsPublishCommand( + message=message, + subject=subject or self.subject, + headers=self.headers | (headers or {}), + timeout=timeout or self.timeout, + correlation_id=correlation_id or gen_cor_id(), + stream=getattr(self.stream, "name", None), + _publish_type=PublishType.Request, ) - msg: NatsMessage = await process_msg( - msg=published_msg, - middlewares=self._broker_middlewares, - parser=self._producer._parser, - decoder=self._producer._decoder, - ) - msg._source_type = SourceType.Response + msg: NatsMessage = await self._basic_request(cmd) return msg def add_prefix(self, prefix: str) -> None: diff --git a/faststream/nats/response.py b/faststream/nats/response.py index 625ac866f0..a6fcee1961 100644 --- a/faststream/nats/response.py +++ b/faststream/nats/response.py @@ -1,11 +1,12 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from typing_extensions import override -from faststream.response import Response +from faststream.response.publish_type import PublishType +from faststream.response.response import PublishCommand, Response if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict, SendableMessage + from faststream._internal.basic_types import SendableMessage class NatsResponse(Response): @@ -25,8 +26,68 @@ def __init__( self.stream = stream @override - def as_publish_kwargs(self) -> "AnyDict": - return { - **super().as_publish_kwargs(), - "stream": self.stream, - } + def as_publish_command(self) -> "NatsPublishCommand": + return NatsPublishCommand( + message=self.body, + headers=self.headers, + correlation_id=self.correlation_id, + _publish_type=PublishType.Reply, + # Nats specific + subject="", + stream=self.stream, + ) + + +class NatsPublishCommand(PublishCommand): + def __init__( + self, + message: "SendableMessage", + *, + subject: str = "", + correlation_id: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + stream: Optional[str] = None, + timeout: Optional[float] = None, + _publish_type: PublishType, + ) -> None: + super().__init__( + body=message, + destination=subject, + correlation_id=correlation_id, + headers=headers, + reply_to=reply_to, + _publish_type=_publish_type, + ) + + self.stream = stream + self.timeout = timeout + + def headers_to_publish(self, *, js: bool = False) -> dict[str, str]: + headers = {} + + if self.correlation_id: + headers["correlation_id"] = self.correlation_id + + if js and self.reply_to: + headers["reply_to"] = self.reply_to + + return headers | self.headers + + @classmethod + def from_cmd( + cls, + cmd: Union["PublishCommand", "NatsPublishCommand"], + ) -> "NatsPublishCommand": + if isinstance(cmd, NatsPublishCommand): + # NOTE: Should return a copy probably. + return cmd + + return cls( + message=cmd.body, + subject=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + _publish_type=cmd.publish_type, + ) diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index be75b89776..ff2e8c03c0 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -1,6 +1,6 @@ import asyncio from abc import abstractmethod -from collections.abc import Awaitable, Coroutine, Iterable, Sequence +from collections.abc import Awaitable, Coroutine, Iterable from contextlib import suppress from typing import ( TYPE_CHECKING, @@ -21,7 +21,6 @@ from typing_extensions import Doc, override from faststream._internal.context.repository import context -from faststream._internal.publisher.fake import FakePublisher from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import MsgType @@ -33,6 +32,7 @@ NatsParser, ObjParser, ) +from faststream.nats.publisher.fake import NatsFakePublisher from faststream.nats.schemas.js_stream import compile_nats_wildcard from faststream.nats.subscriber.adapters import ( UnsubscribeAdapter, @@ -53,7 +53,7 @@ LoggerProto, SendableMessage, ) - from faststream._internal.publisher.proto import ProducerProto + from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto from faststream._internal.setup import SetupState from faststream._internal.types import ( AsyncCallable, @@ -270,17 +270,15 @@ def __init__( def _make_response_publisher( self, message: "StreamMessage[Any]", - ) -> Sequence[FakePublisher]: - """Create FakePublisher object to use it as one of `publishers` in `self.consume` scope.""" + ) -> Iterable["BasePublisherProto"]: + """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" if self._producer is None: return () return ( - FakePublisher( - self._producer.publish, - publish_kwargs={ - "subject": message.reply_to, - }, + NatsFakePublisher( + producer=self._producer, + subject=message.reply_to, ), ) @@ -1140,8 +1138,8 @@ def _make_response_publisher( "StreamMessage[KeyValue.Entry]", Doc("Message requiring reply"), ], - ) -> Sequence[FakePublisher]: - """Create FakePublisher object to use it as one of `publishers` in `self.consume` scope.""" + ) -> Iterable["BasePublisherProto"]: + """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" return () def get_log_context( @@ -1293,8 +1291,8 @@ def _make_response_publisher( "StreamMessage[ObjectInfo]", Doc("Message requiring reply"), ], - ) -> Sequence[FakePublisher]: - """Create FakePublisher object to use it as one of `publishers` in `self.consume` scope.""" + ) -> Iterable["BasePublisherProto"]: + """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" return () def get_log_context( diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index 7765df0181..80e2b91f7e 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage from faststream.nats.publisher.specified import SpecificationPublisher + from faststream.nats.response import NatsPublishCommand from faststream.nats.subscriber.usecase import LogicSubscriber __all__ = ("TestNatsBroker",) @@ -74,28 +75,20 @@ def __init__(self, broker: NatsBroker) -> None: @override async def publish( # type: ignore[override] - self, - message: "SendableMessage", - subject: str, - reply_to: str = "", - headers: Optional[dict[str, str]] = None, - correlation_id: Optional[str] = None, - # NatsJSFastProducer compatibility - timeout: Optional[float] = None, - stream: Optional[str] = None, + self, cmd: "NatsPublishCommand" ) -> None: incoming = build_message( - message=message, - subject=subject, - headers=headers, - correlation_id=correlation_id, - reply_to=reply_to, + message=cmd.body, + subject=cmd.destination, + headers=cmd.headers, + correlation_id=cmd.correlation_id, + reply_to=cmd.reply_to, ) for handler in _find_handler( self.broker._subscribers, - subject, - stream, + cmd.destination, + cmd.stream, ): msg: Union[list[PatchedMessage], PatchedMessage] @@ -104,31 +97,24 @@ async def publish( # type: ignore[override] else: msg = incoming - await self._execute_handler(msg, subject, handler) + await self._execute_handler(msg, cmd.destination, handler) @override async def request( # type: ignore[override] self, - message: "SendableMessage", - subject: str, - *, - correlation_id: Optional[str] = None, - headers: Optional[dict[str, str]] = None, - timeout: float = 0.5, - # NatsJSFastProducer compatibility - stream: Optional[str] = None, + cmd: "NatsPublishCommand", ) -> "PatchedMessage": incoming = build_message( - message=message, - subject=subject, - headers=headers, - correlation_id=correlation_id, + message=cmd.body, + subject=cmd.destination, + headers=cmd.headers, + correlation_id=cmd.correlation_id, ) for handler in _find_handler( self.broker._subscribers, - subject, - stream, + cmd.destination, + cmd.stream, ): msg: Union[list[PatchedMessage], PatchedMessage] @@ -137,8 +123,8 @@ async def request( # type: ignore[override] else: msg = incoming - with anyio.fail_after(timeout): - return await self._execute_handler(msg, subject, handler) + with anyio.fail_after(cmd.timeout): + return await self._execute_handler(msg, cmd.destination, handler) raise SubscriberNotFound diff --git a/faststream/opentelemetry/middleware.py b/faststream/opentelemetry/middleware.py index 8305e3457a..853b85ccc7 100644 --- a/faststream/opentelemetry/middleware.py +++ b/faststream/opentelemetry/middleware.py @@ -10,10 +10,7 @@ from opentelemetry.trace import Link, Span from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator -from faststream import ( - BaseMiddleware, - context as fs_context, -) +from faststream import BaseMiddleware from faststream.opentelemetry.baggage import Baggage from faststream.opentelemetry.consts import ( ERROR_TYPE, @@ -22,7 +19,6 @@ WITH_BATCH, MessageAction, ) -from faststream.opentelemetry.provider import TelemetrySettingsProvider if TYPE_CHECKING: from contextvars import Token @@ -33,13 +29,58 @@ from opentelemetry.util.types import Attributes from faststream._internal.basic_types import AnyDict, AsyncFunc, AsyncFuncAny + from faststream._internal.context.repository import ContextRepo from faststream.message import StreamMessage + from faststream.opentelemetry.provider import TelemetrySettingsProvider + from faststream.response.response import PublishCommand _BAGGAGE_PROPAGATOR = W3CBaggagePropagator() _TRACE_PROPAGATOR = TraceContextTextMapPropagator() +class TelemetryMiddleware: + # NOTE: should it be class or function? + __slots__ = ( + "_meter", + "_metrics", + "_settings_provider_factory", + "_tracer", + ) + + def __init__( + self, + *, + settings_provider_factory: Callable[ + [Any], + Optional["TelemetrySettingsProvider[Any]"], + ], + tracer_provider: Optional["TracerProvider"] = None, + meter_provider: Optional["MeterProvider"] = None, + meter: Optional["Meter"] = None, + include_messages_counters: bool = False, + ) -> None: + self._tracer = _get_tracer(tracer_provider) + self._meter = _get_meter(meter_provider, meter) + self._metrics = _MetricsContainer(self._meter, include_messages_counters) + self._settings_provider_factory = settings_provider_factory + + def __call__( + self, + msg: Optional[Any], + /, + *, + context: "ContextRepo", + ) -> "_BaseTelemetryMiddleware": + return _BaseTelemetryMiddleware( + msg, + tracer=self._tracer, + metrics_container=self._metrics, + settings_provider_factory=self._settings_provider_factory, + context=context, + ) + + class _MetricsContainer: __slots__ = ( "include_messages_counters", @@ -112,19 +153,21 @@ def observe_consume( ) -class BaseTelemetryMiddleware(BaseMiddleware): +class _BaseTelemetryMiddleware(BaseMiddleware): def __init__( self, + msg: Optional[Any], + /, *, tracer: "Tracer", settings_provider_factory: Callable[ [Any], - Optional[TelemetrySettingsProvider[Any]], + Optional["TelemetrySettingsProvider[Any]"], ], metrics_container: _MetricsContainer, - msg: Optional[Any] = None, + context: "ContextRepo", ) -> None: - self.msg = msg + super().__init__(msg, context=context) self._tracer = tracer self._metrics = metrics_container @@ -136,29 +179,27 @@ def __init__( async def publish_scope( self, call_next: "AsyncFunc", - msg: Any, - *args: Any, - **kwargs: Any, + msg: "PublishCommand", ) -> Any: if (provider := self.__settings_provider) is None: - return await call_next(msg, *args, **kwargs) + return await call_next(msg) - headers = kwargs.pop("headers", {}) or {} + headers = msg.headers current_context = context.get_current() - destination_name = provider.get_publish_destination_name(kwargs) + destination_name = provider.get_publish_destination_name(msg) - current_baggage: Optional[Baggage] = fs_context.get_local("baggage") + current_baggage: Optional[Baggage] = self.context.get_local("baggage") if current_baggage: headers.update(current_baggage.to_headers()) - trace_attributes = provider.get_publish_attrs_from_kwargs(kwargs) + trace_attributes = provider.get_publish_attrs_from_cmd(msg) metrics_attributes = { SpanAttributes.MESSAGING_SYSTEM: provider.messaging_system, SpanAttributes.MESSAGING_DESTINATION_NAME: destination_name, } # NOTE: if batch with single message? - if (msg_count := len((msg, *args))) > 1: + if (msg_count := len(msg.batch_bodies)) > 1: trace_attributes[SpanAttributes.MESSAGING_BATCH_MESSAGE_COUNT] = msg_count current_context = _BAGGAGE_PROPAGATOR.extract(headers, current_context) _BAGGAGE_PROPAGATOR.inject( @@ -196,7 +237,8 @@ async def publish_scope( SpanAttributes.MESSAGING_OPERATION, MessageAction.PUBLISH, ) - result = await call_next(msg, *args, headers=headers, **kwargs) + msg.headers = headers + result = await call_next(msg) except Exception as e: metrics_attributes[ERROR_TYPE] = type(e).__name__ @@ -207,7 +249,7 @@ async def publish_scope( self._metrics.observe_publish(metrics_attributes, duration, msg_count) for key, token in self._scope_tokens: - fs_context.reset_local(key, token) + self.context.reset_local(key, token) return result @@ -260,9 +302,15 @@ async def consume_scope( ) self._current_span = span - self._scope_tokens.append(("span", fs_context.set_local("span", span))) + self._scope_tokens.append(( + "span", + self.context.set_local("span", span), + )) self._scope_tokens.append( - ("baggage", fs_context.set_local("baggage", Baggage.from_msg(msg))), + ( + "baggage", + self.context.set_local("baggage", Baggage.from_msg(msg)), + ), ) new_context = trace.set_span_in_context(span, current_context) @@ -295,41 +343,6 @@ async def after_processed( return False -class TelemetryMiddleware: - # NOTE: should it be class or function? - __slots__ = ( - "_meter", - "_metrics", - "_settings_provider_factory", - "_tracer", - ) - - def __init__( - self, - *, - settings_provider_factory: Callable[ - [Any], - Optional[TelemetrySettingsProvider[Any]], - ], - tracer_provider: Optional["TracerProvider"] = None, - meter_provider: Optional["MeterProvider"] = None, - meter: Optional["Meter"] = None, - include_messages_counters: bool = False, - ) -> None: - self._tracer = _get_tracer(tracer_provider) - self._meter = _get_meter(meter_provider, meter) - self._metrics = _MetricsContainer(self._meter, include_messages_counters) - self._settings_provider_factory = settings_provider_factory - - def __call__(self, msg: Optional[Any]) -> BaseMiddleware: - return BaseTelemetryMiddleware( - tracer=self._tracer, - metrics_container=self._metrics, - settings_provider_factory=self._settings_provider_factory, - msg=msg, - ) - - def _get_meter( meter_provider: Optional["MeterProvider"] = None, meter: Optional["Meter"] = None, diff --git a/faststream/opentelemetry/provider.py b/faststream/opentelemetry/provider.py index 304f1f332b..6e2aaa90b6 100644 --- a/faststream/opentelemetry/provider.py +++ b/faststream/opentelemetry/provider.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict from faststream.message import StreamMessage + from faststream.response.response import PublishCommand class TelemetrySettingsProvider(Protocol[MsgType]): @@ -20,12 +21,12 @@ def get_consume_destination_name( msg: "StreamMessage[MsgType]", ) -> str: ... - def get_publish_attrs_from_kwargs( + def get_publish_attrs_from_cmd( self, - kwargs: "AnyDict", + cmd: "PublishCommand", ) -> "AnyDict": ... def get_publish_destination_name( self, - kwargs: "AnyDict", + cmd: "PublishCommand", ) -> str: ... diff --git a/faststream/prometheus/__init__.py b/faststream/prometheus/__init__.py index e604b8cef7..a06f158ff3 100644 --- a/faststream/prometheus/__init__.py +++ b/faststream/prometheus/__init__.py @@ -1,9 +1,9 @@ -from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream.prometheus.middleware import PrometheusMiddleware from faststream.prometheus.provider import MetricsSettingsProvider from faststream.prometheus.types import ConsumeAttrs __all__ = ( - "BasePrometheusMiddleware", "ConsumeAttrs", "MetricsSettingsProvider", + "PrometheusMiddleware", ) diff --git a/faststream/prometheus/consts.py b/faststream/prometheus/consts.py index 3c4648d333..75cb8ba89a 100644 --- a/faststream/prometheus/consts.py +++ b/faststream/prometheus/consts.py @@ -1,5 +1,5 @@ -from faststream.broker.message import AckStatus from faststream.exceptions import AckMessage, NackMessage, RejectMessage, SkipMessage +from faststream.message.message import AckStatus from faststream.prometheus.types import ProcessingStatus PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP = { diff --git a/faststream/prometheus/middleware.py b/faststream/prometheus/middleware.py index 9bbaafb79a..3d7f256f05 100644 --- a/faststream/prometheus/middleware.py +++ b/faststream/prometheus/middleware.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Callable, Optional from faststream import BaseMiddleware +from faststream._internal.constants import EMPTY from faststream.prometheus.consts import ( PROCESSING_STATUS_BY_ACK_STATUS, PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP, @@ -11,28 +12,74 @@ from faststream.prometheus.manager import MetricsManager from faststream.prometheus.provider import MetricsSettingsProvider from faststream.prometheus.types import ProcessingStatus, PublishingStatus -from faststream.types import EMPTY if TYPE_CHECKING: from prometheus_client import CollectorRegistry - from faststream.broker.message import StreamMessage + from faststream._internal.context.repository import ContextRepo + from faststream.message.message import StreamMessage + from faststream.response.response import PublishCommand from faststream.types import AsyncFunc, AsyncFuncAny -class PrometheusMiddleware(BaseMiddleware): +class PrometheusMiddleware: + __slots__ = ("_metrics_container", "_metrics_manager", "_settings_provider_factory") + + def __init__( + self, + *, + settings_provider_factory: Callable[ + [Any], Optional[MetricsSettingsProvider[Any]] + ], + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Optional[Sequence[float]] = None, + ) -> None: + if app_name is EMPTY: + app_name = metrics_prefix + + self._settings_provider_factory = settings_provider_factory + self._metrics_container = MetricsContainer( + registry, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + ) + self._metrics_manager = MetricsManager( + self._metrics_container, + app_name=app_name, + ) + + def __call__( + self, + msg: Optional[Any], + /, + *, + context: "ContextRepo", + ) -> "_PrometheusMiddleware": + return _PrometheusMiddleware( + msg, + metrics_manager=self._metrics_manager, + settings_provider_factory=self._settings_provider_factory, + context=context, + ) + + +class _PrometheusMiddleware(BaseMiddleware): def __init__( self, - msg: Optional[Any] = None, + msg: Optional[Any], + /, *, settings_provider_factory: Callable[ [Any], Optional[MetricsSettingsProvider[Any]] ], metrics_manager: MetricsManager, + context: "ContextRepo", ) -> None: self._metrics_manager = metrics_manager self._settings_provider = settings_provider_factory(msg) - super().__init__(msg) + super().__init__(msg, context=context) async def consume_scope( self, @@ -114,15 +161,13 @@ async def consume_scope( async def publish_scope( self, call_next: "AsyncFunc", - msg: Any, - *args: Any, - **kwargs: Any, + cmd: "PublishCommand", ) -> Any: if self._settings_provider is None: - return await call_next(msg, *args, **kwargs) + return await call_next(cmd) destination_name = ( - self._settings_provider.get_publish_destination_name_from_kwargs(kwargs) + self._settings_provider.get_publish_destination_name_from_cmd(cmd) ) messaging_system = self._settings_provider.messaging_system @@ -130,11 +175,7 @@ async def publish_scope( start_time = time.perf_counter() try: - result = await call_next( - await self.on_publish(msg, *args, **kwargs), - *args, - **kwargs, - ) + result = await call_next(cmd) except Exception as e: err = e @@ -155,49 +196,12 @@ async def publish_scope( ) status = PublishingStatus.error if err else PublishingStatus.success - messages_count = len((msg, *args)) self._metrics_manager.add_published_message( - amount=messages_count, + amount=len(cmd.batch_bodies), status=status, broker=messaging_system, destination=destination_name, ) return result - - -class BasePrometheusMiddleware: - __slots__ = ("_metrics_container", "_metrics_manager", "_settings_provider_factory") - - def __init__( - self, - *, - settings_provider_factory: Callable[ - [Any], Optional[MetricsSettingsProvider[Any]] - ], - registry: "CollectorRegistry", - app_name: str = EMPTY, - metrics_prefix: str = "faststream", - received_messages_size_buckets: Optional[Sequence[float]] = None, - ) -> None: - if app_name is EMPTY: - app_name = metrics_prefix - - self._settings_provider_factory = settings_provider_factory - self._metrics_container = MetricsContainer( - registry, - metrics_prefix=metrics_prefix, - received_messages_size_buckets=received_messages_size_buckets, - ) - self._metrics_manager = MetricsManager( - self._metrics_container, - app_name=app_name, - ) - - def __call__(self, msg: Optional[Any]) -> BaseMiddleware: - return PrometheusMiddleware( - msg=msg, - metrics_manager=self._metrics_manager, - settings_provider_factory=self._settings_provider_factory, - ) diff --git a/faststream/prometheus/provider.py b/faststream/prometheus/provider.py index 1a543f5b55..acbf68702f 100644 --- a/faststream/prometheus/provider.py +++ b/faststream/prometheus/provider.py @@ -1,11 +1,10 @@ from typing import TYPE_CHECKING, Protocol -from faststream.broker.message import MsgType +from faststream.message.message import MsgType, StreamMessage if TYPE_CHECKING: - from faststream.broker.message import StreamMessage from faststream.prometheus import ConsumeAttrs - from faststream.types import AnyDict + from faststream.response.response import PublishCommand class MetricsSettingsProvider(Protocol[MsgType]): @@ -16,7 +15,7 @@ def get_consume_attrs_from_message( msg: "StreamMessage[MsgType]", ) -> "ConsumeAttrs": ... - def get_publish_destination_name_from_kwargs( + def get_publish_destination_name_from_cmd( self, - kwargs: "AnyDict", + cmd: "PublishCommand", ) -> str: ... diff --git a/faststream/rabbit/annotations.py b/faststream/rabbit/annotations.py index 4a135ecae9..dcbea9d20e 100644 --- a/faststream/rabbit/annotations.py +++ b/faststream/rabbit/annotations.py @@ -4,7 +4,6 @@ from faststream._internal.context import Context from faststream.annotations import ContextRepo, Logger -from faststream.params import NoCast from faststream.rabbit.broker import RabbitBroker as RB from faststream.rabbit.message import RabbitMessage as RM from faststream.rabbit.publisher.producer import AioPikaFastProducer @@ -14,7 +13,6 @@ "Connection", "ContextRepo", "Logger", - "NoCast", "RabbitBroker", "RabbitMessage", "RabbitProducer", diff --git a/faststream/rabbit/broker/broker.py b/faststream/rabbit/broker/broker.py index 7e15392154..80b30305e6 100644 --- a/faststream/rabbit/broker/broker.py +++ b/faststream/rabbit/broker/broker.py @@ -22,6 +22,7 @@ from faststream.message import gen_cor_id from faststream.rabbit.helpers.declarer import RabbitDeclarer from faststream.rabbit.publisher.producer import AioPikaFastProducer +from faststream.rabbit.response import RabbitPublishCommand from faststream.rabbit.schemas import ( RABBIT_REPLY, RabbitExchange, @@ -29,6 +30,7 @@ ) from faststream.rabbit.security import parse_security from faststream.rabbit.utils import build_url +from faststream.response.publish_type import PublishType from .logging import make_rabbit_logger_state from .registrator import RabbitRegistrator @@ -619,32 +621,31 @@ async def publish( # type: ignore[override] Please, use `@broker.publisher(...)` or `broker.publisher(...).publish(...)` instead in a regular way. """ - routing = routing_key or RabbitQueue.validate(queue).routing - correlation_id = correlation_id or gen_cor_id() - - return await super().publish( + cmd = RabbitPublishCommand( message, - producer=self._producer, - routing_key=routing, + routing_key=routing_key or RabbitQueue.validate(queue).routing, + exchange=RabbitExchange.validate(exchange), + correlation_id=correlation_id or gen_cor_id(), app_id=self.app_id, - exchange=exchange, mandatory=mandatory, immediate=immediate, persist=persist, reply_to=reply_to, headers=headers, - correlation_id=correlation_id, content_type=content_type, content_encoding=content_encoding, expiration=expiration, message_id=message_id, - timestamp=timestamp, message_type=message_type, + timestamp=timestamp, user_id=user_id, timeout=timeout, priority=priority, + _publish_type=PublishType.Publish, ) + return await super()._basic_publish(cmd, producer=self._producer) + @override async def request( # type: ignore[override] self, @@ -739,16 +740,12 @@ async def request( # type: ignore[override] Doc("The message priority (0 by default)."), ] = None, ) -> "RabbitMessage": - routing = routing_key or RabbitQueue.validate(queue).routing - correlation_id = correlation_id or gen_cor_id() - - msg: RabbitMessage = await super().request( + cmd = RabbitPublishCommand( message, - producer=self._producer, - correlation_id=correlation_id, - routing_key=routing, + routing_key=routing_key or RabbitQueue.validate(queue).routing, + exchange=RabbitExchange.validate(exchange), + correlation_id=correlation_id or gen_cor_id(), app_id=self.app_id, - exchange=exchange, mandatory=mandatory, immediate=immediate, persist=persist, @@ -757,12 +754,15 @@ async def request( # type: ignore[override] content_encoding=content_encoding, expiration=expiration, message_id=message_id, - timestamp=timestamp, message_type=message_type, + timestamp=timestamp, user_id=user_id, timeout=timeout, priority=priority, + _publish_type=PublishType.Request, ) + + msg: RabbitMessage = await super()._basic_request(cmd, producer=self._producer) return msg async def declare_queue( diff --git a/faststream/rabbit/opentelemetry/provider.py b/faststream/rabbit/opentelemetry/provider.py index ffa14e60e6..e9bd12c7fd 100644 --- a/faststream/rabbit/opentelemetry/provider.py +++ b/faststream/rabbit/opentelemetry/provider.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from opentelemetry.semconv.trace import SpanAttributes @@ -10,7 +10,7 @@ from faststream._internal.basic_types import AnyDict from faststream.message import StreamMessage - from faststream.rabbit.schemas.exchange import RabbitExchange + from faststream.rabbit.response import RabbitPublishCommand class RabbitTelemetrySettingsProvider(TelemetrySettingsProvider["IncomingMessage"]): @@ -41,28 +41,19 @@ def get_consume_destination_name( routing_key = msg.raw_message.routing_key return f"{exchange}.{routing_key}" - def get_publish_attrs_from_kwargs( + def get_publish_attrs_from_cmd( self, - kwargs: "AnyDict", + cmd: "RabbitPublishCommand", ) -> "AnyDict": - exchange: Union[None, str, RabbitExchange] = kwargs.get("exchange") return { SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, - SpanAttributes.MESSAGING_DESTINATION_NAME: getattr( - exchange, - "name", - exchange or "", - ), - SpanAttributes.MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY: kwargs[ - "routing_key" - ], - SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: kwargs["correlation_id"], + SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.exchange.name, + SpanAttributes.MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY: cmd.destination, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, } def get_publish_destination_name( self, - kwargs: "AnyDict", + cmd: "RabbitPublishCommand", ) -> str: - exchange: str = kwargs.get("exchange") or "default" - routing_key: str = kwargs["routing_key"] - return f"{exchange}.{routing_key}" + return f"{cmd.exchange.name or 'default'}.{cmd.destination}" diff --git a/faststream/rabbit/parser.py b/faststream/rabbit/parser.py index 70b2ba5492..de43697e17 100644 --- a/faststream/rabbit/parser.py +++ b/faststream/rabbit/parser.py @@ -61,19 +61,19 @@ async def decode_message( def encode_message( message: "AioPikaSendableMessage", *, - persist: bool, - reply_to: Optional[str], - headers: Optional["HeadersType"], - content_type: Optional[str], - content_encoding: Optional[str], - priority: Optional[int], - correlation_id: Optional[str], - expiration: Optional["DateType"], - message_id: Optional[str], - timestamp: Optional["DateType"], - message_type: Optional[str], - user_id: Optional[str], - app_id: Optional[str], + persist: bool = False, + reply_to: Optional[str] = None, + headers: Optional["HeadersType"] = None, + content_type: Optional[str] = None, + content_encoding: Optional[str] = None, + priority: Optional[int] = None, + correlation_id: Optional[str] = None, + expiration: "DateType" = None, + message_id: Optional[str] = None, + timestamp: "DateType" = None, + message_type: Optional[str] = None, + user_id: Optional[str] = None, + app_id: Optional[str] = None, ) -> Message: """Encodes a message for sending using AioPika.""" if isinstance(message, Message): diff --git a/faststream/rabbit/prometheus/middleware.py b/faststream/rabbit/prometheus/middleware.py index 44f179b66d..78dd498576 100644 --- a/faststream/rabbit/prometheus/middleware.py +++ b/faststream/rabbit/prometheus/middleware.py @@ -1,15 +1,15 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Optional -from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream._internal.constants import EMPTY +from faststream.prometheus.middleware import PrometheusMiddleware from faststream.rabbit.prometheus.provider import RabbitMetricsSettingsProvider -from faststream.types import EMPTY if TYPE_CHECKING: from prometheus_client import CollectorRegistry -class RabbitPrometheusMiddleware(BasePrometheusMiddleware): +class RabbitPrometheusMiddleware(PrometheusMiddleware): def __init__( self, *, diff --git a/faststream/rabbit/prometheus/provider.py b/faststream/rabbit/prometheus/provider.py index 48c1bb2541..f4fa0d977f 100644 --- a/faststream/rabbit/prometheus/provider.py +++ b/faststream/rabbit/prometheus/provider.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from faststream.prometheus import ( ConsumeAttrs, @@ -8,9 +8,8 @@ if TYPE_CHECKING: from aio_pika import IncomingMessage - from faststream.broker.message import StreamMessage - from faststream.rabbit.schemas.exchange import RabbitExchange - from faststream.types import AnyDict + from faststream.message.message import StreamMessage + from faststream.rabbit.response import RabbitPublishCommand class RabbitMetricsSettingsProvider(MetricsSettingsProvider["IncomingMessage"]): @@ -32,13 +31,8 @@ def get_consume_attrs_from_message( "messages_count": 1, } - def get_publish_destination_name_from_kwargs( + def get_publish_destination_name_from_cmd( self, - kwargs: "AnyDict", + cmd: "RabbitPublishCommand", ) -> str: - exchange: Union[None, str, RabbitExchange] = kwargs.get("exchange") - exchange_prefix = getattr(exchange, "name", exchange or "default") - - routing_key: str = kwargs["routing_key"] - - return f"{exchange_prefix}.{routing_key}" + return f"{cmd.exchange.name or 'default'}.{cmd.destination}" diff --git a/faststream/rabbit/publisher/fake.py b/faststream/rabbit/publisher/fake.py new file mode 100644 index 0000000000..e5c67848e6 --- /dev/null +++ b/faststream/rabbit/publisher/fake.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING, Optional, Union + +from faststream._internal.publisher.fake import FakePublisher +from faststream.rabbit.response import RabbitPublishCommand + +if TYPE_CHECKING: + from faststream._internal.publisher.proto import ProducerProto + from faststream.response.response import PublishCommand + + +class RabbitFakePublisher(FakePublisher): + """Publisher Interface implementation to use as RPC or REPLY TO answer publisher.""" + + def __init__( + self, + producer: "ProducerProto", + routing_key: str, + app_id: Optional[str], + ) -> None: + super().__init__(producer=producer) + self.routing_key = routing_key + self.app_id = str + + def patch_command( + self, cmd: Union["PublishCommand", "RabbitPublishCommand"] + ) -> "RabbitPublishCommand": + real_cmd = RabbitPublishCommand.from_cmd(cmd) + real_cmd.destination = self.routing_key + real_cmd.app_id = self.app_id + return real_cmd diff --git a/faststream/rabbit/publisher/options.py b/faststream/rabbit/publisher/options.py new file mode 100644 index 0000000000..81343b7cf9 --- /dev/null +++ b/faststream/rabbit/publisher/options.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING, Optional + +from typing_extensions import TypedDict + +if TYPE_CHECKING: + from aio_pika.abc import DateType, HeadersType, TimeoutType + + +class PublishOptions(TypedDict, total=False): + mandatory: bool + immediate: bool + timeout: "TimeoutType" + + +class MessageOptions(TypedDict, total=False): + persist: bool + reply_to: Optional[str] + headers: Optional["HeadersType"] + content_type: Optional[str] + content_encoding: Optional[str] + priority: Optional[int] + expiration: "DateType" + message_id: Optional[str] + timestamp: "DateType" + message_type: Optional[str] + user_id: Optional[str] + app_id: Optional[str] + correlation_id: Optional[str] diff --git a/faststream/rabbit/publisher/producer.py b/faststream/rabbit/publisher/producer.py index 780ef16424..ec509842a3 100644 --- a/faststream/rabbit/publisher/producer.py +++ b/faststream/rabbit/publisher/producer.py @@ -1,15 +1,15 @@ from typing import ( TYPE_CHECKING, Optional, - Union, cast, ) import anyio -from typing_extensions import override +from typing_extensions import Unpack, override from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func +from faststream.exceptions import FeatureNotSupportedException from faststream.rabbit.parser import AioPikaParser from faststream.rabbit.schemas import RABBIT_REPLY, RabbitExchange @@ -18,7 +18,7 @@ import aiormq from aio_pika import IncomingMessage, RobustQueue - from aio_pika.abc import AbstractIncomingMessage, DateType, HeadersType, TimeoutType + from aio_pika.abc import AbstractIncomingMessage, TimeoutType from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from faststream._internal.types import ( @@ -26,6 +26,7 @@ CustomCallable, ) from faststream.rabbit.helpers.declarer import RabbitDeclarer + from faststream.rabbit.response import MessageOptions, RabbitPublishCommand from faststream.rabbit.types import AioPikaSendableMessage @@ -53,99 +54,40 @@ def __init__( @override async def publish( # type: ignore[override] self, - message: "AioPikaSendableMessage", - exchange: Union["RabbitExchange", str, None] = None, - *, - correlation_id: str = "", - routing_key: str = "", - mandatory: bool = True, - immediate: bool = False, - timeout: "TimeoutType" = None, - persist: bool = False, - reply_to: Optional[str] = None, - headers: Optional["HeadersType"] = None, - content_type: Optional[str] = None, - content_encoding: Optional[str] = None, - priority: Optional[int] = None, - expiration: Optional["DateType"] = None, - message_id: Optional[str] = None, - timestamp: Optional["DateType"] = None, - message_type: Optional[str] = None, - user_id: Optional[str] = None, - app_id: Optional[str] = None, + cmd: "RabbitPublishCommand", ) -> Optional["aiormq.abc.ConfirmationFrameType"]: """Publish a message to a RabbitMQ queue.""" return await self._publish( - message=message, - exchange=exchange, - routing_key=routing_key, - mandatory=mandatory, - immediate=immediate, - timeout=timeout, - persist=persist, - reply_to=reply_to, - headers=headers, - content_type=content_type, - content_encoding=content_encoding, - priority=priority, - correlation_id=correlation_id, - expiration=expiration, - message_id=message_id, - timestamp=timestamp, - message_type=message_type, - user_id=user_id, - app_id=app_id, + message=cmd.body, + exchange=cmd.exchange, + routing_key=cmd.destination, + reply_to=cmd.reply_to, + headers=cmd.headers, + correlation_id=cmd.correlation_id, + **cmd.publish_options, + **cmd.message_options, ) @override async def request( # type: ignore[override] self, - message: "AioPikaSendableMessage", - exchange: Union["RabbitExchange", str, None] = None, - *, - correlation_id: str = "", - routing_key: str = "", - mandatory: bool = True, - immediate: bool = False, - timeout: Optional[float] = None, - persist: bool = False, - headers: Optional["HeadersType"] = None, - content_type: Optional[str] = None, - content_encoding: Optional[str] = None, - priority: Optional[int] = None, - expiration: Optional["DateType"] = None, - message_id: Optional[str] = None, - timestamp: Optional["DateType"] = None, - message_type: Optional[str] = None, - user_id: Optional[str] = None, - app_id: Optional[str] = None, + cmd: "RabbitPublishCommand", ) -> "IncomingMessage": """Publish a message to a RabbitMQ queue.""" async with _RPCCallback( self._rpc_lock, await self.declarer.declare_queue(RABBIT_REPLY), ) as response_queue: - with anyio.fail_after(timeout): + with anyio.fail_after(cmd.timeout): await self._publish( - message=message, - exchange=exchange, - routing_key=routing_key, - mandatory=mandatory, - immediate=immediate, - timeout=timeout, - persist=persist, + message=cmd.body, + exchange=cmd.exchange, + routing_key=cmd.destination, reply_to=RABBIT_REPLY.name, - headers=headers, - content_type=content_type, - content_encoding=content_encoding, - priority=priority, - correlation_id=correlation_id, - expiration=expiration, - message_id=message_id, - timestamp=timestamp, - message_type=message_type, - user_id=user_id, - app_id=app_id, + headers=cmd.headers, + correlation_id=cmd.correlation_id, + **cmd.publish_options, + **cmd.message_options, ) return await response_queue.receive() @@ -153,45 +95,18 @@ async def _publish( self, message: "AioPikaSendableMessage", *, - correlation_id: str, - exchange: Union["RabbitExchange", str, None], + exchange: "RabbitExchange", routing_key: str, - mandatory: bool, - immediate: bool, - timeout: "TimeoutType", - persist: bool, - reply_to: Optional[str], - headers: Optional["HeadersType"], - content_type: Optional[str], - content_encoding: Optional[str], - priority: Optional[int], - expiration: Optional["DateType"], - message_id: Optional[str], - timestamp: Optional["DateType"], - message_type: Optional[str], - user_id: Optional[str], - app_id: Optional[str], + mandatory: bool = True, + immediate: bool = False, + timeout: "TimeoutType" = None, + **message_options: Unpack["MessageOptions"], ) -> Optional["aiormq.abc.ConfirmationFrameType"]: """Publish a message to a RabbitMQ exchange.""" - message = AioPikaParser.encode_message( - message=message, - persist=persist, - reply_to=reply_to, - headers=headers, - content_type=content_type, - content_encoding=content_encoding, - priority=priority, - correlation_id=correlation_id, - expiration=expiration, - message_id=message_id, - timestamp=timestamp, - message_type=message_type, - user_id=user_id, - app_id=app_id, - ) + message = AioPikaParser.encode_message(message=message, **message_options) exchange_obj = await self.declarer.declare_exchange( - exchange=RabbitExchange.validate(exchange), + exchange=exchange, passive=True, ) @@ -203,6 +118,14 @@ async def _publish( timeout=timeout, ) + @override + async def publish_batch( + self, + cmd: "RabbitPublishCommand", + ) -> None: + msg = "RabbitMQ doesn't support publishing in batches." + raise FeatureNotSupportedException(msg) + class _RPCCallback: """A class provides an RPC lock.""" diff --git a/faststream/rabbit/publisher/specified.py b/faststream/rabbit/publisher/specified.py index fce0411752..6d16769926 100644 --- a/faststream/rabbit/publisher/specified.py +++ b/faststream/rabbit/publisher/specified.py @@ -47,10 +47,12 @@ def get_schema(self) -> dict[str, Channel]: bindings=OperationBinding( amqp=amqp.OperationBinding( cc=self.routing or None, - deliveryMode=2 if self.message_kwargs.get("persist") else 1, - mandatory=self.message_kwargs.get("mandatory"), # type: ignore[arg-type] - replyTo=self.message_kwargs.get("reply_to"), # type: ignore[arg-type] - priority=self.message_kwargs.get("priority"), # type: ignore[arg-type] + deliveryMode=2 + if self.message_options.get("persist") + else 1, + replyTo=self.message_options.get("reply_to"), # type: ignore[arg-type] + mandatory=self.publish_options.get("mandatory"), # type: ignore[arg-type] + priority=self.message_options.get("priority"), # type: ignore[arg-type] ), ) if is_routing_exchange(self.exchange) diff --git a/faststream/rabbit/publisher/usecase.py b/faststream/rabbit/publisher/usecase.py index c5ff3fb653..d8aafe7df8 100644 --- a/faststream/rabbit/publisher/usecase.py +++ b/faststream/rabbit/publisher/usecase.py @@ -1,102 +1,41 @@ -from collections.abc import Awaitable, Iterable +from collections.abc import Iterable from copy import deepcopy -from functools import partial -from itertools import chain from typing import ( TYPE_CHECKING, Annotated, Any, - Callable, Optional, Union, ) from aio_pika import IncomingMessage -from typing_extensions import Doc, TypedDict, Unpack, override +from typing_extensions import Doc, Unpack, override from faststream._internal.publisher.usecase import PublisherUsecase -from faststream._internal.subscriber.utils import process_msg -from faststream.exceptions import NOT_CONNECTED_YET -from faststream.message import SourceType, gen_cor_id -from faststream.rabbit.schemas import BaseRMQInformation, RabbitQueue +from faststream._internal.utils.data import filter_by_dict +from faststream.message import gen_cor_id +from faststream.rabbit.response import RabbitPublishCommand +from faststream.rabbit.schemas import BaseRMQInformation, RabbitExchange, RabbitQueue +from faststream.response.publish_type import PublishType + +from .options import MessageOptions, PublishOptions if TYPE_CHECKING: import aiormq - from aio_pika.abc import DateType, HeadersType, TimeoutType - from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.rabbit.message import RabbitMessage from faststream.rabbit.publisher.producer import AioPikaFastProducer - from faststream.rabbit.schemas.exchange import RabbitExchange from faststream.rabbit.types import AioPikaSendableMessage + from faststream.response.response import PublishCommand # should be public to use in imports -class RequestPublishKwargs(TypedDict, total=False): +class RequestPublishKwargs(MessageOptions, PublishOptions, total=False): """Typed dict to annotate RabbitMQ requesters.""" - headers: Annotated[ - Optional["HeadersType"], - Doc( - "Message headers to store metainformation. " - "Can be overridden by `publish.headers` if specified.", - ), - ] - mandatory: Annotated[ - Optional[bool], - Doc( - "Client waits for confirmation that the message is placed to some queue. " - "RabbitMQ returns message to client if there is no suitable queue.", - ), - ] - immediate: Annotated[ - Optional[bool], - Doc( - "Client expects that there is consumer ready to take the message to work. " - "RabbitMQ returns message to client if there is no suitable consumer.", - ), - ] - timeout: Annotated[ - "TimeoutType", - Doc("Send confirmation time from RabbitMQ."), - ] - persist: Annotated[ - Optional[bool], - Doc("Restore the message on RabbitMQ reboot."), - ] - - priority: Annotated[ - Optional[int], - Doc("The message priority (0 by default)."), - ] - message_type: Annotated[ - Optional[str], - Doc("Application-specific message type, e.g. **orders.created**."), - ] - content_type: Annotated[ - Optional[str], - Doc( - "Message **content-type** header. " - "Used by application, not core RabbitMQ. " - "Will be set automatically if not specified.", - ), - ] - user_id: Annotated[ - Optional[str], - Doc("Publisher connection User ID, validated if set."), - ] - expiration: Annotated[ - Optional["DateType"], - Doc("Message expiration (lifetime) in seconds (or datetime or timedelta)."), - ] - content_encoding: Annotated[ - Optional[str], - Doc("Message body content encoding, e.g. **gzip**."), - ] - -class PublishKwargs(RequestPublishKwargs, total=False): +class PublishKwargs(MessageOptions, PublishOptions, total=False): """Typed dict to annotate RabbitMQ publishers.""" reply_to: Annotated[ @@ -123,6 +62,7 @@ def __init__( routing_key: str, queue: "RabbitQueue", exchange: "RabbitExchange", + # PublishCommand options message_kwargs: "PublishKwargs", # Publisher args broker_middlewares: Iterable["BrokerMiddleware[IncomingMessage]"], @@ -145,9 +85,12 @@ def __init__( self.routing_key = routing_key - request_kwargs = dict(message_kwargs) - self.reply_to = request_kwargs.pop("reply_to", None) - self.message_kwargs = request_kwargs + request_options = dict(message_kwargs) + self.headers = request_options.pop("headers") or {} + self.reply_to = request_options.pop("reply_to", "") + self.timeout = request_options.pop("timeout", None) + self.message_options = filter_by_dict(MessageOptions, request_options) + self.publish_options = filter_by_dict(PublishOptions, request_options) # BaseRMQInformation self.queue = queue @@ -165,8 +108,12 @@ def _setup( # type: ignore[override] app_id: Optional[str], virtual_host: str, ) -> None: - self.app_id = app_id + if app_id: + self.message_options["app_id"] = app_id + self.app_id = app_id + self.virtual_host = virtual_host + super()._setup(producer=producer) @property @@ -202,77 +149,52 @@ async def publish( "**correlation_id** is a useful option to trace messages.", ), ] = None, - message_id: Annotated[ - Optional[str], - Doc("Arbitrary message id. Generated automatically if not presented."), - ] = None, - timestamp: Annotated[ - Optional["DateType"], - Doc("Message publish timestamp. Generated automatically if not presented."), - ] = None, # publisher specific **publish_kwargs: "Unpack[PublishKwargs]", ) -> Optional["aiormq.abc.ConfirmationFrameType"]: - return await self._publish( + if not routing_key: + if q := RabbitQueue.validate(queue): + routing_key = q.routing + else: + routing_key = self.routing + + headers = self.headers | publish_kwargs.pop("headers", {}) + cmd = RabbitPublishCommand( message, - queue=queue, - exchange=exchange, routing_key=routing_key, - correlation_id=correlation_id, - message_id=message_id, - timestamp=timestamp, + exchange=RabbitExchange.validate(exchange or self.exchange), + correlation_id=correlation_id or gen_cor_id(), + headers=headers, + _publish_type=PublishType.Publish, + **(self.publish_options | self.message_options | publish_kwargs), + ) + + frame: Optional[aiormq.abc.ConfirmationFrameType] = await self._basic_publish( + cmd, _extra_middlewares=(), - **publish_kwargs, ) + return frame @override async def _publish( self, - message: "AioPikaSendableMessage", - queue: Union["RabbitQueue", str, None] = None, - exchange: Union["RabbitExchange", str, None] = None, + cmd: Union["RabbitPublishCommand", "PublishCommand"], *, - routing_key: str = "", - # message args - correlation_id: Optional[str] = None, - message_id: Optional[str] = None, - timestamp: Optional["DateType"] = None, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), - **publish_kwargs: "Unpack[PublishKwargs]", - ) -> Optional["aiormq.abc.ConfirmationFrameType"]: - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - kwargs: AnyDict = { - "routing_key": routing_key - or self.routing_key - or RabbitQueue.validate(queue or self.queue).routing, - "exchange": exchange or self.exchange.name, - "app_id": self.app_id, - "correlation_id": correlation_id or gen_cor_id(), - "message_id": message_id, - "timestamp": timestamp, - # specific args - "reply_to": self.reply_to, - **self.message_kwargs, - **publish_kwargs, - } - - call: Callable[ - ..., - Awaitable[Optional[aiormq.abc.ConfirmationFrameType]], - ] = self._producer.publish - - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) + _extra_middlewares: Iterable["PublisherMiddleware"], + ) -> None: + """This method should be called in subscriber flow only.""" + cmd = RabbitPublishCommand.from_cmd(cmd) + + cmd.destination = self.routing + cmd.reply_to = cmd.reply_to or self.reply_to + cmd.add_headers(self.headers, override=False) - return await call(message, **kwargs) + cmd.timeout = cmd.timeout or self.timeout + + cmd.message_options = {**self.message_options, **cmd.message_options} + cmd.publish_options = {**self.publish_options, **cmd.publish_options} + + await self._basic_publish(cmd, _extra_middlewares=_extra_middlewares) @override async def request( @@ -302,60 +224,27 @@ async def request( "**correlation_id** is a useful option to trace messages.", ), ] = None, - message_id: Annotated[ - Optional[str], - Doc("Arbitrary message id. Generated automatically if not presented."), - ] = None, - timestamp: Annotated[ - Optional["DateType"], - Doc("Message publish timestamp. Generated automatically if not presented."), - ] = None, # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), **publish_kwargs: "Unpack[RequestPublishKwargs]", ) -> "RabbitMessage": - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - kwargs: AnyDict = { - "routing_key": routing_key - or self.routing_key - or RabbitQueue.validate(queue or self.queue).routing, - "exchange": exchange or self.exchange.name, - "app_id": self.app_id, - "correlation_id": correlation_id or gen_cor_id(), - "message_id": message_id, - "timestamp": timestamp, - # specific args - **self.message_kwargs, - **publish_kwargs, - } - - request: Callable[..., Awaitable[Any]] = self._producer.request - - for pub_m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - request = partial(pub_m, request) - - published_msg = await request( + if not routing_key: + if q := RabbitQueue.validate(queue): + routing_key = q.routing + else: + routing_key = self.routing + + headers = self.headers | publish_kwargs.pop("headers", {}) + cmd = RabbitPublishCommand( message, - **kwargs, + routing_key=routing_key, + exchange=RabbitExchange.validate(exchange or self.exchange), + correlation_id=correlation_id or gen_cor_id(), + headers=headers, + _publish_type=PublishType.Publish, + **(self.publish_options | self.message_options | publish_kwargs), ) - msg: RabbitMessage = await process_msg( - msg=published_msg, - middlewares=self._broker_middlewares, - parser=self._producer._parser, - decoder=self._producer._decoder, - ) - msg._source_type = SourceType.Response + msg: RabbitMessage = await self._basic_request(cmd) return msg def add_prefix(self, prefix: str) -> None: diff --git a/faststream/rabbit/response.py b/faststream/rabbit/response.py index 756c665c4b..560a45bb57 100644 --- a/faststream/rabbit/response.py +++ b/faststream/rabbit/response.py @@ -1,13 +1,15 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union -from typing_extensions import override +from typing_extensions import Unpack, override -from faststream.response import Response +from faststream.rabbit.schemas.exchange import RabbitExchange +from faststream.response import PublishCommand, Response +from faststream.response.publish_type import PublishType if TYPE_CHECKING: - from aio_pika.abc import DateType, TimeoutType + from aio_pika.abc import TimeoutType - from faststream._internal.basic_types import AnyDict + from faststream.rabbit.publisher.options import MessageOptions from faststream.rabbit.types import AioPikaSendableMessage @@ -16,48 +18,91 @@ def __init__( self, body: "AioPikaSendableMessage", *, - headers: Optional["AnyDict"] = None, - correlation_id: Optional[str] = None, - message_id: Optional[str] = None, + timeout: "TimeoutType" = None, mandatory: bool = True, immediate: bool = False, - timeout: "TimeoutType" = None, - persist: Optional[bool] = None, - priority: Optional[int] = None, - message_type: Optional[str] = None, - content_type: Optional[str] = None, - expiration: Optional["DateType"] = None, - content_encoding: Optional[str] = None, + **message_options: Unpack["MessageOptions"], ) -> None: + headers = message_options.pop("headers", {}) + correlation_id = message_options.pop("correlation_id", None) + super().__init__( body=body, headers=headers, correlation_id=correlation_id, ) - self.message_id = message_id - self.mandatory = mandatory - self.immediate = immediate - self.timeout = timeout - self.persist = persist - self.priority = priority - self.message_type = message_type - self.content_type = content_type - self.expiration = expiration - self.content_encoding = content_encoding + self.message_options = message_options + self.publish_options = { + "mandatory": mandatory, + "immediate": immediate, + "timeout": timeout, + } @override - def as_publish_kwargs(self) -> "AnyDict": - return { - **super().as_publish_kwargs(), - "message_id": self.message_id, - "mandatory": self.mandatory, - "immediate": self.immediate, - "timeout": self.timeout, - "persist": self.persist, - "priority": self.priority, - "message_type": self.message_type, - "content_type": self.content_type, - "expiration": self.expiration, - "content_encoding": self.content_encoding, + def as_publish_command(self) -> "RabbitPublishCommand": + return RabbitPublishCommand( + message=self.body, + headers=self.headers, + correlation_id=self.correlation_id, + _publish_type=PublishType.Reply, + # RMQ specific + routing_key="", + **self.publish_options, + **self.message_options, + ) + + +class RabbitPublishCommand(PublishCommand): + def __init__( + self, + message: "AioPikaSendableMessage", + *, + _publish_type: PublishType, + routing_key: str = "", + exchange: Optional[RabbitExchange] = None, + # publish kwargs + mandatory: bool = True, + immediate: bool = False, + timeout: "TimeoutType" = None, + correlation_id: Optional[str] = None, + **message_options: Unpack["MessageOptions"], + ) -> None: + headers = message_options.pop("headers", {}) + reply_to = message_options.pop("reply_to", "") + + super().__init__( + body=message, + destination=routing_key, + correlation_id=correlation_id, + headers=headers, + reply_to=reply_to, + _publish_type=_publish_type, + ) + self.exchange = exchange or RabbitExchange() + + self.timeout = timeout + + self.message_options = message_options + self.publish_options = { + "mandatory": mandatory, + "immediate": immediate, } + + @classmethod + def from_cmd( + cls, + cmd: Union["PublishCommand", "RabbitPublishCommand"], + ) -> "RabbitPublishCommand": + if isinstance(cmd, RabbitPublishCommand): + # NOTE: Should return a copy probably. + return cmd + + return cls( + message=cmd.body, + routing_key=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + _publish_type=cmd.publish_type, + ) diff --git a/faststream/rabbit/schemas/proto.py b/faststream/rabbit/schemas/proto.py index ca00168745..41045b94fa 100644 --- a/faststream/rabbit/schemas/proto.py +++ b/faststream/rabbit/schemas/proto.py @@ -9,5 +9,5 @@ class BaseRMQInformation(Protocol): virtual_host: str queue: RabbitQueue - exchange: Optional[RabbitExchange] + exchange: RabbitExchange app_id: Optional[str] diff --git a/faststream/rabbit/subscriber/usecase.py b/faststream/rabbit/subscriber/usecase.py index e7fc7e5205..67566d0fb1 100644 --- a/faststream/rabbit/subscriber/usecase.py +++ b/faststream/rabbit/subscriber/usecase.py @@ -9,11 +9,11 @@ import anyio from typing_extensions import override -from faststream._internal.publisher.fake import FakePublisher from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream.exceptions import SetupError from faststream.rabbit.parser import AioPikaParser +from faststream.rabbit.publisher.fake import RabbitFakePublisher from faststream.rabbit.schemas import BaseRMQInformation if TYPE_CHECKING: @@ -21,6 +21,7 @@ from fast_depends.dependencies import Depends from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.publisher.proto import BasePublisherProto from faststream._internal.setup import SetupState from faststream._internal.types import BrokerMiddleware, CustomCallable from faststream.message import StreamMessage @@ -205,17 +206,15 @@ async def get_one( def _make_response_publisher( self, message: "StreamMessage[Any]", - ) -> Sequence["FakePublisher"]: + ) -> Sequence["BasePublisherProto"]: if self._producer is None: return () return ( - FakePublisher( - self._producer.publish, - publish_kwargs={ - "routing_key": message.reply_to, - "app_id": self.app_id, - }, + RabbitFakePublisher( + self._producer, + routing_key=message.reply_to, + app_id=self.app_id, ), ) diff --git a/faststream/rabbit/testing.py b/faststream/rabbit/testing.py index 7d45ed786b..97472fa5fe 100644 --- a/faststream/rabbit/testing.py +++ b/faststream/rabbit/testing.py @@ -25,9 +25,10 @@ ) if TYPE_CHECKING: - from aio_pika.abc import DateType, HeadersType, TimeoutType + from aio_pika.abc import DateType, HeadersType from faststream.rabbit.publisher.specified import SpecificationPublisher + from faststream.rabbit.response import RabbitPublishCommand from faststream.rabbit.subscriber.usecase import LogicSubscriber from faststream.rabbit.types import AioPikaSendableMessage @@ -131,6 +132,7 @@ def build_message( routing = routing_key or que.routing + correlation_id = correlation_id or gen_cor_id() msg = AioPikaParser.encode_message( message=message, persist=persist, @@ -141,7 +143,7 @@ def build_message( priority=priority, correlation_id=correlation_id, expiration=expiration, - message_id=message_id or gen_cor_id(), + message_id=message_id or correlation_id, timestamp=timestamp, message_type=message_type, user_id=user_id, @@ -194,47 +196,17 @@ def __init__(self, broker: RabbitBroker) -> None: @override async def publish( # type: ignore[override] self, - message: "AioPikaSendableMessage", - exchange: Union["RabbitExchange", str, None] = None, - *, - correlation_id: str = "", - routing_key: str = "", - mandatory: bool = True, - immediate: bool = False, - timeout: "TimeoutType" = None, - persist: bool = False, - reply_to: Optional[str] = None, - headers: Optional["HeadersType"] = None, - content_type: Optional[str] = None, - content_encoding: Optional[str] = None, - priority: Optional[int] = None, - expiration: Optional["DateType"] = None, - message_id: Optional[str] = None, - timestamp: Optional["DateType"] = None, - message_type: Optional[str] = None, - user_id: Optional[str] = None, - app_id: Optional[str] = None, + cmd: "RabbitPublishCommand", ) -> None: """Publish a message to a RabbitMQ queue or exchange.""" - exch = RabbitExchange.validate(exchange) - incoming = build_message( - message=message, - exchange=exch, - routing_key=routing_key, - reply_to=reply_to, - app_id=app_id, - user_id=user_id, - message_type=message_type, - headers=headers, - persist=persist, - message_id=message_id, - priority=priority, - content_encoding=content_encoding, - content_type=content_type, - correlation_id=correlation_id, - expiration=expiration, - timestamp=timestamp, + message=cmd.body, + exchange=cmd.exchange, + routing_key=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + **cmd.message_options, ) for handler in self.broker._subscribers: # pragma: no branch @@ -242,52 +214,23 @@ async def publish( # type: ignore[override] handler, incoming.routing_key, incoming.headers, - exch, + cmd.exchange, ): await self._execute_handler(incoming, handler) @override async def request( # type: ignore[override] self, - message: "AioPikaSendableMessage" = "", - exchange: Union["RabbitExchange", str, None] = None, - *, - correlation_id: str = "", - routing_key: str = "", - mandatory: bool = True, - immediate: bool = False, - timeout: Optional[float] = None, - persist: bool = False, - headers: Optional["HeadersType"] = None, - content_type: Optional[str] = None, - content_encoding: Optional[str] = None, - priority: Optional[int] = None, - expiration: Optional["DateType"] = None, - message_id: Optional[str] = None, - timestamp: Optional["DateType"] = None, - message_type: Optional[str] = None, - user_id: Optional[str] = None, - app_id: Optional[str] = None, + cmd: "RabbitPublishCommand", ) -> "PatchedMessage": """Publish a message to a RabbitMQ queue or exchange.""" - exch = RabbitExchange.validate(exchange) - incoming = build_message( - message=message, - exchange=exch, - routing_key=routing_key, - app_id=app_id, - user_id=user_id, - message_type=message_type, - headers=headers, - persist=persist, - message_id=message_id, - priority=priority, - content_encoding=content_encoding, - content_type=content_type, - correlation_id=correlation_id, - expiration=expiration, - timestamp=timestamp, + message=cmd.body, + exchange=cmd.exchange, + routing_key=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + **cmd.message_options, ) for handler in self.broker._subscribers: # pragma: no branch @@ -295,9 +238,9 @@ async def request( # type: ignore[override] handler, incoming.routing_key, incoming.headers, - exch, + cmd.exchange, ): - with anyio.fail_after(timeout): + with anyio.fail_after(cmd.timeout): return await self._execute_handler(incoming, handler) raise SubscriberNotFound diff --git a/faststream/redis/annotations.py b/faststream/redis/annotations.py index 4bbbb9b324..b4db951b54 100644 --- a/faststream/redis/annotations.py +++ b/faststream/redis/annotations.py @@ -4,14 +4,12 @@ from faststream._internal.context import Context from faststream.annotations import ContextRepo, Logger -from faststream.params import NoCast from faststream.redis.broker.broker import RedisBroker as RB from faststream.redis.message import UnifyRedisMessage __all__ = ( "ContextRepo", "Logger", - "NoCast", "Redis", "RedisBroker", "RedisMessage", diff --git a/faststream/redis/broker/broker.py b/faststream/redis/broker/broker.py index 76c4265754..4ddcdeac05 100644 --- a/faststream/redis/broker/broker.py +++ b/faststream/redis/broker/broker.py @@ -27,11 +27,13 @@ from faststream.__about__ import __version__ from faststream._internal.broker.broker import BrokerUsecase from faststream._internal.constants import EMPTY -from faststream.exceptions import NOT_CONNECTED_YET +from faststream._internal.context.repository import context from faststream.message import gen_cor_id from faststream.redis.message import UnifyRedisDict from faststream.redis.publisher.producer import RedisFastProducer +from faststream.redis.response import RedisPublishCommand from faststream.redis.security import parse_security +from faststream.response.publish_type import PublishType from .logging import make_redis_logger_state from .registrator import RedisRegistrator @@ -412,9 +414,8 @@ async def publish( # type: ignore[override] Please, use `@broker.publisher(...)` or `broker.publisher(...).publish(...)` instead in a regular way. """ - await super().publish( + cmd = RedisPublishCommand( message, - producer=self._producer, correlation_id=correlation_id or gen_cor_id(), channel=channel, list=list, @@ -422,7 +423,9 @@ async def publish( # type: ignore[override] maxlen=maxlen, reply_to=reply_to, headers=headers, + _publish_type=PublishType.Publish, ) + await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] @@ -437,9 +440,8 @@ async def request( # type: ignore[override] headers: Optional["AnyDict"] = None, timeout: Optional[float] = 30.0, ) -> "RedisMessage": - msg: RedisMessage = await super().request( + cmd = RedisPublishCommand( message, - producer=self._producer, correlation_id=correlation_id or gen_cor_id(), channel=channel, list=list, @@ -447,12 +449,14 @@ async def request( # type: ignore[override] maxlen=maxlen, headers=headers, timeout=timeout, + _publish_type=PublishType.Request, ) + msg: RedisMessage = await super()._basic_request(cmd, producer=self._producer) return msg async def publish_batch( self, - *msgs: Annotated[ + *messages: Annotated[ "SendableMessage", Doc("Messages bodies to send."), ], @@ -467,22 +471,31 @@ async def publish_batch( "**correlation_id** is a useful option to trace messages.", ), ] = None, + reply_to: Annotated[ + str, + Doc("Reply message destination PubSub object name."), + ] = "", + headers: Annotated[ + Optional["AnyDict"], + Doc("Message headers to store metainformation."), + ] = None, ) -> None: """Publish multiple messages to Redis List by one request.""" - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - correlation_id = correlation_id or gen_cor_id() + cmd = RedisPublishCommand( + *messages, + list=list, + reply_to=reply_to, + headers=headers, + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, + ) call: AsyncFunc = self._producer.publish_batch for m in self._middlewares: - call = partial(m(None).publish_scope, call) + call = partial(m(None, context=context).publish_scope, call) - await call( - *msgs, - list=list, - correlation_id=correlation_id, - ) + await self._basic_publish_batch(cmd, producer=self._producer) @override async def ping(self, timeout: Optional[float]) -> bool: diff --git a/faststream/redis/opentelemetry/provider.py b/faststream/redis/opentelemetry/provider.py index ea8c17462c..852cd8ca5f 100644 --- a/faststream/redis/opentelemetry/provider.py +++ b/faststream/redis/opentelemetry/provider.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict from faststream.message import StreamMessage + from faststream.redis.response import RedisPublishCommand class RedisTelemetrySettingsProvider(TelemetrySettingsProvider["AnyDict"]): @@ -42,21 +43,21 @@ def get_consume_destination_name( ) -> str: return self._get_destination(msg.raw_message) - def get_publish_attrs_from_kwargs( + def get_publish_attrs_from_cmd( self, - kwargs: "AnyDict", + cmd: "RedisPublishCommand", ) -> "AnyDict": return { SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, - SpanAttributes.MESSAGING_DESTINATION_NAME: self._get_destination(kwargs), - SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: kwargs["correlation_id"], + SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.destination, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, } def get_publish_destination_name( self, - kwargs: "AnyDict", + cmd: "RedisPublishCommand", ) -> str: - return self._get_destination(kwargs) + return cmd.destination @staticmethod def _get_destination(kwargs: "AnyDict") -> str: diff --git a/faststream/redis/prometheus/middleware.py b/faststream/redis/prometheus/middleware.py index 9e8f5d811f..8c62c745fd 100644 --- a/faststream/redis/prometheus/middleware.py +++ b/faststream/redis/prometheus/middleware.py @@ -1,15 +1,15 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Optional -from faststream.prometheus.middleware import BasePrometheusMiddleware +from faststream._internal.constants import EMPTY +from faststream.prometheus.middleware import PrometheusMiddleware from faststream.redis.prometheus.provider import settings_provider_factory -from faststream.types import EMPTY if TYPE_CHECKING: from prometheus_client import CollectorRegistry -class RedisPrometheusMiddleware(BasePrometheusMiddleware): +class RedisPrometheusMiddleware(PrometheusMiddleware): def __init__( self, *, diff --git a/faststream/redis/prometheus/provider.py b/faststream/redis/prometheus/provider.py index 6cee2824a0..d34227a15e 100644 --- a/faststream/redis/prometheus/provider.py +++ b/faststream/redis/prometheus/provider.py @@ -7,8 +7,9 @@ ) if TYPE_CHECKING: - from faststream.broker.message import StreamMessage - from faststream.types import AnyDict + from faststream._internal.basic_types import AnyDict + from faststream.message.message import StreamMessage + from faststream.redis.response import RedisPublishCommand class BaseRedisMetricsSettingsProvider(MetricsSettingsProvider["AnyDict"]): @@ -17,15 +18,11 @@ class BaseRedisMetricsSettingsProvider(MetricsSettingsProvider["AnyDict"]): def __init__(self) -> None: self.messaging_system = "redis" - def get_publish_destination_name_from_kwargs( + def get_publish_destination_name_from_cmd( self, - kwargs: "AnyDict", + cmd: "RedisPublishCommand", ) -> str: - return self._get_destination(kwargs) - - @staticmethod - def _get_destination(kwargs: "AnyDict") -> str: - return kwargs.get("channel") or kwargs.get("list") or kwargs.get("stream") or "" + return cmd.destination class RedisMetricsSettingsProvider(BaseRedisMetricsSettingsProvider): @@ -34,7 +31,7 @@ def get_consume_attrs_from_message( msg: "StreamMessage[AnyDict]", ) -> ConsumeAttrs: return { - "destination_name": self._get_destination(msg.raw_message), + "destination_name": _get_destination(msg.raw_message), "message_size": len(msg.body), "messages_count": 1, } @@ -46,7 +43,7 @@ def get_consume_attrs_from_message( msg: "StreamMessage[AnyDict]", ) -> ConsumeAttrs: return { - "destination_name": self._get_destination(msg.raw_message), + "destination_name": _get_destination(msg.raw_message), "message_size": len(msg.body), "messages_count": len(cast(Sized, msg._decoded_body)), } @@ -61,3 +58,7 @@ def settings_provider_factory( if msg is not None and msg.get("type", "").startswith("b"): return BatchRedisMetricsSettingsProvider() return RedisMetricsSettingsProvider() + + +def _get_destination(kwargs: "AnyDict") -> str: + return kwargs.get("channel") or kwargs.get("list") or kwargs.get("stream") or "" diff --git a/faststream/redis/publisher/fake.py b/faststream/redis/publisher/fake.py new file mode 100644 index 0000000000..2fd055e6f2 --- /dev/null +++ b/faststream/redis/publisher/fake.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING, Union + +from faststream._internal.publisher.fake import FakePublisher +from faststream.redis.response import RedisPublishCommand + +if TYPE_CHECKING: + from faststream._internal.publisher.proto import ProducerProto + from faststream.response.response import PublishCommand + + +class RedisFakePublisher(FakePublisher): + """Publisher Interface implementation to use as RPC or REPLY TO answer publisher.""" + + def __init__( + self, + producer: "ProducerProto", + channel: str, + ) -> None: + super().__init__(producer=producer) + self.channel = channel + + def patch_command( + self, cmd: Union["PublishCommand", "RedisPublishCommand"] + ) -> "RedisPublishCommand": + real_cmd = RedisPublishCommand.from_cmd(cmd) + real_cmd.destination = self.channel + return real_cmd diff --git a/faststream/redis/publisher/producer.py b/faststream/redis/publisher/producer.py index 8c196e1a71..57afef2d31 100644 --- a/faststream/redis/publisher/producer.py +++ b/faststream/redis/publisher/producer.py @@ -6,15 +6,13 @@ from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func from faststream._internal.utils.nuid import NUID -from faststream.exceptions import SetupError from faststream.redis.message import DATA_KEY from faststream.redis.parser import RawMessage, RedisPubSubParser -from faststream.redis.schemas import INCORRECT_SETUP_MSG +from faststream.redis.response import DestinationType, RedisPublishCommand if TYPE_CHECKING: from redis.asyncio.client import Redis - from faststream._internal.basic_types import AnyDict, SendableMessage from faststream._internal.types import ( AsyncCallable, CustomCallable, @@ -49,93 +47,47 @@ def __init__( @override async def publish( # type: ignore[override] self, - message: "SendableMessage", - *, - correlation_id: str, - channel: Optional[str] = None, - list: Optional[str] = None, - stream: Optional[str] = None, - maxlen: Optional[int] = None, - headers: Optional["AnyDict"] = None, - reply_to: str = "", + cmd: "RedisPublishCommand", ) -> None: - if not any((channel, list, stream)): - raise SetupError(INCORRECT_SETUP_MSG) - msg = RawMessage.encode( - message=message, - reply_to=reply_to, - headers=headers, - correlation_id=correlation_id, + message=cmd.body, + reply_to=cmd.reply_to, + headers=cmd.headers, + correlation_id=cmd.correlation_id, ) - if channel is not None: - await self._connection.publish(channel, msg) - elif list is not None: - await self._connection.rpush(list, msg) - elif stream is not None: - await self._connection.xadd( - name=stream, - fields={DATA_KEY: msg}, - maxlen=maxlen, - ) - else: - msg = "unreachable" - raise AssertionError(msg) + await self.__publish(msg, cmd) @override async def request( # type: ignore[override] self, - message: "SendableMessage", - *, - correlation_id: str, - channel: Optional[str] = None, - list: Optional[str] = None, - stream: Optional[str] = None, - maxlen: Optional[int] = None, - headers: Optional["AnyDict"] = None, - timeout: Optional[float] = 30.0, + cmd: "RedisPublishCommand", ) -> "Any": - if not any((channel, list, stream)): - raise SetupError(INCORRECT_SETUP_MSG) - nuid = NUID() reply_to = str(nuid.next(), "utf-8") psub = self._connection.pubsub() await psub.subscribe(reply_to) msg = RawMessage.encode( - message=message, + message=cmd.body, reply_to=reply_to, - headers=headers, - correlation_id=correlation_id, + headers=cmd.headers, + correlation_id=cmd.correlation_id, ) - if channel is not None: - await self._connection.publish(channel, msg) - elif list is not None: - await self._connection.rpush(list, msg) - elif stream is not None: - await self._connection.xadd( - name=stream, - fields={DATA_KEY: msg}, - maxlen=maxlen, - ) - else: - msg = "unreachable" - raise AssertionError(msg) + await self.__publish(msg, cmd) - with anyio.fail_after(timeout) as scope: + with anyio.fail_after(cmd.timeout) as scope: # skip subscribe message await psub.get_message( ignore_subscribe_messages=True, - timeout=timeout or 0.0, + timeout=cmd.timeout or 0.0, ) # get real response response_msg = await psub.get_message( ignore_subscribe_messages=True, - timeout=timeout or 0.0, + timeout=cmd.timeout or 0.0, ) await psub.unsubscribe() @@ -146,20 +98,33 @@ async def request( # type: ignore[override] return response_msg + @override async def publish_batch( self, - *msgs: "SendableMessage", - list: str, - correlation_id: str, - headers: Optional["AnyDict"] = None, + cmd: "RedisPublishCommand", ) -> None: - batch = ( + batch = [ RawMessage.encode( message=msg, - correlation_id=correlation_id, - reply_to=None, - headers=headers, + correlation_id=cmd.correlation_id, + reply_to=cmd.reply_to, + headers=cmd.headers, ) - for msg in msgs - ) - await self._connection.rpush(list, *batch) + for msg in cmd.batch_bodies + ] + await self._connection.rpush(cmd.destination, *batch) + + async def __publish(self, msg: bytes, cmd: "RedisPublishCommand") -> None: + if cmd.destination_type is DestinationType.Channel: + await self._connection.publish(cmd.destination, msg) + elif cmd.destination_type is DestinationType.List: + await self._connection.rpush(cmd.destination, msg) + elif cmd.destination_type is DestinationType.Stream: + await self._connection.xadd( + name=cmd.destination, + fields={DATA_KEY: msg}, + maxlen=cmd.maxlen, + ) + else: + error_msg = "unreachable" + raise AssertionError(error_msg) diff --git a/faststream/redis/publisher/usecase.py b/faststream/redis/publisher/usecase.py index 2012f0a85e..34e6f934c6 100644 --- a/faststream/redis/publisher/usecase.py +++ b/faststream/redis/publisher/usecase.py @@ -1,24 +1,23 @@ from abc import abstractmethod -from collections.abc import Awaitable, Iterable +from collections.abc import Iterable from copy import deepcopy -from functools import partial -from itertools import chain -from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional +from typing import TYPE_CHECKING, Annotated, Any, Optional, Union from typing_extensions import Doc, override from faststream._internal.publisher.usecase import PublisherUsecase -from faststream._internal.subscriber.utils import process_msg -from faststream.exceptions import NOT_CONNECTED_YET -from faststream.message import SourceType, gen_cor_id +from faststream.message import gen_cor_id from faststream.redis.message import UnifyRedisDict -from faststream.redis.schemas import ListSub, PubSub, StreamSub +from faststream.redis.response import RedisPublishCommand +from faststream.response.publish_type import PublishType if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, SendableMessage from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.redis.message import RedisMessage from faststream.redis.publisher.producer import RedisFastProducer + from faststream.redis.schemas import ListSub, PubSub, StreamSub + from faststream.response.response import PublishCommand class LogicPublisher(PublisherUsecase[UnifyRedisDict]): @@ -51,7 +50,7 @@ def __init__( ) self.reply_to = reply_to - self.headers = headers + self.headers = headers or {} self._producer = None @@ -128,56 +127,33 @@ async def publish( "**correlation_id** is a useful option to trace messages.", ), ] = None, - **kwargs: Any, # option to suppress maxlen ) -> None: - return await self._publish( + cmd = RedisPublishCommand( message, - channel=channel, - reply_to=reply_to, - headers=headers, - correlation_id=correlation_id, - _extra_middlewares=(), - **kwargs, + channel=channel or self.channel.name, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, ) + await self._basic_publish(cmd, _extra_middlewares=()) @override async def _publish( self, - message: "SendableMessage" = None, - channel: Optional[str] = None, - reply_to: str = "", - headers: Optional["AnyDict"] = None, - correlation_id: Optional[str] = None, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), - **kwargs: Any, # option to suppress maxlen + cmd: Union["PublishCommand", "RedisPublishCommand"], + *, + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 + """This method should be called in subscriber flow only.""" + cmd = RedisPublishCommand.from_cmd(cmd) - channel_sub = PubSub.validate(channel or self.channel) - reply_to = reply_to or self.reply_to - headers = headers or self.headers - correlation_id = correlation_id or gen_cor_id() + cmd.set_destination(channel=self.channel.name) - call: Callable[..., Awaitable[None]] = self._producer.publish - - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - await call( - message, - channel=channel_sub.name, - # basic args - reply_to=reply_to, - headers=headers, - correlation_id=correlation_id, - ) + await self._basic_publish(cmd, _extra_middlewares=_extra_middlewares) @override async def request( @@ -206,44 +182,17 @@ async def request( Optional[float], Doc("RPC reply waiting time."), ] = 30.0, - # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), ) -> "RedisMessage": - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - kwargs = { - "channel": PubSub.validate(channel or self.channel).name, - # basic args - "headers": headers or self.headers, - "correlation_id": correlation_id or gen_cor_id(), - "timeout": timeout, - } - request: Callable[..., Awaitable[Any]] = self._producer.request - - for pub_m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - request = partial(pub_m, request) - - published_msg = await request( + cmd = RedisPublishCommand( message, - **kwargs, + channel=channel or self.channel.name, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Request, + timeout=timeout, ) - msg: RedisMessage = await process_msg( - msg=published_msg, - middlewares=self._broker_middlewares, - parser=self._producer._parser, - decoder=self._producer._decoder, - ) - msg._source_type = SourceType.Response + msg: RedisMessage = await self._basic_request(cmd) return msg @@ -315,56 +264,34 @@ async def publish( "**correlation_id** is a useful option to trace messages.", ), ] = None, - # publisher specific - **kwargs: Any, # option to suppress maxlen ) -> None: - return await self._publish( + cmd = RedisPublishCommand( message, - list=list, - reply_to=reply_to, - headers=headers, - correlation_id=correlation_id, - _extra_middlewares=(), - **kwargs, + list=list or self.list.name, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, ) + return await self._basic_publish(cmd, _extra_middlewares=()) + @override async def _publish( self, - message: "SendableMessage" = None, - list: Optional[str] = None, - reply_to: str = "", - headers: Optional["AnyDict"] = None, - correlation_id: Optional[str] = None, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), - **kwargs: Any, # option to suppress maxlen + cmd: Union["PublishCommand", "RedisPublishCommand"], + *, + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - list_sub = ListSub.validate(list or self.list) - reply_to = reply_to or self.reply_to - correlation_id = correlation_id or gen_cor_id() + """This method should be called in subscriber flow only.""" + cmd = RedisPublishCommand.from_cmd(cmd) - call: Callable[..., Awaitable[None]] = self._producer.publish + cmd.set_destination(list=self.list.name) - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - await call( - message, - list=list_sub.name, - # basic args - reply_to=reply_to, - headers=headers or self.headers, - correlation_id=correlation_id, - ) + await self._basic_publish(cmd, _extra_middlewares=_extra_middlewares) @override async def request( @@ -393,45 +320,17 @@ async def request( Optional[float], Doc("RPC reply waiting time."), ] = 30.0, - # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), ) -> "RedisMessage": - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - kwargs = { - "list": ListSub.validate(list or self.list).name, - # basic args - "headers": headers or self.headers, - "correlation_id": correlation_id or gen_cor_id(), - "timeout": timeout, - } - - request: Callable[..., Awaitable[Any]] = self._producer.request - - for pub_m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - request = partial(pub_m, request) - - published_msg = await request( + cmd = RedisPublishCommand( message, - **kwargs, + list=list or self.list.name, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Request, + timeout=timeout, ) - msg: RedisMessage = await process_msg( - msg=published_msg, - middlewares=self._broker_middlewares, - parser=self._producer._parser, - decoder=self._producer._decoder, - ) - msg._source_type = SourceType.Response + msg: RedisMessage = await self._basic_request(cmd) return msg @@ -439,69 +338,57 @@ class ListBatchPublisher(ListPublisher): @override async def publish( # type: ignore[override] self, - message: Annotated[ - Iterable["SendableMessage"], - Doc("Message body to send."), - ] = (), + *messages: Annotated[ + "SendableMessage", + Doc("Messages bodies to send."), + ], list: Annotated[ - Optional[str], - Doc("Redis List object name to send message."), - ] = None, - *, + str, + Doc("Redis List object name to send messages."), + ], correlation_id: Annotated[ Optional[str], - Doc("Has no real effect. Option to be compatible with original protocol."), + Doc( + "Manual message **correlation_id** setter. " + "**correlation_id** is a useful option to trace messages.", + ), ] = None, + reply_to: Annotated[ + str, + Doc("Reply message destination PubSub object name."), + ] = "", headers: Annotated[ Optional["AnyDict"], Doc("Message headers to store metainformation."), ] = None, - # publisher specific - **kwargs: Any, # option to suppress maxlen ) -> None: - return await self._publish( - message, - list=list, - correlation_id=correlation_id, - headers=headers, - _extra_middlewares=(), - **kwargs, + cmd = RedisPublishCommand( + *messages, + list=list or self.list.name, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Publish, ) + await self._basic_publish_batch(cmd, _extra_middlewares=()) + @override async def _publish( # type: ignore[override] self, - message: "SendableMessage" = (), - list: Optional[str] = None, + cmd: Union["PublishCommand", "RedisPublishCommand"], *, - correlation_id: Optional[str] = None, - headers: Optional["AnyDict"] = None, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), - **kwargs: Any, # option to suppress maxlen + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 + """This method should be called in subscriber flow only.""" + cmd = RedisPublishCommand.from_cmd(cmd, batch=True) - list_sub = ListSub.validate(list or self.list) - correlation_id = correlation_id or gen_cor_id() + cmd.set_destination(list=self.list.name) - call: Callable[..., Awaitable[None]] = self._producer.publish_batch + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) - - await call( - *message, - list=list_sub.name, - correlation_id=correlation_id, - headers=headers or self.headers, - ) + await self._basic_publish_batch(cmd, _extra_middlewares=_extra_middlewares) class StreamPublisher(LogicPublisher): @@ -581,57 +468,35 @@ async def publish( ), ] = None, ) -> None: - return await self._publish( + cmd = RedisPublishCommand( message, - stream=stream, - reply_to=reply_to, - headers=headers, - correlation_id=correlation_id, - maxlen=maxlen, - _extra_middlewares=(), + stream=stream or self.stream.name, + reply_to=reply_to or self.reply_to, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + maxlen=maxlen or self.stream.maxlen, + _publish_type=PublishType.Publish, ) + return await self._basic_publish(cmd, _extra_middlewares=()) + @override async def _publish( self, - message: "SendableMessage" = None, - stream: Optional[str] = None, - reply_to: str = "", - headers: Optional["AnyDict"] = None, - correlation_id: Optional[str] = None, + cmd: Union["PublishCommand", "RedisPublishCommand"], *, - maxlen: Optional[int] = None, - # publisher specific - _extra_middlewares: Iterable["PublisherMiddleware"] = (), + _extra_middlewares: Iterable["PublisherMiddleware"], ) -> None: - assert self._producer, NOT_CONNECTED_YET # nosec B101 + """This method should be called in subscriber flow only.""" + cmd = RedisPublishCommand.from_cmd(cmd) - stream_sub = StreamSub.validate(stream or self.stream) - maxlen = maxlen or stream_sub.maxlen - reply_to = reply_to or self.reply_to - headers = headers or self.headers - correlation_id = correlation_id or gen_cor_id() + cmd.set_destination(stream=self.stream.name) - call: Callable[..., Awaitable[None]] = self._producer.publish + cmd.add_headers(self.headers, override=False) + cmd.reply_to = cmd.reply_to or self.reply_to + cmd.maxlen = self.stream.maxlen - for m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - call = partial(m, call) - - await call( - message, - stream=stream_sub.name, - maxlen=maxlen, - # basic args - reply_to=reply_to, - headers=headers, - correlation_id=correlation_id, - ) + await self._basic_publish(cmd, _extra_middlewares=_extra_middlewares) @override async def request( @@ -667,43 +532,16 @@ async def request( Optional[float], Doc("RPC reply waiting time."), ] = 30.0, - # publisher specific - _extra_middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Extra middlewares to wrap publishing process."), - ] = (), ) -> "RedisMessage": - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - kwargs = { - "stream": StreamSub.validate(stream or self.stream).name, - # basic args - "headers": headers or self.headers, - "correlation_id": correlation_id or gen_cor_id(), - "timeout": timeout, - } - - request: Callable[..., Awaitable[Any]] = self._producer.request - - for pub_m in chain( - ( - _extra_middlewares - or (m(None).publish_scope for m in self._broker_middlewares) - ), - self._middlewares, - ): - request = partial(pub_m, request) - - published_msg = await request( + cmd = RedisPublishCommand( message, - **kwargs, + stream=stream or self.stream.name, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + _publish_type=PublishType.Request, + maxlen=maxlen or self.stream.maxlen, + timeout=timeout, ) - msg: RedisMessage = await process_msg( - msg=published_msg, - middlewares=self._broker_middlewares, - parser=self._producer._parser, - decoder=self._producer._decoder, - ) - msg._source_type = SourceType.Response + msg: RedisMessage = await self._basic_request(cmd) return msg diff --git a/faststream/redis/response.py b/faststream/redis/response.py index b5f4a231f9..a0b830b6c9 100644 --- a/faststream/redis/response.py +++ b/faststream/redis/response.py @@ -1,13 +1,24 @@ -from typing import TYPE_CHECKING, Optional +from collections.abc import Sequence +from enum import Enum +from typing import TYPE_CHECKING, Optional, Union from typing_extensions import override -from faststream.response import Response +from faststream.exceptions import SetupError +from faststream.redis.schemas import INCORRECT_SETUP_MSG +from faststream.response.publish_type import PublishType +from faststream.response.response import PublishCommand, Response if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, SendableMessage +class DestinationType(str, Enum): + Channel = "channel" + List = "list" + Stream = "stream" + + class RedisResponse(Response): def __init__( self, @@ -25,8 +36,107 @@ def __init__( self.maxlen = maxlen @override - def as_publish_kwargs(self) -> "AnyDict": - return { - **super().as_publish_kwargs(), - "maxlen": self.maxlen, - } + def as_publish_command(self) -> "RedisPublishCommand": + return RedisPublishCommand( + self.body, + headers=self.headers, + correlation_id=self.correlation_id, + _publish_type=PublishType.Reply, + # Kafka specific + channel="fake-channel", # it will be replaced by reply-sender + maxlen=self.maxlen, + ) + + +class RedisPublishCommand(PublishCommand): + destination_type: DestinationType + + def __init__( + self, + message: "SendableMessage", + /, + *messages: "SendableMessage", + _publish_type: "PublishType", + correlation_id: Optional[str] = None, + channel: Optional[str] = None, + list: Optional[str] = None, + stream: Optional[str] = None, + maxlen: Optional[int] = None, + headers: Optional["AnyDict"] = None, + reply_to: str = "", + timeout: Optional[float] = 30.0, + ) -> None: + super().__init__( + message, + _publish_type=_publish_type, + correlation_id=correlation_id, + reply_to=reply_to, + destination="", + headers=headers, + ) + self.extra_bodies = messages + + self.set_destination( + channel=channel, + list=list, + stream=stream, + ) + + # Stream option + self.maxlen = maxlen + + # Request option + self.timeout = timeout + + def set_destination( + self, + *, + channel: Optional[str] = None, + list: Optional[str] = None, + stream: Optional[str] = None, + ) -> str: + if channel is not None: + self.destination_type = DestinationType.Channel + self.destination = channel + elif list is not None: + self.destination_type = DestinationType.List + self.destination = list + elif stream is not None: + self.destination_type = DestinationType.Stream + self.destination = stream + else: + raise SetupError(INCORRECT_SETUP_MSG) + + @property + def batch_bodies(self) -> tuple["SendableMessage", ...]: + if self.body: + return (self.body, *self.extra_bodies) + return self.extra_bodies + + @classmethod + def from_cmd( + cls, + cmd: Union["PublishCommand", "RedisPublishCommand"], + *, + batch: bool = False, + ) -> "RedisPublishCommand": + if isinstance(cmd, RedisPublishCommand): + # NOTE: Should return a copy probably. + return cmd + + body, extra_bodies = cmd.body, [] + if batch and isinstance(body, Sequence) and not isinstance(body, str): + if body: + body, extra_bodies = body[0], body[1:] + else: + body = None + + return cls( + body, + *extra_bodies, + channel=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + _publish_type=cmd.publish_type, + ) diff --git a/faststream/redis/schemas/proto.py b/faststream/redis/schemas/proto.py index 1b4a5526f6..2644432dcd 100644 --- a/faststream/redis/schemas/proto.py +++ b/faststream/redis/schemas/proto.py @@ -23,7 +23,7 @@ def validate_options( channel: Union["PubSub", str, None], list: Union["ListSub", str, None], stream: Union["StreamSub", str, None], -) -> None: +) -> str: if all((channel, list)): msg = "You can't use `PubSub` and `ListSub` both" raise SetupError(msg) @@ -33,3 +33,4 @@ def validate_options( if all((list, stream)): msg = "You can't use `ListSub` and `StreamSub` both" raise SetupError(msg) + return channel or list or stream diff --git a/faststream/redis/subscriber/usecase.py b/faststream/redis/subscriber/usecase.py index 6ecc4793c6..769aaf34e5 100644 --- a/faststream/redis/subscriber/usecase.py +++ b/faststream/redis/subscriber/usecase.py @@ -19,7 +19,6 @@ from redis.exceptions import ResponseError from typing_extensions import TypeAlias, override -from faststream._internal.publisher.fake import FakePublisher from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream.redis.message import ( @@ -40,13 +39,14 @@ RedisPubSubParser, RedisStreamParser, ) +from faststream.redis.publisher.fake import RedisFakePublisher from faststream.redis.schemas import ListSub, PubSub, StreamSub if TYPE_CHECKING: from fast_depends.dependencies import Depends from faststream._internal.basic_types import AnyDict, LoggerProto - from faststream._internal.publisher.proto import ProducerProto + from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto from faststream._internal.setup import SetupState from faststream._internal.types import ( AsyncCallable, @@ -130,16 +130,14 @@ def _setup( # type: ignore[override] def _make_response_publisher( self, message: "BrokerStreamMessage[UnifyRedisDict]", - ) -> Sequence[FakePublisher]: + ) -> Sequence["BasePublisherProto"]: if self._producer is None: return () return ( - FakePublisher( - self._producer.publish, - publish_kwargs={ - "channel": message.reply_to, - }, + RedisFakePublisher( + self._producer, + channel=message.reply_to, ), ) diff --git a/faststream/redis/testing.py b/faststream/redis/testing.py index 48205dbe08..2237af768d 100644 --- a/faststream/redis/testing.py +++ b/faststream/redis/testing.py @@ -28,6 +28,7 @@ ) from faststream.redis.parser import RawMessage, RedisPubSubParser from faststream.redis.publisher.producer import RedisFastProducer +from faststream.redis.response import DestinationType, RedisPublishCommand from faststream.redis.schemas import INCORRECT_SETUP_MSG from faststream.redis.subscriber.usecase import ( ChannelSubscriber, @@ -108,26 +109,16 @@ def __init__(self, broker: RedisBroker) -> None: @override async def publish( self, - message: "SendableMessage", - *, - channel: Optional[str] = None, - list: Optional[str] = None, - stream: Optional[str] = None, - maxlen: Optional[int] = None, - headers: Optional["AnyDict"] = None, - reply_to: str = "", - correlation_id: Optional[str] = None, + cmd: "RedisPublishCommand", ) -> None: - correlation_id = correlation_id or gen_cor_id() - body = build_message( - message=message, - reply_to=reply_to, - correlation_id=correlation_id, - headers=headers, + message=cmd.body, + reply_to=cmd.reply_to, + correlation_id=cmd.correlation_id or gen_cor_id(), + headers=cmd.headers, ) - destination = _make_destionation_kwargs(channel, list, stream) + destination = _make_destionation_kwargs(cmd) visitors = (ChannelVisitor(), ListVisitor(), StreamVisitor()) for handler in self.broker._subscribers: # pragma: no branch @@ -144,25 +135,15 @@ async def publish( @override async def request( # type: ignore[override] self, - message: "SendableMessage", - *, - correlation_id: str, - channel: Optional[str] = None, - list: Optional[str] = None, - stream: Optional[str] = None, - maxlen: Optional[int] = None, - headers: Optional["AnyDict"] = None, - timeout: Optional[float] = 30.0, + cmd: "RedisPublishCommand", ) -> "PubSubMessage": - correlation_id = correlation_id or gen_cor_id() - body = build_message( - message=message, - correlation_id=correlation_id, - headers=headers, + message=cmd.body, + correlation_id=cmd.correlation_id or gen_cor_id(), + headers=cmd.headers, ) - destination = _make_destionation_kwargs(channel, list, stream) + destination = _make_destionation_kwargs(cmd) visitors = (ChannelVisitor(), ListVisitor(), StreamVisitor()) for handler in self.broker._subscribers: # pragma: no branch @@ -174,34 +155,33 @@ async def request( # type: ignore[override] handler, # type: ignore[arg-type] ) - with anyio.fail_after(timeout): + with anyio.fail_after(cmd.timeout): return await self._execute_handler(msg, handler) raise SubscriberNotFound async def publish_batch( self, - *msgs: "SendableMessage", - list: str, - headers: Optional["AnyDict"] = None, - correlation_id: Optional[str] = None, + cmd: "RedisPublishCommand", ) -> None: data_to_send = [ build_message( m, - correlation_id=correlation_id or gen_cor_id(), - headers=headers, + correlation_id=cmd.correlation_id or gen_cor_id(), + headers=cmd.headers, ) - for m in msgs + for m in cmd.batch_bodies ] visitor = ListVisitor() for handler in self.broker._subscribers: # pragma: no branch - if visitor.visit(list=list, sub=handler): + if visitor.visit(list=cmd.destination, sub=handler): casted_handler = cast(_ListHandlerMixin, handler) if casted_handler.list_sub.batch: - msg = visitor.get_message(list, data_to_send, casted_handler) + msg = visitor.get_message( + cmd.destination, data_to_send, casted_handler + ) await self._execute_handler(msg, handler) @@ -375,18 +355,14 @@ class _DestinationKwargs(TypedDict, total=False): stream: str -def _make_destionation_kwargs( - channel: Optional[str], - list: Optional[str], - stream: Optional[str], -) -> _DestinationKwargs: +def _make_destionation_kwargs(cmd: RedisPublishCommand) -> _DestinationKwargs: destination: _DestinationKwargs = {} - if channel: - destination["channel"] = channel - if list: - destination["list"] = list - if stream: - destination["stream"] = stream + if cmd.destination_type is DestinationType.Channel: + destination["channel"] = cmd.destination + if cmd.destination_type is DestinationType.List: + destination["list"] = cmd.destination + if cmd.destination_type is DestinationType.Stream: + destination["stream"] = cmd.destination if len(destination) != 1: raise SetupError(INCORRECT_SETUP_MSG) diff --git a/faststream/response/__init__.py b/faststream/response/__init__.py index 686ec1dc50..9a0cc2410e 100644 --- a/faststream/response/__init__.py +++ b/faststream/response/__init__.py @@ -1,7 +1,10 @@ -from .response import Response +from .publish_type import PublishType +from .response import PublishCommand, Response from .utils import ensure_response __all__ = ( + "PublishCommand", + "PublishType", "Response", "ensure_response", ) diff --git a/faststream/response/publish_type.py b/faststream/response/publish_type.py new file mode 100644 index 0000000000..b837b91632 --- /dev/null +++ b/faststream/response/publish_type.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class PublishType(str, Enum): + Publish = "Publish" + """Regular `broker/publisher.publish(...)` call.""" + + Reply = "Reply" + """Response to RPC/Reply-To request.""" + + Request = "Request" + """RPC request call.""" diff --git a/faststream/response/response.py b/faststream/response/response.py index cbb338bed7..09136c8a28 100644 --- a/faststream/response/response.py +++ b/faststream/response/response.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING, Any, Optional +from .publish_type import PublishType + if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict @@ -17,19 +19,50 @@ def __init__( self.headers = headers or {} self.correlation_id = correlation_id + def as_publish_command(self) -> "PublishCommand": + return PublishCommand( + body=self.body, + headers=self.headers, + correlation_id=self.correlation_id, + _publish_type=PublishType.Reply, + ) + + +class PublishCommand(Response): + def __init__( + self, + body: Any, + *, + _publish_type: PublishType, + reply_to: str = "", + destination: str = "", + correlation_id: Optional[str] = None, + headers: Optional["AnyDict"] = None, + ) -> None: + super().__init__( + body, + headers=headers, + correlation_id=correlation_id, + ) + + self.destination = destination + self.reply_to = reply_to + + self.publish_type = _publish_type + + @property + def batch_bodies(self) -> tuple["Any", ...]: + if self.body: + return (self.body,) + return () + def add_headers( self, - extra_headers: "AnyDict", + headers: "AnyDict", *, override: bool = True, ) -> None: if override: - self.headers = {**self.headers, **extra_headers} + self.headers |= headers else: - self.headers = {**extra_headers, **self.headers} - - def as_publish_kwargs(self) -> "AnyDict": - return { - "headers": self.headers, - "correlation_id": self.correlation_id, - } + self.headers = headers | self.headers diff --git a/tests/a_docs/getting_started/subscription/test_annotated.py b/tests/a_docs/getting_started/subscription/test_annotated.py index d3b608277c..07c2e841a2 100644 --- a/tests/a_docs/getting_started/subscription/test_annotated.py +++ b/tests/a_docs/getting_started/subscription/test_annotated.py @@ -1,7 +1,8 @@ -from typing import Any, TypeAlias +from typing import Any import pytest from pydantic import ValidationError +from typing_extensions import TypeAlias from faststream._internal.broker.broker import BrokerUsecase from faststream._internal.subscriber.usecase import SubscriberUsecase diff --git a/tests/asyncapi/rabbit/v2_6_0/test_publisher.py b/tests/asyncapi/rabbit/v2_6_0/test_publisher.py index c9c8ff7ea7..b9c17a9d00 100644 --- a/tests/asyncapi/rabbit/v2_6_0/test_publisher.py +++ b/tests/asyncapi/rabbit/v2_6_0/test_publisher.py @@ -185,4 +185,4 @@ async def handle(msg) -> None: ... }, "servers": ["development"], }, - } + }, schema["channels"] diff --git a/tests/brokers/base/consume.py b/tests/brokers/base/consume.py index f6d6510a67..9f0acf2806 100644 --- a/tests/brokers/base/consume.py +++ b/tests/brokers/base/consume.py @@ -1,5 +1,4 @@ import asyncio -from typing import NoReturn from unittest.mock import MagicMock import anyio @@ -343,7 +342,7 @@ async def test_stop_consume_exc( args, kwargs = self.get_subscriber_params(queue) @consume_broker.subscriber(*args, **kwargs) - def subscriber(m) -> NoReturn: + def subscriber(m): mock() event.set() raise StopConsume diff --git a/tests/brokers/base/middlewares.py b/tests/brokers/base/middlewares.py index 22111f4ebc..dbde16432f 100644 --- a/tests/brokers/base/middlewares.py +++ b/tests/brokers/base/middlewares.py @@ -1,5 +1,4 @@ import asyncio -from typing import NoReturn from unittest.mock import Mock, call import pytest @@ -8,6 +7,7 @@ from faststream._internal.basic_types import DecodedMessage from faststream.exceptions import SkipMessage from faststream.middlewares import BaseMiddleware, ExceptionMiddleware +from faststream.response import PublishCommand from .basic import BaseTestcaseConfig @@ -209,7 +209,7 @@ async def mid(call_next, msg): args, kwargs = self.get_subscriber_params(queue, middlewares=(mid,)) @broker.subscriber(*args, **kwargs) - async def handler2(m) -> NoReturn: + async def handler2(m): event.set() raise ValueError @@ -331,8 +331,9 @@ async def test_patch_publish( event: asyncio.Event, ) -> None: class Mid(BaseMiddleware): - async def on_publish(self, msg: str, *args, **kwargs) -> str: - return msg * 2 + async def on_publish(self, msg: PublishCommand) -> PublishCommand: + msg.body *= 2 + return msg broker = self.get_broker(middlewares=(Mid,)) @@ -370,11 +371,10 @@ async def test_global_publisher_middleware( mock: Mock, ) -> None: class Mid(BaseMiddleware): - async def on_publish(self, msg: str, *args, **kwargs) -> str: - data = msg * 2 - assert args or kwargs - mock.enter(data) - return data + async def on_publish(self, msg: PublishCommand) -> PublishCommand: + msg.body *= 2 + mock.enter(msg.body) + return msg async def after_publish(self, *args, **kwargs) -> None: mock.end() @@ -429,7 +429,7 @@ async def value_error_handler(exc) -> str: @broker.subscriber(*args, **kwargs) @broker.publisher(queue + "1") - async def subscriber1(m) -> NoReturn: + async def subscriber1(m): raise ValueError args, kwargs = self.get_subscriber_params(queue + "1") @@ -462,7 +462,7 @@ async def test_exception_middleware_skip_msg( mid = ExceptionMiddleware() @mid.add_handler(ValueError, publish=True) - async def value_error_handler(exc) -> NoReturn: + async def value_error_handler(exc): event.set() raise SkipMessage @@ -471,7 +471,7 @@ async def value_error_handler(exc) -> NoReturn: @broker.subscriber(*args, **kwargs) @broker.publisher(queue + "1") - async def subscriber1(m) -> NoReturn: + async def subscriber1(m): raise ValueError args2, kwargs2 = self.get_subscriber_params(queue + "1") @@ -509,7 +509,7 @@ async def value_error_handler(exc) -> None: args, kwargs = self.get_subscriber_params(queue) @broker.subscriber(*args, **kwargs) - async def subscriber(m) -> NoReturn: + async def subscriber(m): event.set() raise SkipMessage @@ -536,7 +536,7 @@ async def test_exception_middleware_reraise( mid = ExceptionMiddleware() @mid.add_handler(ValueError, publish=True) - async def value_error_handler(exc) -> NoReturn: + async def value_error_handler(exc): event.set() raise exc @@ -545,7 +545,7 @@ async def value_error_handler(exc) -> NoReturn: @broker.subscriber(*args, **kwargs) @broker.publisher(queue + "1") - async def subscriber1(m) -> NoReturn: + async def subscriber1(m): raise ValueError args2, kwargs2 = self.get_subscriber_params(queue + "1") @@ -590,14 +590,14 @@ async def value_error_handler(exc) -> str: @broker.subscriber(*args, **kwargs) @publisher - async def subscriber1(m) -> NoReturn: + async def subscriber1(m): raise ZeroDivisionError args2, kwargs2 = self.get_subscriber_params(queue + "1") @broker.subscriber(*args2, **kwargs2) @publisher - async def subscriber2(m) -> NoReturn: + async def subscriber2(m): raise ValueError args3, kwargs3 = self.get_subscriber_params(queue + "2") @@ -670,7 +670,7 @@ async def value_error_handler(exc) -> None: args, kwargs = self.get_subscriber_params(queue) @broker.subscriber(*args, **kwargs) - async def subscriber1(m) -> NoReturn: + async def subscriber1(m): raise ZeroDivisionError async with self.patch_broker(broker) as br: diff --git a/tests/brokers/base/requests.py b/tests/brokers/base/requests.py index a9beb8f764..9cb8296fa9 100644 --- a/tests/brokers/base/requests.py +++ b/tests/brokers/base/requests.py @@ -1,5 +1,3 @@ -from typing import NoReturn - import anyio import pytest @@ -7,10 +5,10 @@ class RequestsTestcase(BaseTestcaseConfig): - def get_middleware(self, **kwargs) -> NoReturn: + def get_middleware(self, **kwargs): raise NotImplementedError - def get_router(self, **kwargs) -> NoReturn: + def get_router(self, **kwargs): raise NotImplementedError async def test_request_timeout(self, queue: str) -> None: diff --git a/tests/brokers/base/testclient.py b/tests/brokers/base/testclient.py index a594eebca4..6326eacdbb 100644 --- a/tests/brokers/base/testclient.py +++ b/tests/brokers/base/testclient.py @@ -1,6 +1,5 @@ import asyncio from abc import abstractmethod -from typing import NoReturn from unittest.mock import Mock import anyio @@ -117,7 +116,7 @@ async def test_exception_raises(self, queue: str) -> None: args, kwargs = self.get_subscriber_params(queue) @test_broker.subscriber(*args, **kwargs) - async def m(msg) -> NoReturn: # pragma: no cover + async def m(msg): # pragma: no cover raise ValueError async with self.patch_broker(test_broker) as br: diff --git a/tests/brokers/confluent/test_consume.py b/tests/brokers/confluent/test_consume.py index 1cbb6889f7..6f61f01d5f 100644 --- a/tests/brokers/confluent/test_consume.py +++ b/tests/brokers/confluent/test_consume.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, NoReturn +from typing import Any from unittest.mock import patch import pytest @@ -180,7 +180,7 @@ async def test_consume_ack_raise( ) @consume_broker.subscriber(*args, **kwargs) - async def handler(msg: KafkaMessage) -> NoReturn: + async def handler(msg: KafkaMessage): event.set() raise AckMessage diff --git a/tests/brokers/confluent/test_publish_command.py b/tests/brokers/confluent/test_publish_command.py new file mode 100644 index 0000000000..0f21843038 --- /dev/null +++ b/tests/brokers/confluent/test_publish_command.py @@ -0,0 +1,46 @@ +from typing import Any + +import pytest + +from faststream import Response +from faststream.confluent.response import KafkaPublishCommand, KafkaResponse +from faststream.response import ensure_response + + +def test_simple_reponse(): + response = ensure_response(1) + cmd = KafkaPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + + +def test_base_response_class(): + response = ensure_response(Response(body=1, headers={1: 1})) + cmd = KafkaPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + assert cmd.headers == {1: 1} + + +def test_kafka_response_class(): + response = ensure_response(KafkaResponse(body=1, headers={1: 1}, key=b"1")) + cmd = KafkaPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + assert cmd.headers == {1: 1} + assert cmd.key == b"1" + + +@pytest.mark.parametrize( + ("data", "expected_body"), + ( + pytest.param(None, (), id="None Response"), + pytest.param((), (), id="Empty Sequence"), + pytest.param("123", ("123",), id="String Response"), + pytest.param([1, 2, 3], (1, 2, 3), id="Sequence Data"), + ), +) +def test_batch_response(data: Any, expected_body: Any): + response = ensure_response(data) + cmd = KafkaPublishCommand.from_cmd( + response.as_publish_command(), + batch=True, + ) + assert cmd.batch_bodies == expected_body diff --git a/tests/brokers/kafka/test_consume.py b/tests/brokers/kafka/test_consume.py index 84db65b32d..f5e5421fbd 100644 --- a/tests/brokers/kafka/test_consume.py +++ b/tests/brokers/kafka/test_consume.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, NoReturn +from typing import Any from unittest.mock import patch import pytest @@ -221,7 +221,7 @@ async def test_consume_ack_raise( consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, group_id="test", auto_commit=False) - async def handler(msg: KafkaMessage) -> NoReturn: + async def handler(msg: KafkaMessage): event.set() raise AckMessage diff --git a/tests/brokers/kafka/test_publish_command.py b/tests/brokers/kafka/test_publish_command.py new file mode 100644 index 0000000000..0c2b43b781 --- /dev/null +++ b/tests/brokers/kafka/test_publish_command.py @@ -0,0 +1,46 @@ +from typing import Any + +import pytest + +from faststream import Response +from faststream.kafka.response import KafkaPublishCommand, KafkaResponse +from faststream.response import ensure_response + + +def test_simple_reponse(): + response = ensure_response(1) + cmd = KafkaPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + + +def test_base_response_class(): + response = ensure_response(Response(body=1, headers={1: 1})) + cmd = KafkaPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + assert cmd.headers == {1: 1} + + +def test_kafka_response_class(): + response = ensure_response(KafkaResponse(body=1, headers={1: 1}, key=b"1")) + cmd = KafkaPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + assert cmd.headers == {1: 1} + assert cmd.key == b"1" + + +@pytest.mark.parametrize( + ("data", "expected_body"), + ( + pytest.param(None, (), id="None Response"), + pytest.param((), (), id="Empty Sequence"), + pytest.param("123", ("123",), id="String Response"), + pytest.param([1, 2, 3], (1, 2, 3), id="Sequence Data"), + ), +) +def test_batch_response(data: Any, expected_body: Any): + response = ensure_response(data) + cmd = KafkaPublishCommand.from_cmd( + response.as_publish_command(), + batch=True, + ) + assert cmd.batch_bodies == expected_body diff --git a/tests/brokers/nats/test_consume.py b/tests/brokers/nats/test_consume.py index cc72c36073..d7200e627f 100644 --- a/tests/brokers/nats/test_consume.py +++ b/tests/brokers/nats/test_consume.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, NoReturn +from typing import Any from unittest.mock import Mock, patch import pytest @@ -224,7 +224,7 @@ async def test_consume_ack_raise( consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, stream=stream) - async def handler(msg: NatsMessage) -> NoReturn: + async def handler(msg: NatsMessage): event.set() raise AckMessage diff --git a/tests/brokers/rabbit/test_consume.py b/tests/brokers/rabbit/test_consume.py index 37d79b7728..742daac225 100644 --- a/tests/brokers/rabbit/test_consume.py +++ b/tests/brokers/rabbit/test_consume.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, NoReturn +from typing import Any from unittest.mock import patch import pytest @@ -197,7 +197,7 @@ async def test_consume_manual_nack( consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) - async def handler(msg: RabbitMessage) -> NoReturn: + async def handler(msg: RabbitMessage): await msg.nack() event.set() raise ValueError @@ -268,7 +268,7 @@ async def test_consume_manual_reject( consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) - async def handler(msg: RabbitMessage) -> NoReturn: + async def handler(msg: RabbitMessage): await msg.reject() event.set() raise ValueError diff --git a/tests/brokers/rabbit/test_publish.py b/tests/brokers/rabbit/test_publish.py index 92545e6a1b..4e9b7b3121 100644 --- a/tests/brokers/rabbit/test_publish.py +++ b/tests/brokers/rabbit/test_publish.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import Mock, patch import pytest @@ -10,6 +10,9 @@ from tests.brokers.base.publish import BrokerPublishTestcase from tests.tools import spy_decorator +if TYPE_CHECKING: + from faststream.rabbit.response import RabbitPublishCommand + @pytest.mark.rabbit() class TestPublish(BrokerPublishTestcase): @@ -54,8 +57,9 @@ async def handler(m): timeout=3, ) - assert m.mock.call_args.kwargs.get("persist") - assert m.mock.call_args.kwargs.get("immediate") is False + cmd: RabbitPublishCommand = m.mock.call_args[0][1] + assert cmd.message_options["persist"] + assert not cmd.publish_options["immediate"] assert event.is_set() mock.assert_called_with("Hello!") @@ -72,10 +76,7 @@ async def test_response( @pub_broker.subscriber(queue) @pub_broker.publisher(queue + "1") async def handle(): - return RabbitResponse( - 1, - persist=True, - ) + return RabbitResponse(1, persist=True) @pub_broker.subscriber(queue + "1") async def handle_next(msg=Context("message")) -> None: @@ -100,7 +101,8 @@ async def handle_next(msg=Context("message")) -> None: assert event.is_set() - assert m.mock.call_args.kwargs.get("persist") + cmd: RabbitPublishCommand = m.mock.call_args[0][1] + assert cmd.message_options["persist"] mock.assert_called_once_with(body=b"1") diff --git a/tests/brokers/rabbit/test_test_client.py b/tests/brokers/rabbit/test_test_client.py index f42fee9d01..856ed80668 100644 --- a/tests/brokers/rabbit/test_test_client.py +++ b/tests/brokers/rabbit/test_test_client.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, NoReturn +from typing import Any import pytest @@ -213,13 +213,13 @@ async def handler(msg: RabbitMessage) -> None: consume.set() @broker.subscriber(queue=queue + "1", exchange=exchange, retry=1) - async def handler2(msg: RabbitMessage) -> NoReturn: + async def handler2(msg: RabbitMessage): await msg.raw_message.nack() consume2.set() raise ValueError @broker.subscriber(queue=queue + "2", exchange=exchange, retry=1) - async def handler3(msg: RabbitMessage) -> NoReturn: + async def handler3(msg: RabbitMessage): await msg.raw_message.reject() consume3.set() raise ValueError diff --git a/tests/brokers/redis/test_publish_command.py b/tests/brokers/redis/test_publish_command.py new file mode 100644 index 0000000000..78c272e26e --- /dev/null +++ b/tests/brokers/redis/test_publish_command.py @@ -0,0 +1,46 @@ +from typing import Any + +import pytest + +from faststream import Response +from faststream.redis.response import RedisPublishCommand, RedisResponse +from faststream.response import ensure_response + + +def test_simple_reponse(): + response = ensure_response(1) + cmd = RedisPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + + +def test_base_response_class(): + response = ensure_response(Response(body=1, headers={1: 1})) + cmd = RedisPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + assert cmd.headers == {1: 1} + + +def test_kafka_response_class(): + response = ensure_response(RedisResponse(body=1, headers={1: 1}, maxlen=1)) + cmd = RedisPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + assert cmd.headers == {1: 1} + assert cmd.maxlen == 1 + + +@pytest.mark.parametrize( + ("data", "expected_body"), + ( + pytest.param(None, (), id="None Response"), + pytest.param((), (), id="Empty Sequence"), + pytest.param("123", ("123",), id="String Response"), + pytest.param([1, 2, 3], (1, 2, 3), id="Sequence Data"), + ), +) +def test_batch_response(data: Any, expected_body: Any): + response = ensure_response(data) + cmd = RedisPublishCommand.from_cmd( + response.as_publish_command(), + batch=True, + ) + assert cmd.batch_bodies == expected_body diff --git a/tests/brokers/test_pushback.py b/tests/brokers/test_pushback.py index 7dc083a803..afb064ff69 100644 --- a/tests/brokers/test_pushback.py +++ b/tests/brokers/test_pushback.py @@ -1,4 +1,3 @@ -from typing import NoReturn from unittest.mock import AsyncMock import pytest @@ -92,7 +91,7 @@ async def test_push_endless_back_watcher(async_mock: AsyncMock, message) -> None @pytest.mark.asyncio() -async def test_ignore_skip(async_mock: AsyncMock, message) -> NoReturn: +async def test_ignore_skip(async_mock: AsyncMock, message) -> None: watcher = CounterWatcher(3) context = WatcherContext( @@ -111,7 +110,7 @@ async def test_ignore_skip(async_mock: AsyncMock, message) -> NoReturn: @pytest.mark.asyncio() async def test_additional_params_with_handler_exception( async_mock: AsyncMock, message -) -> NoReturn: +) -> None: watcher = EndlessWatcher() context = WatcherContext( diff --git a/tests/brokers/test_response.py b/tests/brokers/test_response.py index 710706d1c3..a8b669cc52 100644 --- a/tests/brokers/test_response.py +++ b/tests/brokers/test_response.py @@ -1,4 +1,5 @@ -from faststream.response import Response, ensure_response +from faststream.response import ensure_response +from faststream.response.response import Response def test_raw_data() -> None: @@ -13,13 +14,13 @@ def test_response_with_response_instance() -> None: assert resp.headers == {"some": 1} -def test_headers_override() -> None: - resp = Response(1, headers={"some": 1}) - resp.add_headers({"some": 2}) - assert resp.headers == {"some": 2} +def test_add_headers_not_overrides() -> None: + publish_cmd = Response(1, headers={1: 1, 2: 2}).as_publish_command() + publish_cmd.add_headers({1: "ignored", 3: 3}, override=False) + assert publish_cmd.headers == {1: 1, 2: 2, 3: 3} -def test_headers_with_default() -> None: - resp = Response(1, headers={"some": 1}) - resp.add_headers({"some": 2}, override=False) - assert resp.headers == {"some": 1} +def test_add_headers_overrides() -> None: + publish_cmd = Response(1, headers={1: "ignored", 2: 2}).as_publish_command() + publish_cmd.add_headers({1: 1, 3: 3}, override=True) + assert publish_cmd.headers == {1: 1, 2: 2, 3: 3} diff --git a/tests/cli/rabbit/test_app.py b/tests/cli/rabbit/test_app.py index c44d5b05d9..2dcfbc29ea 100644 --- a/tests/cli/rabbit/test_app.py +++ b/tests/cli/rabbit/test_app.py @@ -2,7 +2,6 @@ import os import signal from contextlib import asynccontextmanager -from typing import NoReturn from unittest.mock import AsyncMock, Mock, patch import anyio @@ -303,7 +302,7 @@ async def test_test_app(mock: Mock) -> None: @pytest.mark.asyncio() -async def test_test_app_with_excp(mock: Mock) -> NoReturn: +async def test_test_app_with_excp(mock: Mock) -> None: app = FastStream() app.on_startup(mock.on) @@ -330,7 +329,7 @@ def test_sync_test_app(mock: Mock) -> None: mock.off.assert_called_once() -def test_sync_test_app_with_excp(mock: Mock) -> NoReturn: +def test_sync_test_app_with_excp(mock: Mock) -> None: app = FastStream() app.on_startup(mock.on) diff --git a/tests/cli/test_publish.py b/tests/cli/test_publish.py index 383b89c8d1..c545a03a72 100644 --- a/tests/cli/test_publish.py +++ b/tests/cli/test_publish.py @@ -1,10 +1,11 @@ +from typing import TYPE_CHECKING from unittest.mock import AsyncMock, patch -from dirty_equals import IsPartialDict from typer.testing import CliRunner from faststream import FastStream from faststream._internal.cli.main import cli as faststream_app +from faststream.response.publish_type import PublishType from tests.marks import ( require_aiokafka, require_aiopika, @@ -13,6 +14,15 @@ require_redis, ) +if TYPE_CHECKING: + from faststream.confluent.response import ( + KafkaPublishCommand as ConfluentPublishCommand, + ) + from faststream.kafka.response import KafkaPublishCommand + from faststream.nats.response import NatsPublishCommand + from faststream.rabbit.response import RabbitPublishCommand + from faststream.redis.response import RedisPublishCommand + def get_mock_app(broker_type, producer_type) -> tuple[FastStream, AsyncMock]: broker = broker_type() @@ -46,10 +56,6 @@ def test_publish_command_with_redis_options(runner) -> None: "channelname", "--reply_to", "tester", - "--list", - "listname", - "--stream", - "streamname", "--correlation_id", "someId", ], @@ -57,14 +63,11 @@ def test_publish_command_with_redis_options(runner) -> None: assert result.exit_code == 0 - assert producer_mock.publish.call_args.args[0] == "hello world" - assert producer_mock.publish.call_args.kwargs == IsPartialDict( - reply_to="tester", - stream="streamname", - list="listname", - channel="channelname", - correlation_id="someId", - ) + cmd: RedisPublishCommand = producer_mock.publish.call_args.args[0] + assert cmd.body == "hello world" + assert cmd.reply_to == "tester" + assert cmd.destination == "channelname" + assert cmd.correlation_id == "someId" @require_confluent @@ -93,11 +96,10 @@ def test_publish_command_with_confluent_options(runner) -> None: assert result.exit_code == 0 - assert producer_mock.publish.call_args.args[0] == "hello world" - assert producer_mock.publish.call_args.kwargs == IsPartialDict( - topic="topicname", - correlation_id="someId", - ) + cmd: ConfluentPublishCommand = producer_mock.publish.call_args.args[0] + assert cmd.body == "hello world" + assert cmd.destination == "topicname" + assert cmd.correlation_id == "someId" @require_aiokafka @@ -125,11 +127,11 @@ def test_publish_command_with_kafka_options(runner) -> None: ) assert result.exit_code == 0 - assert producer_mock.publish.call_args.args[0] == "hello world" - assert producer_mock.publish.call_args.kwargs == IsPartialDict( - topic="topicname", - correlation_id="someId", - ) + + cmd: KafkaPublishCommand = producer_mock.publish.call_args.args[0] + assert cmd.body == "hello world" + assert cmd.destination == "topicname" + assert cmd.correlation_id == "someId" @require_nats @@ -160,12 +162,11 @@ def test_publish_command_with_nats_options(runner) -> None: assert result.exit_code == 0 - assert producer_mock.publish.call_args.args[0] == "hello world" - assert producer_mock.publish.call_args.kwargs == IsPartialDict( - subject="subjectname", - reply_to="tester", - correlation_id="someId", - ) + cmd: NatsPublishCommand = producer_mock.publish.call_args.args[0] + assert cmd.body == "hello world" + assert cmd.destination == "subjectname" + assert cmd.reply_to == "tester" + assert cmd.correlation_id == "someId" @require_aiopika @@ -185,6 +186,8 @@ def test_publish_command_with_rabbit_options(runner) -> None: "publish", "fastream:app", "hello world", + "--queue", + "queuename", "--correlation_id", "someId", ], @@ -192,12 +195,10 @@ def test_publish_command_with_rabbit_options(runner) -> None: assert result.exit_code == 0 - assert producer_mock.publish.call_args.args[0] == "hello world" - assert producer_mock.publish.call_args.kwargs == IsPartialDict( - { - "correlation_id": "someId", - }, - ) + cmd: RabbitPublishCommand = producer_mock.publish.call_args.args[0] + assert cmd.body == "hello world" + assert cmd.destination == "queuename" + assert cmd.correlation_id == "someId" @require_nats @@ -225,8 +226,8 @@ def test_publish_nats_request_command(runner: CliRunner) -> None: ], ) - assert producer_mock.request.call_args.args[0] == "hello world" - assert producer_mock.request.call_args.kwargs == IsPartialDict( - subject="subjectname", - timeout=1.0, - ) + cmd: NatsPublishCommand = producer_mock.request.call_args.args[0] + + assert cmd.destination == "subjectname" + assert cmd.timeout == 1.0 + assert cmd.publish_type is PublishType.Request diff --git a/tests/prometheus/basic.py b/tests/prometheus/basic.py index 0a7b4bc666..74c55dc4c0 100644 --- a/tests/prometheus/basic.py +++ b/tests/prometheus/basic.py @@ -6,8 +6,8 @@ from prometheus_client import CollectorRegistry from faststream import Context -from faststream.broker.message import AckStatus from faststream.exceptions import RejectMessage +from faststream.message import AckStatus from faststream.prometheus.middleware import ( PROCESSING_STATUS_BY_ACK_STATUS, PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP, diff --git a/tests/tools.py b/tests/tools.py index 55d4405c9d..c2682f2455 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -1,8 +1,10 @@ import inspect from functools import wraps -from typing import Callable, ParamSpec, Protocol, TypeVar +from typing import Callable, Protocol, TypeVar from unittest.mock import MagicMock +from typing_extensions import ParamSpec + P = ParamSpec("P") T = TypeVar("T") diff --git a/tests/utils/test_ast.py b/tests/utils/test_ast.py index a92c6fcd87..d57d29e88f 100644 --- a/tests/utils/test_ast.py +++ b/tests/utils/test_ast.py @@ -1,5 +1,3 @@ -from typing import NoReturn - import pytest from faststream._internal.testing.ast import is_contains_context_name @@ -75,7 +73,7 @@ def test_nested_invalid() -> None: assert not a.contains -def test_not_broken() -> NoReturn: +def test_not_broken() -> None: with A() as a, B(): assert a.contains From 5a6556a2436821aa3492ec77ebfb7029537a7777 Mon Sep 17 00:00:00 2001 From: Ilya <81091299+ulbwa@users.noreply.github.com> Date: Mon, 28 Oct 2024 00:18:11 +0500 Subject: [PATCH 22/48] Add support for environment variables in faststream run command (#1876) --- faststream/cli/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/faststream/cli/main.py b/faststream/cli/main.py index aeadd28c4f..2775261e72 100644 --- a/faststream/cli/main.py +++ b/faststream/cli/main.py @@ -66,11 +66,13 @@ def run( 1, show_default=False, help="Run [workers] applications with process spawning.", + envvar="FASTSTREAM_WORKERS", ), log_level: LogLevels = typer.Option( LogLevels.notset, case_sensitive=False, help="Set selected level for FastStream and brokers logger objects.", + envvar="FASTSTREAM_LOG_LEVEL", ), reload: bool = typer.Option( False, @@ -93,6 +95,7 @@ def run( "Look for APP in the specified directory, by adding this to the PYTHONPATH." " Defaults to the current working directory." ), + envvar="FASTSTREAM_APP_DIR", ), is_factory: bool = typer.Option( False, From 270332d5a8be7deaf51f158ae712e9039069adcc Mon Sep 17 00:00:00 2001 From: Maks Date: Sun, 27 Oct 2024 22:23:18 +0300 Subject: [PATCH 23/48] fastapi app initialization updated (#1875) --- examples/fastapi_integration/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/fastapi_integration/app.py b/examples/fastapi_integration/app.py index e9f7ccc579..b53f4becfa 100644 --- a/examples/fastapi_integration/app.py +++ b/examples/fastapi_integration/app.py @@ -3,7 +3,7 @@ from faststream.rabbit.fastapi import Logger, RabbitRouter router = RabbitRouter("amqp://guest:guest@localhost:5672/") -app = FastAPI(lifespan=router.lifespan_context) +app = FastAPI() publisher = router.publisher("response-q") From 524af9661891c43d5ba7a5151354c08a1897b5b4 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 27 Oct 2024 22:25:29 +0300 Subject: [PATCH 24/48] Do not import `fake_context` if not needed (#1877) --- faststream/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/faststream/app.py b/faststream/app.py index debe04f7dc..fc18797f1e 100644 --- a/faststream/app.py +++ b/faststream/app.py @@ -17,7 +17,6 @@ from faststream.asgi.app import AsgiFastStream from faststream.cli.supervisors.utils import set_exit from faststream.exceptions import ValidationError -from faststream.utils.functions import fake_context P_HookParams = ParamSpec("P_HookParams") T_HookReturn = TypeVar("T_HookReturn") @@ -77,4 +76,6 @@ async def catch_startup_validation_error() -> AsyncIterator[None]: raise ValidationError(fields=fields) from e except ImportError: + from faststream.utils.functions import fake_context + catch_startup_validation_error = fake_context From cff911e9413300a4874c639baee1407e62087d3c Mon Sep 17 00:00:00 2001 From: Vladislav Tumko <56307628+vectorvp@users.noreply.github.com> Date: Mon, 28 Oct 2024 20:08:16 +0400 Subject: [PATCH 25/48] build: add warning about manual lifespan_context (#1878) --- faststream/broker/fastapi/router.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/faststream/broker/fastapi/router.py b/faststream/broker/fastapi/router.py index baf7ceff53..b7249491ff 100644 --- a/faststream/broker/fastapi/router.py +++ b/faststream/broker/fastapi/router.py @@ -1,4 +1,5 @@ import json +import warnings from abc import abstractmethod from contextlib import asynccontextmanager from enum import Enum @@ -165,6 +166,8 @@ def __init__( self.contact = None self.schema = None + # Flag to prevent double lifespan start + self._lifespan_started = False super().__init__( prefix=prefix, @@ -316,7 +319,15 @@ async def start_broker_lifespan( context = dict(maybe_context) context.update({"broker": self.broker}) - await self.broker.start() + + if not self._lifespan_started: + await self.broker.start() + self._lifespan_started = True + else: + warnings.warn( + "Specifying 'lifespan_context' manually is no longer necessary with FastAPI >= 0.112.2.", + stacklevel=2, + ) for h in self._after_startup_hooks: h_context = await h(app) From 9b7c33e8765485cf28f850cb485afcd838c12079 Mon Sep 17 00:00:00 2001 From: Davor Runje Date: Wed, 30 Oct 2024 12:18:41 +0100 Subject: [PATCH 26/48] Add trending badge (#1882) --- README.md | 5 +++++ docs/docs/en/faststream.md | 5 +++++ faststream/exceptions.py | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bcde26a543..f1b579c40b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ ---

+ airtai%2Ffaststream | Trendshift + +
+
+ Test Passing diff --git a/docs/docs/en/faststream.md b/docs/docs/en/faststream.md index 608a88308a..18ca543802 100644 --- a/docs/docs/en/faststream.md +++ b/docs/docs/en/faststream.md @@ -12,6 +12,11 @@ search: ---

+ airtai%2Ffaststream | Trendshift + +
+
+ Test Passing diff --git a/faststream/exceptions.py b/faststream/exceptions.py index 5de18549ee..231b3eb778 100644 --- a/faststream/exceptions.py +++ b/faststream/exceptions.py @@ -61,7 +61,7 @@ class NackMessage(HandlerException): signature. Args: - extra_options (Any): Additional parameters that will be passed to `message.nack(**extra_options)` method. + kwargs (Any): Additional parameters that will be passed to `message.nack(**extra_options)` method. """ def __init__(self, **kwargs: Any): @@ -80,7 +80,7 @@ class RejectMessage(HandlerException): signature. Args: - extra_options (Any): Additional parameters that will be passed to `message.reject(**extra_options)` method. + kwargs (Any): Additional parameters that will be passed to `message.reject(**extra_options)` method. """ def __init__(self, **kwargs: Any): From e39d0adb3b22745f9ad56e211e3a903af150ba5b Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Thu, 31 Oct 2024 19:55:58 +0300 Subject: [PATCH 27/48] Feat/fast depends 3 (#1886) * refactor: make context not-global * docs: generate API References * tests: refactor context tests * chore: merge main * fix: correct Context propogation from App to Broker * docs: generate API References * tests: in-memory cli * chore: use python3.9 compatible FastDepends * chore: revert FD version --------- Co-authored-by: Lancetnik --- docs/docs/SUMMARY.md | 13 ++ .../broker/state/BrokerState.md} | 2 +- .../broker/state/ConnectedState.md} | 2 +- .../broker/state/ConnectionBrokenState.md | 11 + .../nats/broker/state/EmptyBrokerState.md | 11 + .../nats/helpers/state/ConnectedState.md | 11 + .../nats/helpers/state/ConnectionState.md | 11 + .../helpers/state/EmptyConnectionState.md | 11 + .../state/ConnectedSubscriberState.md | 11 + .../subscriber/state/EmptySubscriberState.md | 11 + .../nats/subscriber/state/SubscriberState.md | 11 + .../en/getting-started/dependencies/index.md | 2 +- .../en/getting-started/subscription/index.md | 2 +- .../asyncapi/asyncapi_customization/basic.py | 2 +- .../asyncapi_customization/custom_info.py | 4 +- .../getting_started/context/confluent/cast.py | 4 +- .../context/confluent/custom_local_context.py | 2 +- .../context/confluent/manual_local_context.py | 7 +- .../getting_started/context/kafka/cast.py | 4 +- .../context/kafka/custom_local_context.py | 2 +- .../context/kafka/manual_local_context.py | 7 +- .../getting_started/context/nats/cast.py | 4 +- .../context/nats/custom_local_context.py | 2 +- .../context/nats/manual_local_context.py | 8 +- .../getting_started/context/nested.py | 2 +- .../getting_started/context/rabbit/cast.py | 4 +- .../context/rabbit/custom_local_context.py | 2 +- .../context/rabbit/manual_local_context.py | 8 +- .../getting_started/context/redis/cast.py | 4 +- .../context/redis/custom_local_context.py | 2 +- .../context/redis/manual_local_context.py | 8 +- .../getting_started/lifespan/multiple.py | 4 +- .../subscription/confluent/real_testing.py | 2 +- .../subscription/confluent/testing.py | 2 +- .../subscription/kafka/real_testing.py | 2 +- .../subscription/kafka/testing.py | 2 +- .../subscription/nats/real_testing.py | 2 +- .../subscription/nats/testing.py | 2 +- .../subscription/rabbit/real_testing.py | 2 +- .../subscription/rabbit/testing.py | 2 +- .../subscription/redis/real_testing.py | 2 +- .../subscription/redis/testing.py | 2 +- docs/docs_src/index/confluent/test.py | 4 +- docs/docs_src/index/kafka/test.py | 4 +- docs/docs_src/index/nats/test.py | 4 +- docs/docs_src/index/rabbit/test.py | 4 +- docs/docs_src/index/redis/test.py | 4 +- faststream/__init__.py | 3 - faststream/_internal/_compat.py | 7 +- faststream/_internal/application.py | 103 ++++++--- faststream/_internal/broker/abc_broker.py | 6 +- faststream/_internal/broker/broker.py | 74 +++--- faststream/_internal/broker/router.py | 4 +- faststream/_internal/context/__init__.py | 3 +- faststream/_internal/context/context_type.py | 1 + faststream/_internal/context/repository.py | 5 - faststream/_internal/context/resolve.py | 6 +- faststream/_internal/fastapi/context.py | 14 +- faststream/_internal/fastapi/get_dependant.py | 6 +- faststream/_internal/fastapi/route.py | 8 +- faststream/_internal/fastapi/router.py | 31 ++- faststream/_internal/log/logging.py | 13 +- faststream/_internal/publisher/proto.py | 2 + faststream/_internal/publisher/usecase.py | 74 +++--- faststream/_internal/setup/fast_depends.py | 10 +- faststream/_internal/setup/logger.py | 15 +- faststream/_internal/setup/state.py | 2 +- faststream/_internal/subscriber/call_item.py | 20 +- .../_internal/subscriber/call_wrapper/call.py | 30 +-- .../subscriber/call_wrapper/proto.py | 8 +- faststream/_internal/subscriber/proto.py | 23 +- faststream/_internal/subscriber/usecase.py | 29 +-- faststream/_internal/subscriber/utils.py | 14 +- faststream/_internal/utils/functions.py | 21 +- faststream/app.py | 21 +- faststream/asgi/app.py | 11 +- faststream/confluent/broker/broker.py | 12 +- faststream/confluent/broker/logging.py | 4 +- faststream/confluent/broker/registrator.py | 18 +- faststream/confluent/parser.py | 40 ++-- faststream/confluent/publisher/producer.py | 2 +- faststream/confluent/router.py | 10 +- faststream/confluent/subscriber/factory.py | 10 +- faststream/confluent/subscriber/usecase.py | 31 +-- faststream/confluent/testing.py | 2 +- faststream/kafka/broker/broker.py | 12 +- faststream/kafka/broker/logging.py | 4 +- faststream/kafka/broker/registrator.py | 18 +- faststream/kafka/parser.py | 16 +- faststream/kafka/router.py | 10 +- faststream/kafka/subscriber/factory.py | 10 +- faststream/kafka/subscriber/usecase.py | 28 ++- faststream/middlewares/exception.py | 14 +- faststream/middlewares/logging.py | 5 +- faststream/nats/broker/broker.py | 135 +++++------ faststream/nats/broker/logging.py | 4 +- faststream/nats/broker/registrator.py | 8 +- faststream/nats/broker/state.py | 78 +++++++ faststream/nats/fastapi/__init__.py | 5 - faststream/nats/helpers/bucket_declarer.py | 17 +- .../nats/helpers/obj_storage_declarer.py | 17 +- faststream/nats/helpers/state.py | 27 +++ faststream/nats/publisher/producer.py | 41 +++- faststream/nats/router.py | 10 +- faststream/nats/schemas/js_stream.py | 1 + faststream/nats/subscriber/factory.py | 4 +- faststream/nats/subscriber/specified.py | 2 +- faststream/nats/subscriber/state.py | 60 +++++ faststream/nats/subscriber/usecase.py | 211 ++++++++---------- faststream/nats/testing.py | 3 +- faststream/prometheus/middleware.py | 2 +- faststream/rabbit/broker/broker.py | 12 +- faststream/rabbit/broker/logging.py | 4 +- faststream/rabbit/broker/registrator.py | 6 +- faststream/rabbit/publisher/usecase.py | 4 +- faststream/rabbit/router.py | 12 +- faststream/rabbit/subscriber/factory.py | 4 +- faststream/rabbit/subscriber/usecase.py | 9 +- faststream/redis/broker/broker.py | 20 +- faststream/redis/broker/logging.py | 4 +- faststream/redis/broker/registrator.py | 6 +- faststream/redis/router.py | 10 +- faststream/redis/subscriber/factory.py | 4 +- faststream/redis/subscriber/usecase.py | 61 +++-- faststream/specification/asyncapi/factory.py | 7 +- faststream/specification/asyncapi/message.py | 12 +- pyproject.toml | 2 +- .../cli/confluent/test_confluent_context.py | 4 +- .../cli/kafka/test_kafka_context.py | 4 +- .../cli/nats/test_nats_context.py | 4 +- .../cli/rabbit/test_rabbit_context.py | 4 +- .../cli/redis/test_redis_context.py | 4 +- .../getting_started/context/test_initial.py | 21 +- .../getting_started/lifespan/test_basic.py | 12 +- .../getting_started/lifespan/test_multi.py | 4 +- .../subscription/test_annotated.py | 2 +- tests/asgi/testcase.py | 11 +- tests/brokers/base/consume.py | 2 +- tests/brokers/base/fastapi.py | 19 +- tests/cli/conftest.py | 7 +- tests/cli/rabbit/test_app.py | 108 ++------- tests/cli/rabbit/test_logs.py | 9 +- tests/cli/test_asyncapi_docs.py | 6 +- tests/cli/test_run_asgi.py | 6 +- tests/cli/test_run_regular.py | 12 +- tests/conftest.py | 8 +- tests/utils/context/test_alias.py | 8 +- tests/utils/context/test_main.py | 32 +-- 148 files changed, 1226 insertions(+), 902 deletions(-) rename docs/docs/en/api/faststream/{broker/subscriber/mixins/TasksMixin.md => nats/broker/state/BrokerState.md} (68%) rename docs/docs/en/api/faststream/{broker/subscriber/mixins/ConcurrentMixin.md => nats/broker/state/ConnectedState.md} (66%) create mode 100644 docs/docs/en/api/faststream/nats/broker/state/ConnectionBrokenState.md create mode 100644 docs/docs/en/api/faststream/nats/broker/state/EmptyBrokerState.md create mode 100644 docs/docs/en/api/faststream/nats/helpers/state/ConnectedState.md create mode 100644 docs/docs/en/api/faststream/nats/helpers/state/ConnectionState.md create mode 100644 docs/docs/en/api/faststream/nats/helpers/state/EmptyConnectionState.md create mode 100644 docs/docs/en/api/faststream/nats/subscriber/state/ConnectedSubscriberState.md create mode 100644 docs/docs/en/api/faststream/nats/subscriber/state/EmptySubscriberState.md create mode 100644 docs/docs/en/api/faststream/nats/subscriber/state/SubscriberState.md create mode 100644 faststream/nats/broker/state.py create mode 100644 faststream/nats/helpers/state.py create mode 100644 faststream/nats/subscriber/state.py diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index 8f8c5f0cba..bf061a321a 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -513,6 +513,11 @@ search: - [NatsParamsStorage](api/faststream/nats/broker/logging/NatsParamsStorage.md) - registrator - [NatsRegistrator](api/faststream/nats/broker/registrator/NatsRegistrator.md) + - state + - [BrokerState](api/faststream/nats/broker/state/BrokerState.md) + - [ConnectedState](api/faststream/nats/broker/state/ConnectedState.md) + - [ConnectionBrokenState](api/faststream/nats/broker/state/ConnectionBrokenState.md) + - [EmptyBrokerState](api/faststream/nats/broker/state/EmptyBrokerState.md) - fastapi - [Context](api/faststream/nats/fastapi/Context.md) - [NatsRouter](api/faststream/nats/fastapi/NatsRouter.md) @@ -528,6 +533,10 @@ search: - [OSBucketDeclarer](api/faststream/nats/helpers/obj_storage_declarer/OSBucketDeclarer.md) - object_builder - [StreamBuilder](api/faststream/nats/helpers/object_builder/StreamBuilder.md) + - state + - [ConnectedState](api/faststream/nats/helpers/state/ConnectedState.md) + - [ConnectionState](api/faststream/nats/helpers/state/ConnectionState.md) + - [EmptyConnectionState](api/faststream/nats/helpers/state/EmptyConnectionState.md) - message - [NatsBatchMessage](api/faststream/nats/message/NatsBatchMessage.md) - [NatsKvMessage](api/faststream/nats/message/NatsKvMessage.md) @@ -612,6 +621,10 @@ search: - [SpecificationPullStreamSubscriber](api/faststream/nats/subscriber/specified/SpecificationPullStreamSubscriber.md) - [SpecificationStreamSubscriber](api/faststream/nats/subscriber/specified/SpecificationStreamSubscriber.md) - [SpecificationSubscriber](api/faststream/nats/subscriber/specified/SpecificationSubscriber.md) + - state + - [ConnectedSubscriberState](api/faststream/nats/subscriber/state/ConnectedSubscriberState.md) + - [EmptySubscriberState](api/faststream/nats/subscriber/state/EmptySubscriberState.md) + - [SubscriberState](api/faststream/nats/subscriber/state/SubscriberState.md) - usecase - [BatchPullStreamSubscriber](api/faststream/nats/subscriber/usecase/BatchPullStreamSubscriber.md) - [ConcurrentCoreSubscriber](api/faststream/nats/subscriber/usecase/ConcurrentCoreSubscriber.md) diff --git a/docs/docs/en/api/faststream/broker/subscriber/mixins/TasksMixin.md b/docs/docs/en/api/faststream/nats/broker/state/BrokerState.md similarity index 68% rename from docs/docs/en/api/faststream/broker/subscriber/mixins/TasksMixin.md rename to docs/docs/en/api/faststream/nats/broker/state/BrokerState.md index 6d483bef85..ed5dc00c35 100644 --- a/docs/docs/en/api/faststream/broker/subscriber/mixins/TasksMixin.md +++ b/docs/docs/en/api/faststream/nats/broker/state/BrokerState.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.broker.subscriber.mixins.TasksMixin +::: faststream.nats.broker.state.BrokerState diff --git a/docs/docs/en/api/faststream/broker/subscriber/mixins/ConcurrentMixin.md b/docs/docs/en/api/faststream/nats/broker/state/ConnectedState.md similarity index 66% rename from docs/docs/en/api/faststream/broker/subscriber/mixins/ConcurrentMixin.md rename to docs/docs/en/api/faststream/nats/broker/state/ConnectedState.md index 994f224aea..b7bb106798 100644 --- a/docs/docs/en/api/faststream/broker/subscriber/mixins/ConcurrentMixin.md +++ b/docs/docs/en/api/faststream/nats/broker/state/ConnectedState.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.broker.subscriber.mixins.ConcurrentMixin +::: faststream.nats.broker.state.ConnectedState diff --git a/docs/docs/en/api/faststream/nats/broker/state/ConnectionBrokenState.md b/docs/docs/en/api/faststream/nats/broker/state/ConnectionBrokenState.md new file mode 100644 index 0000000000..66df604330 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/broker/state/ConnectionBrokenState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.broker.state.ConnectionBrokenState diff --git a/docs/docs/en/api/faststream/nats/broker/state/EmptyBrokerState.md b/docs/docs/en/api/faststream/nats/broker/state/EmptyBrokerState.md new file mode 100644 index 0000000000..88bf83710d --- /dev/null +++ b/docs/docs/en/api/faststream/nats/broker/state/EmptyBrokerState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.broker.state.EmptyBrokerState diff --git a/docs/docs/en/api/faststream/nats/helpers/state/ConnectedState.md b/docs/docs/en/api/faststream/nats/helpers/state/ConnectedState.md new file mode 100644 index 0000000000..888302338b --- /dev/null +++ b/docs/docs/en/api/faststream/nats/helpers/state/ConnectedState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.helpers.state.ConnectedState diff --git a/docs/docs/en/api/faststream/nats/helpers/state/ConnectionState.md b/docs/docs/en/api/faststream/nats/helpers/state/ConnectionState.md new file mode 100644 index 0000000000..0d99fb56ed --- /dev/null +++ b/docs/docs/en/api/faststream/nats/helpers/state/ConnectionState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.helpers.state.ConnectionState diff --git a/docs/docs/en/api/faststream/nats/helpers/state/EmptyConnectionState.md b/docs/docs/en/api/faststream/nats/helpers/state/EmptyConnectionState.md new file mode 100644 index 0000000000..31a062d4ad --- /dev/null +++ b/docs/docs/en/api/faststream/nats/helpers/state/EmptyConnectionState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.helpers.state.EmptyConnectionState diff --git a/docs/docs/en/api/faststream/nats/subscriber/state/ConnectedSubscriberState.md b/docs/docs/en/api/faststream/nats/subscriber/state/ConnectedSubscriberState.md new file mode 100644 index 0000000000..3398403cb2 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/subscriber/state/ConnectedSubscriberState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.subscriber.state.ConnectedSubscriberState diff --git a/docs/docs/en/api/faststream/nats/subscriber/state/EmptySubscriberState.md b/docs/docs/en/api/faststream/nats/subscriber/state/EmptySubscriberState.md new file mode 100644 index 0000000000..de80057014 --- /dev/null +++ b/docs/docs/en/api/faststream/nats/subscriber/state/EmptySubscriberState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.subscriber.state.EmptySubscriberState diff --git a/docs/docs/en/api/faststream/nats/subscriber/state/SubscriberState.md b/docs/docs/en/api/faststream/nats/subscriber/state/SubscriberState.md new file mode 100644 index 0000000000..a61839436a --- /dev/null +++ b/docs/docs/en/api/faststream/nats/subscriber/state/SubscriberState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.nats.subscriber.state.SubscriberState diff --git a/docs/docs/en/getting-started/dependencies/index.md b/docs/docs/en/getting-started/dependencies/index.md index 8d88ab81d2..6f2fae0c54 100644 --- a/docs/docs/en/getting-started/dependencies/index.md +++ b/docs/docs/en/getting-started/dependencies/index.md @@ -21,7 +21,7 @@ By default, it applies to all event handlers, unless you disabled the same optio !!! warning Setting the `apply_types=False` flag not only disables type casting but also `Depends` and `Context`. - If you want to disable only type casting, use `validate=False` instead. + If you want to disable only type casting, use `serializer=None` instead. This flag can be useful if you are using **FastStream** within another framework and you need to use its native dependency system. diff --git a/docs/docs/en/getting-started/subscription/index.md b/docs/docs/en/getting-started/subscription/index.md index e1ae7ecef2..2604674830 100644 --- a/docs/docs/en/getting-started/subscription/index.md +++ b/docs/docs/en/getting-started/subscription/index.md @@ -41,7 +41,7 @@ This way **FastStream** still consumes `#!python json.loads` result, but without !!! warning Setting the `apply_types=False` flag not only disables type casting but also `Depends` and `Context`. - If you want to disable only type casting, use `validate=False` instead. + If you want to disable only type casting, use `serializer=None` instead. ## Multiple Subscriptions diff --git a/docs/docs_src/getting_started/asyncapi/asyncapi_customization/basic.py b/docs/docs_src/getting_started/asyncapi/asyncapi_customization/basic.py index 52c427af6c..1dc0c0b9e9 100644 --- a/docs/docs_src/getting_started/asyncapi/asyncapi_customization/basic.py +++ b/docs/docs_src/getting_started/asyncapi/asyncapi_customization/basic.py @@ -1,5 +1,5 @@ from faststream import FastStream -from faststream.kafka import KafkaBroker, KafkaMessage +from faststream.kafka import KafkaBroker broker = KafkaBroker("localhost:9092") app = FastStream(broker) diff --git a/docs/docs_src/getting_started/asyncapi/asyncapi_customization/custom_info.py b/docs/docs_src/getting_started/asyncapi/asyncapi_customization/custom_info.py index 8645296a34..4121bebe29 100644 --- a/docs/docs_src/getting_started/asyncapi/asyncapi_customization/custom_info.py +++ b/docs/docs_src/getting_started/asyncapi/asyncapi_customization/custom_info.py @@ -7,9 +7,7 @@ broker = KafkaBroker("localhost:9092") description="""# Title of the description This description supports **Markdown** syntax""" -app = FastStream( - broker, -) +app = FastStream(broker) docs_obj = AsyncAPI( broker, title="My App", diff --git a/docs/docs_src/getting_started/context/confluent/cast.py b/docs/docs_src/getting_started/context/confluent/cast.py index 3d0b14c343..77000f7b5b 100644 --- a/docs/docs_src/getting_started/context/confluent/cast.py +++ b/docs/docs_src/getting_started/context/confluent/cast.py @@ -1,9 +1,9 @@ -from faststream import Context, FastStream, context +from faststream import Context, FastStream from faststream.confluent import KafkaBroker broker = KafkaBroker("localhost:9092") app = FastStream(broker) -context.set_global("secret", "1") +app.context.set_global("secret", "1") @broker.subscriber("test-topic") async def handle( diff --git a/docs/docs_src/getting_started/context/confluent/custom_local_context.py b/docs/docs_src/getting_started/context/confluent/custom_local_context.py index e10da7f3fa..5c23081e2d 100644 --- a/docs/docs_src/getting_started/context/confluent/custom_local_context.py +++ b/docs/docs_src/getting_started/context/confluent/custom_local_context.py @@ -16,7 +16,7 @@ async def handle( call() -@apply_types +@apply_types(context__=app.context) def call( message: KafkaMessage, correlation_id=Context(), diff --git a/docs/docs_src/getting_started/context/confluent/manual_local_context.py b/docs/docs_src/getting_started/context/confluent/manual_local_context.py index c4264548d0..d419bda9a2 100644 --- a/docs/docs_src/getting_started/context/confluent/manual_local_context.py +++ b/docs/docs_src/getting_started/context/confluent/manual_local_context.py @@ -1,4 +1,4 @@ -from faststream import Context, FastStream, apply_types, context +from faststream import Context, FastStream, apply_types, ContextRepo from faststream.confluent import KafkaBroker from faststream.confluent.annotations import KafkaMessage @@ -10,16 +10,17 @@ async def handle( msg: str, message: KafkaMessage, + context: ContextRepo, ): tag = context.set_local("correlation_id", message.correlation_id) call(tag) -@apply_types +@apply_types(context__=app.context) def call( tag, message: KafkaMessage, correlation_id=Context(), ): assert correlation_id == message.correlation_id - context.reset_local("correlation_id", tag) + app.context.reset_local("correlation_id", tag) diff --git a/docs/docs_src/getting_started/context/kafka/cast.py b/docs/docs_src/getting_started/context/kafka/cast.py index 1ef06d3595..00db482531 100644 --- a/docs/docs_src/getting_started/context/kafka/cast.py +++ b/docs/docs_src/getting_started/context/kafka/cast.py @@ -1,9 +1,9 @@ -from faststream import Context, FastStream, context +from faststream import Context, FastStream from faststream.kafka import KafkaBroker broker = KafkaBroker("localhost:9092") app = FastStream(broker) -context.set_global("secret", "1") +app.context.set_global("secret", "1") @broker.subscriber("test-topic") async def handle( diff --git a/docs/docs_src/getting_started/context/kafka/custom_local_context.py b/docs/docs_src/getting_started/context/kafka/custom_local_context.py index e20a5a6567..e137319775 100644 --- a/docs/docs_src/getting_started/context/kafka/custom_local_context.py +++ b/docs/docs_src/getting_started/context/kafka/custom_local_context.py @@ -16,7 +16,7 @@ async def handle( call() -@apply_types +@apply_types(context__=app.context) def call( message: KafkaMessage, correlation_id=Context(), diff --git a/docs/docs_src/getting_started/context/kafka/manual_local_context.py b/docs/docs_src/getting_started/context/kafka/manual_local_context.py index 3e39cff046..4e69f6600a 100644 --- a/docs/docs_src/getting_started/context/kafka/manual_local_context.py +++ b/docs/docs_src/getting_started/context/kafka/manual_local_context.py @@ -1,4 +1,4 @@ -from faststream import Context, FastStream, apply_types, context +from faststream import Context, FastStream, apply_types, ContextRepo from faststream.kafka import KafkaBroker from faststream.kafka.annotations import KafkaMessage @@ -10,16 +10,17 @@ async def handle( msg: str, message: KafkaMessage, + context: ContextRepo, ): tag = context.set_local("correlation_id", message.correlation_id) call(tag) -@apply_types +@apply_types(context__=app.context) def call( tag, message: KafkaMessage, correlation_id=Context(), ): assert correlation_id == message.correlation_id - context.reset_local("correlation_id", tag) + app.context.reset_local("correlation_id", tag) diff --git a/docs/docs_src/getting_started/context/nats/cast.py b/docs/docs_src/getting_started/context/nats/cast.py index 0733561043..128cb19dd8 100644 --- a/docs/docs_src/getting_started/context/nats/cast.py +++ b/docs/docs_src/getting_started/context/nats/cast.py @@ -1,9 +1,9 @@ -from faststream import Context, FastStream, context +from faststream import Context, FastStream from faststream.nats import NatsBroker broker = NatsBroker("nats://localhost:4222") app = FastStream(broker) -context.set_global("secret", "1") +app.context.set_global("secret", "1") @broker.subscriber("test-subject") async def handle( diff --git a/docs/docs_src/getting_started/context/nats/custom_local_context.py b/docs/docs_src/getting_started/context/nats/custom_local_context.py index 510ec251e4..484bb9f5f8 100644 --- a/docs/docs_src/getting_started/context/nats/custom_local_context.py +++ b/docs/docs_src/getting_started/context/nats/custom_local_context.py @@ -16,7 +16,7 @@ async def handle( call() -@apply_types +@apply_types(context__=app.context) def call( message: NatsMessage, correlation_id=Context(), diff --git a/docs/docs_src/getting_started/context/nats/manual_local_context.py b/docs/docs_src/getting_started/context/nats/manual_local_context.py index 72a3519daf..fac68e4394 100644 --- a/docs/docs_src/getting_started/context/nats/manual_local_context.py +++ b/docs/docs_src/getting_started/context/nats/manual_local_context.py @@ -1,4 +1,4 @@ -from faststream import Context, FastStream, apply_types, context +from faststream import Context, FastStream, apply_types from faststream.nats import NatsBroker from faststream.nats.annotations import NatsMessage @@ -11,15 +11,15 @@ async def handle( msg: str, message: NatsMessage, ): - tag = context.set_local("correlation_id", message.correlation_id) + tag = app.context.set_local("correlation_id", message.correlation_id) call(tag) -@apply_types +@apply_types(context__=app.context) def call( tag, message: NatsMessage, correlation_id=Context(), ): assert correlation_id == message.correlation_id - context.reset_local("correlation_id", tag) + app.context.reset_local("correlation_id", tag) diff --git a/docs/docs_src/getting_started/context/nested.py b/docs/docs_src/getting_started/context/nested.py index 6eac7ca816..362112850d 100644 --- a/docs/docs_src/getting_started/context/nested.py +++ b/docs/docs_src/getting_started/context/nested.py @@ -11,6 +11,6 @@ async def handler(body): nested_func(body) -@apply_types +@apply_types(context__=broker.context) def nested_func(body, logger=Context()): logger.info(body) diff --git a/docs/docs_src/getting_started/context/rabbit/cast.py b/docs/docs_src/getting_started/context/rabbit/cast.py index 24cf1bf72e..47ce2b4525 100644 --- a/docs/docs_src/getting_started/context/rabbit/cast.py +++ b/docs/docs_src/getting_started/context/rabbit/cast.py @@ -1,9 +1,9 @@ -from faststream import Context, FastStream, context +from faststream import Context, FastStream from faststream.rabbit import RabbitBroker broker = RabbitBroker("amqp://guest:guest@localhost:5672/") app = FastStream(broker) -context.set_global("secret", "1") +app.context.set_global("secret", "1") @broker.subscriber("test-queue") async def handle( diff --git a/docs/docs_src/getting_started/context/rabbit/custom_local_context.py b/docs/docs_src/getting_started/context/rabbit/custom_local_context.py index 6ee9866967..9a3f922073 100644 --- a/docs/docs_src/getting_started/context/rabbit/custom_local_context.py +++ b/docs/docs_src/getting_started/context/rabbit/custom_local_context.py @@ -16,7 +16,7 @@ async def handle( call() -@apply_types +@apply_types(context__=app.context) def call( message: RabbitMessage, correlation_id=Context(), diff --git a/docs/docs_src/getting_started/context/rabbit/manual_local_context.py b/docs/docs_src/getting_started/context/rabbit/manual_local_context.py index 426abe88bb..c6859ff184 100644 --- a/docs/docs_src/getting_started/context/rabbit/manual_local_context.py +++ b/docs/docs_src/getting_started/context/rabbit/manual_local_context.py @@ -1,4 +1,4 @@ -from faststream import Context, FastStream, apply_types, context +from faststream import Context, FastStream, apply_types from faststream.rabbit import RabbitBroker from faststream.rabbit.annotations import RabbitMessage @@ -11,15 +11,15 @@ async def handle( msg: str, message: RabbitMessage, ): - tag = context.set_local("correlation_id", message.correlation_id) + tag = app.context.set_local("correlation_id", message.correlation_id) call(tag) -@apply_types +@apply_types(context__=app.context) def call( tag, message: RabbitMessage, correlation_id=Context(), ): assert correlation_id == message.correlation_id - context.reset_local("correlation_id", tag) + app.context.reset_local("correlation_id", tag) diff --git a/docs/docs_src/getting_started/context/redis/cast.py b/docs/docs_src/getting_started/context/redis/cast.py index fbd5eaeb3b..203daafb30 100644 --- a/docs/docs_src/getting_started/context/redis/cast.py +++ b/docs/docs_src/getting_started/context/redis/cast.py @@ -1,9 +1,9 @@ -from faststream import Context, FastStream, context +from faststream import Context, FastStream from faststream.redis import RedisBroker broker = RedisBroker("redis://localhost:6379") app = FastStream(broker) -context.set_global("secret", "1") +app.context.set_global("secret", "1") @broker.subscriber("test-channel") async def handle( diff --git a/docs/docs_src/getting_started/context/redis/custom_local_context.py b/docs/docs_src/getting_started/context/redis/custom_local_context.py index 4feb1eb438..9e06b3ea93 100644 --- a/docs/docs_src/getting_started/context/redis/custom_local_context.py +++ b/docs/docs_src/getting_started/context/redis/custom_local_context.py @@ -16,7 +16,7 @@ async def handle( call() -@apply_types +@apply_types(context__=app.context) def call( message: RedisMessage, correlation_id=Context(), diff --git a/docs/docs_src/getting_started/context/redis/manual_local_context.py b/docs/docs_src/getting_started/context/redis/manual_local_context.py index f52af02782..74a5ced413 100644 --- a/docs/docs_src/getting_started/context/redis/manual_local_context.py +++ b/docs/docs_src/getting_started/context/redis/manual_local_context.py @@ -1,4 +1,4 @@ -from faststream import Context, FastStream, apply_types, context +from faststream import Context, FastStream, apply_types from faststream.redis import RedisBroker from faststream.redis.annotations import RedisMessage @@ -11,15 +11,15 @@ async def handle( msg: str, message: RedisMessage, ): - tag = context.set_local("correlation_id", message.correlation_id) + tag = app.context.set_local("correlation_id", message.correlation_id) call(tag) -@apply_types +@apply_types(context__=app.context) def call( tag, message: RedisMessage, correlation_id=Context(), ): assert correlation_id == message.correlation_id - context.reset_local("correlation_id", tag) + app.context.reset_local("correlation_id", tag) diff --git a/docs/docs_src/getting_started/lifespan/multiple.py b/docs/docs_src/getting_started/lifespan/multiple.py index f0280d4da4..d1d6fd75f6 100644 --- a/docs/docs_src/getting_started/lifespan/multiple.py +++ b/docs/docs_src/getting_started/lifespan/multiple.py @@ -1,6 +1,8 @@ +from unittest.mock import AsyncMock + from faststream import Context, ContextRepo, FastStream -app = FastStream() +app = FastStream(AsyncMock()) @app.on_startup diff --git a/docs/docs_src/getting_started/subscription/confluent/real_testing.py b/docs/docs_src/getting_started/subscription/confluent/real_testing.py index 43973935b9..fcbd09f7e4 100644 --- a/docs/docs_src/getting_started/subscription/confluent/real_testing.py +++ b/docs/docs_src/getting_started/subscription/confluent/real_testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.confluent import TestKafkaBroker diff --git a/docs/docs_src/getting_started/subscription/confluent/testing.py b/docs/docs_src/getting_started/subscription/confluent/testing.py index 57ed6acaaa..dfb2bf964d 100644 --- a/docs/docs_src/getting_started/subscription/confluent/testing.py +++ b/docs/docs_src/getting_started/subscription/confluent/testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.confluent import TestKafkaBroker diff --git a/docs/docs_src/getting_started/subscription/kafka/real_testing.py b/docs/docs_src/getting_started/subscription/kafka/real_testing.py index 0cf374b233..5eb6fd7817 100644 --- a/docs/docs_src/getting_started/subscription/kafka/real_testing.py +++ b/docs/docs_src/getting_started/subscription/kafka/real_testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.kafka import TestKafkaBroker diff --git a/docs/docs_src/getting_started/subscription/kafka/testing.py b/docs/docs_src/getting_started/subscription/kafka/testing.py index e1f6241276..cf834ff802 100644 --- a/docs/docs_src/getting_started/subscription/kafka/testing.py +++ b/docs/docs_src/getting_started/subscription/kafka/testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.kafka import TestKafkaBroker diff --git a/docs/docs_src/getting_started/subscription/nats/real_testing.py b/docs/docs_src/getting_started/subscription/nats/real_testing.py index 5e9d6e4567..c14123218c 100644 --- a/docs/docs_src/getting_started/subscription/nats/real_testing.py +++ b/docs/docs_src/getting_started/subscription/nats/real_testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.nats import TestNatsBroker diff --git a/docs/docs_src/getting_started/subscription/nats/testing.py b/docs/docs_src/getting_started/subscription/nats/testing.py index 0f7560e043..4d66a744c0 100644 --- a/docs/docs_src/getting_started/subscription/nats/testing.py +++ b/docs/docs_src/getting_started/subscription/nats/testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.nats import TestNatsBroker diff --git a/docs/docs_src/getting_started/subscription/rabbit/real_testing.py b/docs/docs_src/getting_started/subscription/rabbit/real_testing.py index 900b6046e7..7cf61a2df5 100644 --- a/docs/docs_src/getting_started/subscription/rabbit/real_testing.py +++ b/docs/docs_src/getting_started/subscription/rabbit/real_testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.rabbit import TestRabbitBroker diff --git a/docs/docs_src/getting_started/subscription/rabbit/testing.py b/docs/docs_src/getting_started/subscription/rabbit/testing.py index 78425924da..f49be05c7a 100644 --- a/docs/docs_src/getting_started/subscription/rabbit/testing.py +++ b/docs/docs_src/getting_started/subscription/rabbit/testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.rabbit import TestRabbitBroker diff --git a/docs/docs_src/getting_started/subscription/redis/real_testing.py b/docs/docs_src/getting_started/subscription/redis/real_testing.py index b2c05c203e..6514d66902 100644 --- a/docs/docs_src/getting_started/subscription/redis/real_testing.py +++ b/docs/docs_src/getting_started/subscription/redis/real_testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.redis import TestRedisBroker diff --git a/docs/docs_src/getting_started/subscription/redis/testing.py b/docs/docs_src/getting_started/subscription/redis/testing.py index 4934366f75..bb38ffd5fe 100644 --- a/docs/docs_src/getting_started/subscription/redis/testing.py +++ b/docs/docs_src/getting_started/subscription/redis/testing.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream.redis import TestRedisBroker diff --git a/docs/docs_src/index/confluent/test.py b/docs/docs_src/index/confluent/test.py index 1cc613d157..b569184a81 100644 --- a/docs/docs_src/index/confluent/test.py +++ b/docs/docs_src/index/confluent/test.py @@ -1,7 +1,7 @@ from .pydantic import broker import pytest -import pydantic +from fast_depends.exceptions import ValidationError from faststream.confluent import TestKafkaBroker @@ -16,5 +16,5 @@ async def test_correct(): @pytest.mark.asyncio async def test_invalid(): async with TestKafkaBroker(broker) as br: - with pytest.raises(pydantic.ValidationError): + with pytest.raises(ValidationError): await br.publish("wrong message", "in-topic") diff --git a/docs/docs_src/index/kafka/test.py b/docs/docs_src/index/kafka/test.py index bfd740312c..67b57e6f12 100644 --- a/docs/docs_src/index/kafka/test.py +++ b/docs/docs_src/index/kafka/test.py @@ -1,7 +1,7 @@ from .pydantic import broker import pytest -import pydantic +from fast_depends.exceptions import ValidationError from faststream.kafka import TestKafkaBroker @@ -16,5 +16,5 @@ async def test_correct(): @pytest.mark.asyncio async def test_invalid(): async with TestKafkaBroker(broker) as br: - with pytest.raises(pydantic.ValidationError): + with pytest.raises(ValidationError): await br.publish("wrong message", "in-topic") diff --git a/docs/docs_src/index/nats/test.py b/docs/docs_src/index/nats/test.py index 85b2e6de76..ca2e71e7b9 100644 --- a/docs/docs_src/index/nats/test.py +++ b/docs/docs_src/index/nats/test.py @@ -1,7 +1,7 @@ from .pydantic import broker import pytest -import pydantic +from fast_depends.exceptions import ValidationError from faststream.nats import TestNatsBroker @@ -16,5 +16,5 @@ async def test_correct(): @pytest.mark.asyncio async def test_invalid(): async with TestNatsBroker(broker) as br: - with pytest.raises(pydantic.ValidationError): + with pytest.raises(ValidationError): await br.publish("wrong message", "in-subject") diff --git a/docs/docs_src/index/rabbit/test.py b/docs/docs_src/index/rabbit/test.py index a193db35b2..7b67df49dc 100644 --- a/docs/docs_src/index/rabbit/test.py +++ b/docs/docs_src/index/rabbit/test.py @@ -1,7 +1,7 @@ from .pydantic import broker import pytest -import pydantic +from fast_depends.exceptions import ValidationError from faststream.rabbit import TestRabbitBroker @@ -16,5 +16,5 @@ async def test_correct(): @pytest.mark.asyncio async def test_invalid(): async with TestRabbitBroker(broker) as br: - with pytest.raises(pydantic.ValidationError): + with pytest.raises(ValidationError): await br.publish("wrong message", "in-queue") diff --git a/docs/docs_src/index/redis/test.py b/docs/docs_src/index/redis/test.py index 9a14ba4190..411e032edb 100644 --- a/docs/docs_src/index/redis/test.py +++ b/docs/docs_src/index/redis/test.py @@ -1,7 +1,7 @@ from .pydantic import broker import pytest -import pydantic +from fast_depends.exceptions import ValidationError from faststream.redis import TestRedisBroker @@ -16,5 +16,5 @@ async def test_correct(): @pytest.mark.asyncio async def test_invalid(): async with TestRedisBroker(broker) as br: - with pytest.raises(pydantic.ValidationError): + with pytest.raises(ValidationError): await br.publish("wrong message", "in-channel") diff --git a/faststream/__init__.py b/faststream/__init__.py index b4241ff458..09514567a8 100644 --- a/faststream/__init__.py +++ b/faststream/__init__.py @@ -1,6 +1,5 @@ """A Python framework for building services interacting with Apache Kafka, RabbitMQ, NATS and Redis.""" -from faststream._internal.context import context from faststream._internal.testing.app import TestApp from faststream._internal.utils import apply_types from faststream.annotations import ContextRepo, Logger @@ -35,6 +34,4 @@ "TestApp", # utils "apply_types", - # context - "context", ) diff --git a/faststream/_internal/_compat.py b/faststream/_internal/_compat.py index c63389ed39..90a63aafab 100644 --- a/faststream/_internal/_compat.py +++ b/faststream/_internal/_compat.py @@ -13,14 +13,13 @@ Union, ) -from fast_depends._compat import ( # type: ignore[attr-defined] - PYDANTIC_V2, - PYDANTIC_VERSION, -) from pydantic import BaseModel +from pydantic.version import VERSION as PYDANTIC_VERSION from faststream._internal.basic_types import AnyDict +PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.") + IS_WINDOWS = ( sys.platform == "win32" or sys.platform == "cygwin" or sys.platform == "msys" ) diff --git a/faststream/_internal/application.py b/faststream/_internal/application.py index 15c389b6e8..056b11930c 100644 --- a/faststream/_internal/application.py +++ b/faststream/_internal/application.py @@ -10,11 +10,13 @@ TypeVar, ) +from fast_depends import Provider from typing_extensions import ParamSpec -from faststream._internal.context import context +from faststream._internal.constants import EMPTY +from faststream._internal.context import ContextRepo from faststream._internal.log import logger -from faststream._internal.setup.state import EmptyState +from faststream._internal.setup.state import FastDependsData from faststream._internal.utils import apply_types from faststream._internal.utils.functions import ( drop_response_type, @@ -23,6 +25,8 @@ ) if TYPE_CHECKING: + from fast_depends.library.serializer import SerializerProto + from faststream._internal.basic_types import ( AnyCallable, AsyncFunc, @@ -68,59 +72,94 @@ async def catch_startup_validation_error() -> AsyncIterator[None]: class StartAbleApplication: def __init__( self, - broker: Optional["BrokerUsecase[Any, Any]"] = None, + broker: "BrokerUsecase[Any, Any]", + /, + provider: Optional["Provider"] = None, + serializer: Optional["SerializerProto"] = EMPTY, + ) -> None: + self._init_setupable_( + broker, + provider=provider, + serializer=serializer, + ) + + def _init_setupable_( # noqa: PLW3201 + self, + broker: "BrokerUsecase[Any, Any]", + /, + provider: Optional["Provider"] = None, + serializer: Optional["SerializerProto"] = EMPTY, ) -> None: - self._state = EmptyState() + self.context = ContextRepo() + self.provider = provider or Provider() + + if serializer is EMPTY: + from fast_depends.pydantic.serializer import PydanticSerializer + + serializer = PydanticSerializer() + + self._state = FastDependsData( + use_fastdepends=True, + get_dependent=None, + call_decorators=(), + serializer=serializer, + provider=self.provider, + context=self.context, + ) self.broker = broker + self._setup() + def _setup(self) -> None: - if self.broker is not None: - self.broker._setup(self._state) + self.broker._setup(di_state=self._state) async def _start_broker(self) -> None: - if self.broker is not None: - await self.broker.connect() - self._setup() - await self.broker.start() + await self.broker.start() class Application(StartAbleApplication): def __init__( self, - *, - broker: Optional["BrokerUsecase[Any, Any]"] = None, + broker: "BrokerUsecase[Any, Any]", + /, logger: Optional["LoggerProto"] = logger, + provider: Optional["Provider"] = None, + serializer: Optional["SerializerProto"] = EMPTY, lifespan: Optional["Lifespan"] = None, on_startup: Sequence["AnyCallable"] = (), after_startup: Sequence["AnyCallable"] = (), on_shutdown: Sequence["AnyCallable"] = (), after_shutdown: Sequence["AnyCallable"] = (), ) -> None: - super().__init__(broker) + super().__init__( + broker, + provider=provider, + serializer=serializer, + ) - context.set_global("app", self) + self.context.set_global("app", self) self.logger = logger - self.context = context self._on_startup_calling: list[AsyncFunc] = [ - apply_types(to_async(x)) for x in on_startup + apply_types(to_async(x), context__=self.context) for x in on_startup ] self._after_startup_calling: list[AsyncFunc] = [ - apply_types(to_async(x)) for x in after_startup + apply_types(to_async(x), context__=self.context) for x in after_startup ] self._on_shutdown_calling: list[AsyncFunc] = [ - apply_types(to_async(x)) for x in on_shutdown + apply_types(to_async(x), context__=self.context) for x in on_shutdown ] self._after_shutdown_calling: list[AsyncFunc] = [ - apply_types(to_async(x)) for x in after_shutdown + apply_types(to_async(x), context__=self.context) for x in after_shutdown ] if lifespan is not None: self.lifespan_context = apply_types( func=lifespan, wrap_model=drop_response_type, + context__=self.context, ) else: self.lifespan_context = fake_context @@ -198,8 +237,7 @@ async def _shutdown(self, log_level: int = logging.INFO) -> None: async def stop(self) -> None: """Executes shutdown hooks and stop broker.""" async with self._shutdown_hooks_context(): - if self.broker is not None: - await self.broker.close() + await self.broker.close() @asynccontextmanager async def _shutdown_hooks_context(self) -> AsyncIterator[None]: @@ -235,13 +273,6 @@ def _log(self, level: int, message: str) -> None: if self.logger is not None: self.logger.log(level, message) - def set_broker(self, broker: "BrokerUsecase[Any, Any]") -> None: - """Set already existed App object broker. - - Useful then you create/init broker in `on_startup` hook. - """ - self.broker = broker - # Hooks def on_startup( @@ -252,7 +283,9 @@ def on_startup( This hook also takes an extra CLI options as a kwargs. """ - self._on_startup_calling.append(apply_types(to_async(func))) + self._on_startup_calling.append( + apply_types(to_async(func), context__=self.context) + ) return func def on_shutdown( @@ -260,7 +293,9 @@ def on_shutdown( func: Callable[P_HookParams, T_HookReturn], ) -> Callable[P_HookParams, T_HookReturn]: """Add hook running BEFORE broker disconnected.""" - self._on_shutdown_calling.append(apply_types(to_async(func))) + self._on_shutdown_calling.append( + apply_types(to_async(func), context__=self.context) + ) return func def after_startup( @@ -268,7 +303,9 @@ def after_startup( func: Callable[P_HookParams, T_HookReturn], ) -> Callable[P_HookParams, T_HookReturn]: """Add hook running AFTER broker connected.""" - self._after_startup_calling.append(apply_types(to_async(func))) + self._after_startup_calling.append( + apply_types(to_async(func), context__=self.context) + ) return func def after_shutdown( @@ -276,5 +313,7 @@ def after_shutdown( func: Callable[P_HookParams, T_HookReturn], ) -> Callable[P_HookParams, T_HookReturn]: """Add hook running AFTER broker disconnected.""" - self._after_shutdown_calling.append(apply_types(to_async(func))) + self._after_shutdown_calling.append( + apply_types(to_async(func), context__=self.context) + ) return func diff --git a/faststream/_internal/broker/abc_broker.py b/faststream/_internal/broker/abc_broker.py index 20aae90d1a..09be9f317f 100644 --- a/faststream/_internal/broker/abc_broker.py +++ b/faststream/_internal/broker/abc_broker.py @@ -10,7 +10,7 @@ from faststream._internal.types import BrokerMiddleware, CustomCallable, MsgType if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.publisher.proto import PublisherProto from faststream._internal.subscriber.proto import SubscriberProto @@ -24,7 +24,7 @@ def __init__( self, *, prefix: str, - dependencies: Iterable["Depends"], + dependencies: Iterable["Dependant"], middlewares: Iterable["BrokerMiddleware[MsgType]"], parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], @@ -77,7 +77,7 @@ def include_router( router: "ABCBroker[Any]", *, prefix: str = "", - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), middlewares: Iterable["BrokerMiddleware[MsgType]"] = (), include_in_schema: Optional[bool] = None, ) -> None: diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index 03d7887cba..162ea5fbc6 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -12,10 +12,13 @@ cast, ) +from fast_depends import Provider +from fast_depends.pydantic import PydanticSerializer from typing_extensions import Doc, Self from faststream._internal._compat import is_test_env -from faststream._internal.context.repository import context +from faststream._internal.constants import EMPTY +from faststream._internal.context.repository import ContextRepo from faststream._internal.setup import ( EmptyState, FastDependsData, @@ -43,7 +46,8 @@ if TYPE_CHECKING: from types import TracebackType - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant + from fast_depends.library.serializer import SerializerProto from faststream._internal.basic_types import AnyDict, Decorator from faststream._internal.publisher.proto import ( @@ -79,7 +83,7 @@ def __init__( Doc("Custom parser object."), ], dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc("Dependencies to apply to all broker subscribers."), ], middlewares: Annotated[ @@ -99,10 +103,7 @@ def __init__( bool, Doc("Whether to use FastDepends or not."), ], - validate: Annotated[ - bool, - Doc("Whether to cast types using Pydantic validation."), - ], + serializer: Optional["SerializerProto"] = EMPTY, _get_dependant: Annotated[ Optional[Callable[..., Any]], Doc("Custom library dependant generator callback."), @@ -172,10 +173,12 @@ def __init__( self._state = EmptyState( depends_params=FastDependsData( - apply_types=apply_types, - is_validate=validate, + use_fastdepends=apply_types, get_dependent=_get_dependant, call_decorators=_call_decorators, + serializer=PydanticSerializer() if serializer is EMPTY else serializer, + provider=Provider(), + context=ContextRepo(), ), logger_state=logger_state, ) @@ -188,6 +191,14 @@ def __init__( self.tags = tags self.security = security + @property + def context(self) -> ContextRepo: + return self._state.depends_params.context + + @property + def provider(self) -> Provider: + return self._state.depends_params.provider + async def __aenter__(self) -> "Self": await self.connect() return self @@ -225,20 +236,29 @@ async def _connect(self) -> ConnectionType: """Connect to a resource.""" raise NotImplementedError - def _setup(self, state: Optional[BaseState] = None) -> None: + def _setup(self, di_state: Optional[FastDependsData] = None) -> None: """Prepare all Broker entities to startup.""" if not self._state: - # Fallback to default state if there no - # parent container like FastStream object - default_state = self._state.copy_to_state(SetupState) - - if state: - self._state = state.copy_with_params( - depends_params=default_state.depends_params, - logger_state=default_state.logger_state, + if di_state is not None: + new_state = SetupState( + logger_state=self._state.logger_state, + depends_params=FastDependsData( + use_fastdepends=self._state.depends_params.use_fastdepends, + call_decorators=self._state.depends_params.call_decorators, + get_dependent=self._state.depends_params.get_dependent, + # from parent + serializer=di_state.serializer, + provider=di_state.provider, + context=di_state.context, + ), ) + else: - self._state = default_state + # Fallback to default state if there no + # parent container like FastStream object + new_state = self._state.copy_to_state(SetupState) + + self._state = new_state if not self.running: self.running = True @@ -266,7 +286,7 @@ def setup_subscriber( """Setup the Subscriber to prepare it to starting.""" data = self._subscriber_setup_extra.copy() data.update(kwargs) - subscriber._setup(**data) + subscriber._setup(**data, state=self._state) def setup_publisher( self, @@ -276,7 +296,7 @@ def setup_publisher( """Setup the Publisher to prepare it to starting.""" data = self._publisher_setup_extra.copy() data.update(kwargs) - publisher._setup(**data) + publisher._setup(**data, state=self._state) @property def _subscriber_setup_extra(self) -> "AnyDict": @@ -291,8 +311,6 @@ def _subscriber_setup_extra(self) -> "AnyDict": # broker options "broker_parser": self._parser, "broker_decoder": self._decoder, - # dependant args - "state": self._state, } @property @@ -331,7 +349,7 @@ async def _basic_publish( publish = producer.publish for m in self._middlewares: - publish = partial(m(None, context=context).publish_scope, publish) + publish = partial(m(None, context=self.context).publish_scope, publish) return await publish(cmd) @@ -347,7 +365,7 @@ async def _basic_publish_batch( publish = producer.publish_batch for m in self._middlewares: - publish = partial(m(None, context=context).publish_scope, publish) + publish = partial(m(None, context=self.context).publish_scope, publish) await publish(cmd) @@ -362,13 +380,15 @@ async def _basic_request( request = producer.request for m in self._middlewares: - request = partial(m(None, context=context).publish_scope, request) + request = partial(m(None, context=self.context).publish_scope, request) published_msg = await request(cmd) response_msg: Any = await process_msg( msg=published_msg, - middlewares=self._middlewares, + middlewares=( + m(published_msg, context=self.context) for m in self._middlewares + ), parser=producer._parser, decoder=producer._decoder, source_type=SourceType.Response, diff --git a/faststream/_internal/broker/router.py b/faststream/_internal/broker/router.py index 35d2b2779a..a6a49fae3a 100644 --- a/faststream/_internal/broker/router.py +++ b/faststream/_internal/broker/router.py @@ -15,7 +15,7 @@ from .abc_broker import ABCBroker if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict @@ -64,7 +64,7 @@ def __init__( handlers: Iterable[SubscriberRoute], # base options prefix: str, - dependencies: Iterable["Depends"], + dependencies: Iterable["Dependant"], middlewares: Iterable["BrokerMiddleware[MsgType]"], parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], diff --git a/faststream/_internal/context/__init__.py b/faststream/_internal/context/__init__.py index 89152e682f..f0a0b1d1cb 100644 --- a/faststream/_internal/context/__init__.py +++ b/faststream/_internal/context/__init__.py @@ -1,8 +1,7 @@ from .context_type import Context -from .repository import ContextRepo, context +from .repository import ContextRepo __all__ = ( "Context", "ContextRepo", - "context", ) diff --git a/faststream/_internal/context/context_type.py b/faststream/_internal/context/context_type.py index c2eb815b6e..b50e859066 100644 --- a/faststream/_internal/context/context_type.py +++ b/faststream/_internal/context/context_type.py @@ -67,6 +67,7 @@ def use(self, /, **kwargs: Any) -> AnyDict: name=name, default=self.default, initial=self.initial, + context=kwargs["context__"], ) ): kwargs[self.param_name] = v diff --git a/faststream/_internal/context/repository.py b/faststream/_internal/context/repository.py index 9990ad9bd0..eab1763088 100644 --- a/faststream/_internal/context/repository.py +++ b/faststream/_internal/context/repository.py @@ -7,8 +7,6 @@ from faststream._internal.constants import EMPTY from faststream.exceptions import ContextError -__all__ = ("ContextRepo", "context") - class ContextRepo: """A class to represent a context repository.""" @@ -171,6 +169,3 @@ def resolve(self, argument: str) -> Any: def clear(self) -> None: self._global_context = {"context": self} self._scope_context.clear() - - -context = ContextRepo() diff --git a/faststream/_internal/context/resolve.py b/faststream/_internal/context/resolve.py index bca91de33a..854229175e 100644 --- a/faststream/_internal/context/resolve.py +++ b/faststream/_internal/context/resolve.py @@ -1,14 +1,16 @@ -from typing import Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable, Optional from faststream._internal.constants import EMPTY -from .repository import context +if TYPE_CHECKING: + from .repository import ContextRepo def resolve_context_by_name( name: str, default: Any, initial: Optional[Callable[..., Any]], + context: "ContextRepo", ) -> Any: value: Any = EMPTY diff --git a/faststream/_internal/fastapi/context.py b/faststream/_internal/fastapi/context.py index 0a76e31a19..78d8dd26a7 100644 --- a/faststream/_internal/fastapi/context.py +++ b/faststream/_internal/fastapi/context.py @@ -15,14 +15,18 @@ def Context( # noqa: N802 initial: Optional[Callable[..., Any]] = None, ) -> Any: """Get access to objects of the Context.""" - return params.Depends( - lambda: resolve_context_by_name( + + def solve_context( + context: Annotated[Any, params.Header(alias="context__")], + ) -> Any: + return resolve_context_by_name( name=name, default=default, initial=initial, - ), - use_cache=True, - ) + context=context, + ) + + return params.Depends(solve_context, use_cache=True) Logger = Annotated[logging.Logger, Context("logger")] diff --git a/faststream/_internal/fastapi/get_dependant.py b/faststream/_internal/fastapi/get_dependant.py index e21a62954a..2db1b140d9 100644 --- a/faststream/_internal/fastapi/get_dependant.py +++ b/faststream/_internal/fastapi/get_dependant.py @@ -1,6 +1,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, Any, Callable, cast +from fast_depends.library.serializer import OptionItem from fastapi.dependencies.utils import get_dependant, get_parameterless_sub_dependant from faststream._internal._compat import PYDANTIC_V2 @@ -120,6 +121,9 @@ def _patch_fastapi_dependent(dependant: "Dependant") -> "Dependant": ) dependant.custom_fields = {} # type: ignore[attr-defined] - dependant.flat_params = params_unique # type: ignore[attr-defined] + dependant.flat_params = [ + OptionItem(field_name=name, field_type=type_, default_value=default) + for name, (type_, default) in params_unique.items() + ] # type: ignore[attr-defined] return dependant diff --git a/faststream/_internal/fastapi/route.py b/faststream/_internal/fastapi/route.py index dffe9517aa..f45b64194e 100644 --- a/faststream/_internal/fastapi/route.py +++ b/faststream/_internal/fastapi/route.py @@ -35,6 +35,7 @@ from fastapi.types import IncEx from faststream._internal.basic_types import AnyDict + from faststream._internal.setup import FastDependsData from faststream.message import StreamMessage as NativeMessage @@ -75,6 +76,7 @@ def wrap_callable_to_fastapi_compatible( response_model_exclude_unset: bool, response_model_exclude_defaults: bool, response_model_exclude_none: bool, + state: "FastDependsData", ) -> Callable[["NativeMessage[Any]"], Awaitable[Any]]: __magic_attr = "__faststream_consumer__" @@ -100,6 +102,7 @@ def wrap_callable_to_fastapi_compatible( response_model_exclude_unset=response_model_exclude_unset, response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, + state=state, ) setattr(parsed_callable, __magic_attr, True) @@ -117,6 +120,7 @@ def build_faststream_to_fastapi_parser( response_model_exclude_unset: bool, response_model_exclude_defaults: bool, response_model_exclude_none: bool, + state: "FastDependsData", ) -> Callable[["NativeMessage[Any]"], Awaitable[Any]]: """Creates a session for handling requests.""" assert dependent.call # nosec B101 @@ -158,14 +162,14 @@ async def parsed_consumer(message: "NativeMessage[Any]") -> Any: stream_message = StreamMessage( body=fastapi_body, - headers=message.headers, + headers={"context__": state.context, **message.headers}, path={**path, **message.path}, ) else: stream_message = StreamMessage( body={}, - headers={}, + headers={"context__": state.context}, path={}, ) diff --git a/faststream/_internal/fastapi/router.py b/faststream/_internal/fastapi/router.py index 68c32f7aec..910c8a0387 100644 --- a/faststream/_internal/fastapi/router.py +++ b/faststream/_internal/fastapi/router.py @@ -26,12 +26,10 @@ from faststream._internal.application import StartAbleApplication from faststream._internal.broker.router import BrokerRouter -from faststream._internal.context.repository import context from faststream._internal.fastapi.get_dependant import get_fastapi_dependant from faststream._internal.fastapi.route import ( wrap_callable_to_fastapi_compatible, ) -from faststream._internal.setup import EmptyState from faststream._internal.types import ( MsgType, P_HandlerParams, @@ -70,7 +68,7 @@ async def __aexit__( if not exc_type and ( background := cast( Optional[BackgroundTasks], - getattr(context.get_local("message"), "background", None), + getattr(self.context.get_local("message"), "background", None), ) ): await background() @@ -131,7 +129,7 @@ def __init__( self.broker_class ), "You should specify `broker_class` at your implementation" - self.broker = self.broker_class( + broker = self.broker_class( *connection_args, middlewares=( *middlewares, @@ -144,6 +142,11 @@ def __init__( **connection_kwars, ) + self._init_setupable_( + broker, + provider=None, + ) + self.setup_state = setup_state # Specification information @@ -160,10 +163,6 @@ def __init__( self.contact = None self.schema = None - # Flag to prevent double lifespan start - self._lifespan_started = False - - self._state = EmptyState() super().__init__( prefix=prefix, @@ -197,6 +196,8 @@ def __init__( self._after_startup_hooks = [] self._on_shutdown_hooks = [] + self._lifespan_started = False + def _get_dependencies_overides_provider(self) -> Optional[Any]: """Dependency provider WeakRef resolver.""" if self.dependency_overrides_provider is not None: @@ -234,6 +235,7 @@ def wrapper( response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, provider_factory=self._get_dependencies_overides_provider, + state=self._state, ) return wrapper @@ -317,12 +319,7 @@ async def start_broker_lifespan( self.weak_dependencies_provider.add(app) async with lifespan_context(app) as maybe_context: - if maybe_context is None: - context: AnyDict = {} - else: - context = dict(maybe_context) - - context.update({"broker": self.broker}) + lifespan_extra = {"broker": self.broker, **(maybe_context or {})} if not self._lifespan_started: await self._start_broker() @@ -334,13 +331,11 @@ async def start_broker_lifespan( ) for h in self._after_startup_hooks: - h_context = await h(app) - if h_context: # pragma: no branch - context.update(h_context) + lifespan_extra.update(await h(app) or {}) try: if self.setup_state: - yield context + yield lifespan_extra else: # NOTE: old asgi compatibility yield None diff --git a/faststream/_internal/log/logging.py b/faststream/_internal/log/logging.py index 4156b24270..7ffc83ded3 100644 --- a/faststream/_internal/log/logging.py +++ b/faststream/_internal/log/logging.py @@ -2,10 +2,14 @@ import sys from collections.abc import Mapping from logging import LogRecord +from typing import TYPE_CHECKING -from faststream._internal.context.repository import context from faststream._internal.log.formatter import ColourizedFormatter +if TYPE_CHECKING: + from faststream._internal.context.repository import ContextRepo + + logger = logging.getLogger("faststream") logger.setLevel(logging.INFO) logger.propagate = False @@ -24,15 +28,17 @@ def __init__( self, default_context: Mapping[str, str], message_id_ln: int, + context: "ContextRepo", name: str = "", ) -> None: self.default_context = default_context self.message_id_ln = message_id_ln + self.context = context super().__init__(name) def filter(self, record: LogRecord) -> bool: if is_suitable := super().filter(record): - log_context: Mapping[str, str] = context.get_local( + log_context: Mapping[str, str] = self.context.get_local( "log_context", self.default_context, ) @@ -51,11 +57,12 @@ def get_broker_logger( default_context: Mapping[str, str], message_id_ln: int, fmt: str, + context: "ContextRepo", ) -> logging.Logger: logger = logging.getLogger(f"faststream.access.{name}") logger.setLevel(logging.INFO) logger.propagate = False - logger.addFilter(ExtendedFilter(default_context, message_id_ln)) + logger.addFilter(ExtendedFilter(default_context, message_id_ln, context=context)) handler = logging.StreamHandler(stream=sys.stdout) handler.setFormatter( ColourizedFormatter( diff --git a/faststream/_internal/publisher/proto.py b/faststream/_internal/publisher/proto.py index 4037e1d285..fef4cb1b68 100644 --- a/faststream/_internal/publisher/proto.py +++ b/faststream/_internal/publisher/proto.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage + from faststream._internal.setup import SetupState from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -102,6 +103,7 @@ def _setup( # type: ignore[override] self, *, producer: Optional["ProducerProto"], + state: "SetupState", ) -> None: ... @abstractmethod diff --git a/faststream/_internal/publisher/usecase.py b/faststream/_internal/publisher/usecase.py index fbd735a783..049ed1e5a5 100644 --- a/faststream/_internal/publisher/usecase.py +++ b/faststream/_internal/publisher/usecase.py @@ -1,6 +1,6 @@ from collections.abc import Awaitable, Iterable from functools import partial -from inspect import unwrap +from inspect import Parameter, unwrap from itertools import chain from typing import ( TYPE_CHECKING, @@ -11,11 +11,10 @@ ) from unittest.mock import MagicMock -from fast_depends._compat import create_model, get_config_base -from fast_depends.core import CallModel, build_call_model +from fast_depends.core import build_call_model +from fast_depends.pydantic._compat import create_model, get_config_base from typing_extensions import Doc, override -from faststream._internal.context.repository import context from faststream._internal.publisher.proto import PublisherProto from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper from faststream._internal.subscriber.utils import process_msg @@ -26,12 +25,15 @@ ) from faststream.exceptions import NOT_CONNECTED_YET from faststream.message.source_type import SourceType -from faststream.specification.asyncapi.message import get_response_schema +from faststream.specification.asyncapi.message import ( + get_model_schema, +) from faststream.specification.asyncapi.utils import to_camelcase if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict from faststream._internal.publisher.proto import ProducerProto + from faststream._internal.setup import SetupState from faststream._internal.types import ( BrokerMiddleware, PublisherMiddleware, @@ -96,11 +98,10 @@ def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: @override def _setup( # type: ignore[override] - self, - *, - producer: Optional["ProducerProto"], + self, *, producer: Optional["ProducerProto"], state: Optional["SetupState"] ) -> None: self._producer = producer + self._state = state def set_test( self, @@ -151,7 +152,7 @@ async def _basic_publish( ( _extra_middlewares or ( - m(None, context=context).publish_scope + m(None, context=self._state.depends_params.context).publish_scope for m in self._broker_middlewares ) ), @@ -167,6 +168,8 @@ async def _basic_request( ) -> Optional[Any]: assert self._producer, NOT_CONNECTED_YET # nosec B101 + context = self._state.depends_params.context + request = self._producer.request for pub_m in chain( @@ -179,7 +182,9 @@ async def _basic_request( response_msg: Any = await process_msg( msg=published_msg, - middlewares=self._broker_middlewares, + middlewares=( + m(published_msg, context=context) for m in self._broker_middlewares + ), parser=self._producer._parser, decoder=self._producer._decoder, source_type=SourceType.Response, @@ -197,7 +202,13 @@ async def _basic_publish_batch( pub = self._producer.publish_batch for pub_m in chain( - (m(None, context=context).publish_scope for m in self._broker_middlewares), + ( + _extra_middlewares + or ( + m(None, context=self._state.depends_params.context).publish_scope + for m in self._broker_middlewares + ) + ), self._middlewares, ): pub = partial(pub_m, pub) @@ -208,34 +219,39 @@ def get_payloads(self) -> list[tuple["AnyDict", str]]: payloads: list[tuple[AnyDict, str]] = [] if self.schema_: - params = {"response__": (self.schema_, ...)} - - call_model: CallModel[Any, Any] = CallModel( - call=lambda: None, - model=create_model("Fake"), - response_model=create_model( # type: ignore[call-overload] + body = get_model_schema( + call=create_model( "", __config__=get_config_base(), - **params, + response__=(self.schema_, ...), ), - params=params, - ) - - body = get_response_schema( - call_model, prefix=f"{self.name}:Message", ) + if body: # pragma: no branch payloads.append((body, "")) else: for call in self.calls: - call_model = build_call_model(call) - body = get_response_schema( - call_model, - prefix=f"{self.name}:Message", + call_model = build_call_model( + call, + dependency_provider=self._state.depends_params.provider, + serializer_cls=self._state.depends_params.serializer, ) - if body: - payloads.append((body, to_camelcase(unwrap(call).__name__))) + + response_type = next( + iter(call_model.serializer.response_option.values()) + ).field_type + if response_type is not None and response_type is not Parameter.empty: + body = get_model_schema( + create_model( + "", + __config__=get_config_base(), + response__=(response_type, ...), + ), + prefix=f"{self.name}:Message", + ) + if body: + payloads.append((body, to_camelcase(unwrap(call).__name__))) return payloads diff --git a/faststream/_internal/setup/fast_depends.py b/faststream/_internal/setup/fast_depends.py index aa3a664540..ad5be38da2 100644 --- a/faststream/_internal/setup/fast_depends.py +++ b/faststream/_internal/setup/fast_depends.py @@ -3,12 +3,18 @@ from typing import TYPE_CHECKING, Any, Callable, Optional if TYPE_CHECKING: + from fast_depends import Provider + from fast_depends.library.serializer import SerializerProto + from faststream._internal.basic_types import Decorator + from faststream._internal.context import ContextRepo @dataclass class FastDependsData: - apply_types: bool - is_validate: bool + use_fastdepends: bool get_dependent: Optional[Callable[..., Any]] call_decorators: Sequence["Decorator"] + provider: "Provider" + serializer: Optional["SerializerProto"] + context: "ContextRepo" diff --git a/faststream/_internal/setup/logger.py b/faststream/_internal/setup/logger.py index 45d660c687..fb7da8f493 100644 --- a/faststream/_internal/setup/logger.py +++ b/faststream/_internal/setup/logger.py @@ -1,6 +1,6 @@ import warnings from dataclasses import dataclass, field -from typing import Optional, Protocol +from typing import TYPE_CHECKING, Optional, Protocol from faststream._internal.basic_types import AnyDict, LoggerProto from faststream._internal.constants import EMPTY @@ -8,6 +8,9 @@ from .proto import SetupAble +if TYPE_CHECKING: + from faststream._internal.context import ContextRepo + __all__ = ( "DefaultLoggerStorage", "LoggerParamsStorage", @@ -105,14 +108,14 @@ def log( class LoggerParamsStorage(Protocol): def setup_log_contest(self, params: "AnyDict") -> None: ... - def get_logger(self) -> Optional["LoggerProto"]: ... + def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: ... class _EmptyLoggerStorage(LoggerParamsStorage): def setup_log_contest(self, params: AnyDict) -> None: pass - def get_logger(self) -> None: + def get_logger(self, *, context: "ContextRepo") -> None: return None @@ -123,7 +126,7 @@ def __init__(self, logger: "LoggerProto") -> None: def setup_log_contest(self, params: AnyDict) -> None: pass - def get_logger(self) -> LoggerProto: + def get_logger(self, *, context: "ContextRepo") -> LoggerProto: return self.__logger @@ -153,8 +156,8 @@ def log( exc_info=exc_info, ) - def _setup(self) -> None: - if logger := self.params_storage.get_logger(): + def _setup(self, *, context: "ContextRepo") -> None: + if logger := self.params_storage.get_logger(context=context): self.logger = _RealLoggerObject(logger) else: self.logger = _EmptyLoggerObject() diff --git a/faststream/_internal/setup/state.py b/faststream/_internal/setup/state.py index 907d8a3b56..da34c29c9e 100644 --- a/faststream/_internal/setup/state.py +++ b/faststream/_internal/setup/state.py @@ -25,7 +25,7 @@ def __bool__(self) -> bool: raise NotImplementedError def _setup(self) -> None: - self.logger_state._setup() + self.logger_state._setup(context=self.depends_params.context) def copy_with_params( self, diff --git a/faststream/_internal/subscriber/call_item.py b/faststream/_internal/subscriber/call_item.py index 6a20e3ac7c..e0d9d94255 100644 --- a/faststream/_internal/subscriber/call_item.py +++ b/faststream/_internal/subscriber/call_item.py @@ -5,7 +5,6 @@ from typing import ( TYPE_CHECKING, Any, - Callable, Generic, Optional, cast, @@ -18,9 +17,10 @@ from faststream.exceptions import IgnoredException, SetupError if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AsyncFuncAny, Decorator + from faststream._internal.setup import FastDependsData from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper from faststream._internal.types import ( AsyncCallable, @@ -54,7 +54,7 @@ def __init__( item_parser: Optional["CustomCallable"], item_decoder: Optional["CustomCallable"], item_middlewares: Iterable["SubscriberMiddleware[StreamMessage[MsgType]]"], - dependencies: Iterable["Depends"], + dependencies: Iterable["Dependant"], ) -> None: self.handler = handler self.filter = filter @@ -75,10 +75,8 @@ def _setup( # type: ignore[override] *, parser: "AsyncCallable", decoder: "AsyncCallable", - broker_dependencies: Iterable["Depends"], - apply_types: bool, - is_validate: bool, - _get_dependant: Optional[Callable[..., Any]], + broker_dependencies: Iterable["Dependant"], + fast_depends_state: "FastDependsData", _call_decorators: Iterable["Decorator"], ) -> None: if self.dependant is None: @@ -88,17 +86,15 @@ def _setup( # type: ignore[override] dependencies = (*broker_dependencies, *self.dependencies) dependant = self.handler.set_wrapped( - apply_types=apply_types, - is_validate=is_validate, dependencies=dependencies, - _get_dependant=_get_dependant, _call_decorators=_call_decorators, + state=fast_depends_state, ) - if _get_dependant is None: + if fast_depends_state.get_dependent is None: self.dependant = dependant else: - self.dependant = _get_dependant( + self.dependant = fast_depends_state.get_dependent( self.handler._original_call, dependencies, ) diff --git a/faststream/_internal/subscriber/call_wrapper/call.py b/faststream/_internal/subscriber/call_wrapper/call.py index 208821ad38..ed2c3b839b 100644 --- a/faststream/_internal/subscriber/call_wrapper/call.py +++ b/faststream/_internal/subscriber/call_wrapper/call.py @@ -11,8 +11,8 @@ from unittest.mock import MagicMock import anyio +from fast_depends import inject from fast_depends.core import CallModel, build_call_model -from fast_depends.use import _InjectWrapper, inject from faststream._internal.types import ( MsgType, @@ -23,10 +23,12 @@ from faststream.exceptions import SetupError if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant + from fast_depends.use import InjectWrapper from faststream._internal.basic_types import Decorator from faststream._internal.publisher.proto import PublisherProto + from faststream._internal.setup.fast_depends import FastDependsData from faststream.message import StreamMessage @@ -144,12 +146,10 @@ def refresh(self, with_mock: bool = False) -> None: def set_wrapped( self, *, - apply_types: bool, - is_validate: bool, - dependencies: Iterable["Depends"], - _get_dependant: Optional[Callable[..., Any]], + dependencies: Iterable["Dependant"], _call_decorators: Iterable["Decorator"], - ) -> Optional["CallModel[..., Any]"]: + state: "FastDependsData", + ) -> Optional["CallModel"]: call = self._original_call for decor in _call_decorators: call = decor(call) @@ -157,16 +157,20 @@ def set_wrapped( f: Callable[..., Awaitable[Any]] = to_async(call) - dependent: Optional[CallModel[..., Any]] = None - if _get_dependant is None: + dependent: Optional[CallModel] = None + if state.get_dependent is None: dependent = build_call_model( f, - cast=is_validate, - extra_dependencies=dependencies, # type: ignore[arg-type] + extra_dependencies=dependencies, + dependency_provider=state.provider, + serializer_cls=state.serializer, ) - if apply_types: - wrapper: _InjectWrapper[Any, Any] = inject(func=None) + if state.use_fastdepends: + wrapper: InjectWrapper[Any, Any] = inject( + func=None, + context__=state.context, + ) f = wrapper(func=f, model=dependent) f = _wrap_decode_message( diff --git a/faststream/_internal/subscriber/call_wrapper/proto.py b/faststream/_internal/subscriber/call_wrapper/proto.py index 161905351a..160b054e6b 100644 --- a/faststream/_internal/subscriber/call_wrapper/proto.py +++ b/faststream/_internal/subscriber/call_wrapper/proto.py @@ -19,7 +19,7 @@ ) if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from .call import HandlerCallWrapper @@ -36,7 +36,7 @@ def __call__( parser: Optional["CustomCallable"] = None, decoder: Optional["CustomCallable"] = None, middlewares: Iterable["SubscriberMiddleware[Any]"] = (), - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), ) -> Callable[ [Callable[P_HandlerParams, T_HandlerReturn]], "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]", @@ -54,7 +54,7 @@ def __call__( parser: Optional["CustomCallable"] = None, decoder: Optional["CustomCallable"] = None, middlewares: Iterable["SubscriberMiddleware[Any]"] = (), - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), ) -> "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]": ... def __call__( @@ -69,7 +69,7 @@ def __call__( parser: Optional["CustomCallable"] = None, decoder: Optional["CustomCallable"] = None, middlewares: Iterable["SubscriberMiddleware[Any]"] = (), - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), ) -> Union[ "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]", Callable[ diff --git a/faststream/_internal/subscriber/proto.py b/faststream/_internal/subscriber/proto.py index e376a497f9..1b42548e7a 100644 --- a/faststream/_internal/subscriber/proto.py +++ b/faststream/_internal/subscriber/proto.py @@ -1,6 +1,6 @@ from abc import abstractmethod from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Optional from typing_extensions import Self, override @@ -10,13 +10,14 @@ from faststream.specification.base.proto import EndpointProto if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant - from faststream._internal.basic_types import AnyDict, Decorator, LoggerProto + from faststream._internal.basic_types import AnyDict, LoggerProto from faststream._internal.publisher.proto import ( BasePublisherProto, ProducerProto, ) + from faststream._internal.setup import SetupState from faststream._internal.subscriber.call_item import HandlerItem from faststream._internal.types import ( BrokerMiddleware, @@ -36,7 +37,7 @@ class SubscriberProto( calls: list["HandlerItem[MsgType]"] running: bool - _broker_dependencies: Iterable["Depends"] + _broker_dependencies: Iterable["Dependant"] _broker_middlewares: Iterable["BrokerMiddleware[MsgType]"] _producer: Optional["ProducerProto"] @@ -56,16 +57,14 @@ def _setup( # type: ignore[override] self, *, logger: Optional["LoggerProto"], + producer: Optional["ProducerProto"], graceful_timeout: Optional[float], + extra_context: "AnyDict", + # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], - producer: Optional["ProducerProto"], - extra_context: "AnyDict", - # FastDepends options - apply_types: bool, - is_validate: bool, - _get_dependant: Optional[Callable[..., Any]], - _call_decorators: Iterable["Decorator"], + # dependant args + state: "SetupState", ) -> None: ... @abstractmethod @@ -105,5 +104,5 @@ def add_call( parser_: "CustomCallable", decoder_: "CustomCallable", middlewares_: Iterable["SubscriberMiddleware[Any]"], - dependencies_: Iterable["Depends"], + dependencies_: Iterable["Dependant"], ) -> Self: ... diff --git a/faststream/_internal/subscriber/usecase.py b/faststream/_internal/subscriber/usecase.py index 6eda9add2e..0133be3493 100644 --- a/faststream/_internal/subscriber/usecase.py +++ b/faststream/_internal/subscriber/usecase.py @@ -13,7 +13,6 @@ from typing_extensions import Self, override -from faststream._internal.context.repository import context from faststream._internal.subscriber.call_item import HandlerItem from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper from faststream._internal.subscriber.proto import SubscriberProto @@ -35,9 +34,10 @@ from faststream.specification.asyncapi.utils import to_camelcase if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict, Decorator, LoggerProto + from faststream._internal.context.repository import ContextRepo from faststream._internal.publisher.proto import ( BasePublisherProto, ProducerProto, @@ -69,7 +69,7 @@ def __init__( parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], middlewares: Iterable["SubscriberMiddleware[Any]"], - dependencies: Iterable["Depends"], + dependencies: Iterable["Dependant"], ) -> None: self.parser = parser self.decoder = decoder @@ -85,7 +85,7 @@ class SubscriberUsecase(SubscriberProto[MsgType]): extra_context: "AnyDict" graceful_timeout: Optional[float] - _broker_dependencies: Iterable["Depends"] + _broker_dependencies: Iterable["Dependant"] _call_options: Optional["_CallOptions"] _call_decorators: Iterable["Decorator"] @@ -95,7 +95,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], default_parser: "AsyncCallable", default_decoder: "AsyncCallable", @@ -151,6 +151,8 @@ def _setup( # type: ignore[override] # dependant args state: "SetupState", ) -> None: + self._state = state + self._producer = producer self.graceful_timeout = graceful_timeout self.extra_context = extra_context @@ -174,9 +176,7 @@ def _setup( # type: ignore[override] call._setup( parser=async_parser, decoder=async_decoder, - apply_types=state.depends_params.apply_types, - is_validate=state.depends_params.is_validate, - _get_dependant=state.depends_params.get_dependent, + fast_depends_state=state.depends_params, _call_decorators=( *self._call_decorators, *state.depends_params.call_decorators, @@ -209,7 +209,7 @@ def add_call( parser_: Optional["CustomCallable"], decoder_: Optional["CustomCallable"], middlewares_: Iterable["SubscriberMiddleware[Any]"], - dependencies_: Iterable["Depends"], + dependencies_: Iterable["Dependant"], ) -> Self: self._call_options = _CallOptions( parser=parser_, @@ -228,7 +228,7 @@ def __call__( parser: Optional["CustomCallable"] = None, decoder: Optional["CustomCallable"] = None, middlewares: Iterable["SubscriberMiddleware[Any]"] = (), - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), ) -> Callable[ [Callable[P_HandlerParams, T_HandlerReturn]], "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]", @@ -243,7 +243,7 @@ def __call__( parser: Optional["CustomCallable"] = None, decoder: Optional["CustomCallable"] = None, middlewares: Iterable["SubscriberMiddleware[Any]"] = (), - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), ) -> "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]": ... def __call__( @@ -254,7 +254,7 @@ def __call__( parser: Optional["CustomCallable"] = None, decoder: Optional["CustomCallable"] = None, middlewares: Iterable["SubscriberMiddleware[Any]"] = (), - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), ) -> Any: if (options := self._call_options) is None: msg = ( @@ -308,7 +308,7 @@ async def consume(self, msg: MsgType) -> Any: # Stop handler at `exit()` call await self.close() - if app := context.get("app"): + if app := self._state.depends_params.context.get("app"): app.exit() except Exception: # nosec B110 @@ -317,6 +317,8 @@ async def consume(self, msg: MsgType) -> Any: async def process_message(self, msg: MsgType) -> "Response": """Execute all message processing stages.""" + context: ContextRepo = self._state.depends_params.context + async with AsyncExitStack() as stack: stack.enter_context(self.lock) @@ -394,7 +396,6 @@ async def process_message(self, msg: MsgType) -> "Response": msg = f"There is no suitable handler for {msg=}" raise SubscriberNotFound(msg) - # An error was raised and processed by some middleware return ensure_response(None) diff --git a/faststream/_internal/subscriber/utils.py b/faststream/_internal/subscriber/utils.py index 5b6d0c990e..4dc615a9c0 100644 --- a/faststream/_internal/subscriber/utils.py +++ b/faststream/_internal/subscriber/utils.py @@ -15,7 +15,6 @@ import anyio from typing_extensions import Literal, Self, overload -from faststream._internal.context.repository import context from faststream._internal.subscriber.acknowledgement_watcher import ( WatcherContext, get_watcher, @@ -30,17 +29,17 @@ from faststream._internal.basic_types import LoggerProto from faststream._internal.types import ( AsyncCallable, - BrokerMiddleware, CustomCallable, SyncCallable, ) from faststream.message import StreamMessage + from faststream.middlewares import BaseMiddleware @overload async def process_msg( msg: Literal[None], - middlewares: Iterable["BrokerMiddleware[MsgType]"], + middlewares: Iterable["BaseMiddleware"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], source_type: SourceType = SourceType.Consume, @@ -50,7 +49,7 @@ async def process_msg( @overload async def process_msg( msg: MsgType, - middlewares: Iterable["BrokerMiddleware[MsgType]"], + middlewares: Iterable["BaseMiddleware"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], source_type: SourceType = SourceType.Consume, @@ -59,7 +58,7 @@ async def process_msg( async def process_msg( msg: Optional[MsgType], - middlewares: Iterable["BrokerMiddleware[MsgType]"], + middlewares: Iterable["BaseMiddleware"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], source_type: SourceType = SourceType.Consume, @@ -74,9 +73,8 @@ async def process_msg( ] = return_input for m in middlewares: - mid = m(msg, context=context) - await stack.enter_async_context(mid) - return_msg = partial(mid.consume_scope, return_msg) + await stack.enter_async_context(m) + return_msg = partial(m.consume_scope, return_msg) parsed_msg = await parser(msg) parsed_msg._source_type = source_type diff --git a/faststream/_internal/utils/functions.py b/faststream/_internal/utils/functions.py index 3fd2eea179..be90e6f0a2 100644 --- a/faststream/_internal/utils/functions.py +++ b/faststream/_internal/utils/functions.py @@ -5,13 +5,19 @@ Any, Callable, Optional, + TypeVar, Union, overload, ) import anyio from fast_depends.core import CallModel -from fast_depends.utils import run_async as call_or_await +from fast_depends.utils import ( + is_coroutine_callable, + run_async as call_or_await, + run_in_threadpool, +) +from typing_extensions import ParamSpec from faststream._internal.basic_types import F_Return, F_Spec @@ -23,6 +29,9 @@ "to_async", ) +P = ParamSpec("P") +T = TypeVar("T") + @overload def to_async( @@ -43,11 +52,13 @@ def to_async( ], ) -> Callable[F_Spec, Awaitable[F_Return]]: """Converts a synchronous function to an asynchronous function.""" + if is_coroutine_callable(func): + return func @wraps(func) async def to_async_wrapper(*args: F_Spec.args, **kwargs: F_Spec.kwargs) -> F_Return: """Wraps a function to make it asynchronous.""" - return await call_or_await(func, *args, **kwargs) + return await run_in_threadpool(func, *args, **kwargs) return to_async_wrapper @@ -72,10 +83,8 @@ def sync_fake_context(*args: Any, **kwargs: Any) -> Iterator[None]: yield None -def drop_response_type( - model: CallModel[F_Spec, F_Return], -) -> CallModel[F_Spec, F_Return]: - model.response_model = None +def drop_response_type(model: CallModel) -> CallModel: + model.serializer.response_callback = None return model diff --git a/faststream/app.py b/faststream/app.py index c43638c839..6e31e12b62 100644 --- a/faststream/app.py +++ b/faststream/app.py @@ -15,14 +15,14 @@ from faststream._internal.basic_types import Lifespan, LoggerProto from faststream._internal.broker.broker import BrokerUsecase from faststream._internal.cli.supervisors.utils import set_exit +from faststream._internal.constants import EMPTY from faststream._internal.log import logger from faststream.asgi.app import AsgiFastStream -P_HookParams = ParamSpec("P_HookParams") -T_HookReturn = TypeVar("T_HookReturn") - - if TYPE_CHECKING: + from fast_depends import Provider + from fast_depends.library.serializer import SerializerProto + from faststream._internal.basic_types import ( AnyCallable, Lifespan, @@ -32,16 +32,21 @@ from faststream._internal.broker.broker import BrokerUsecase from faststream.asgi.types import ASGIApp +P_HookParams = ParamSpec("P_HookParams") +T_HookReturn = TypeVar("T_HookReturn") + class FastStream(Application): """A class representing a FastStream application.""" def __init__( self, - broker: Optional["BrokerUsecase[Any, Any]"] = None, + broker: "BrokerUsecase[Any, Any]", /, # regular broker args logger: Optional["LoggerProto"] = logger, + provider: Optional["Provider"] = None, + serializer: Optional["SerializerProto"] = EMPTY, lifespan: Optional["Lifespan"] = None, on_startup: Sequence["AnyCallable"] = (), after_startup: Sequence["AnyCallable"] = (), @@ -49,8 +54,10 @@ def __init__( after_shutdown: Sequence["AnyCallable"] = (), ) -> None: super().__init__( - broker=broker, + broker, logger=logger, + provider=provider, + serializer=serializer, lifespan=lifespan, on_startup=on_startup, after_startup=after_startup, @@ -66,8 +73,6 @@ async def run( sleep_time: float = 0.1, ) -> None: """Run FastStream Application.""" - assert self.broker, "You should setup a broker" # nosec B101 - set_exit(lambda *_: self.exit(), sync=False) async with self.lifespan_context(**(run_extra_options or {})): diff --git a/faststream/asgi/app.py b/faststream/asgi/app.py index 7258bccef2..b5293ea508 100644 --- a/faststream/asgi/app.py +++ b/faststream/asgi/app.py @@ -15,6 +15,7 @@ from faststream._internal._compat import HAS_TYPER, ExceptionGroup from faststream._internal.application import Application +from faststream._internal.constants import EMPTY from faststream._internal.log import logger from faststream.asgi.response import AsgiResponse from faststream.asgi.websocket import WebSocketClose @@ -24,6 +25,8 @@ from types import FrameType from anyio.abc import TaskStatus + from fast_depends import Provider + from fast_depends.library.serializer import SerializerProto from faststream._internal.basic_types import ( AnyCallable, @@ -75,11 +78,13 @@ class AsgiFastStream(Application): def __init__( self, - broker: Optional["BrokerUsecase[Any, Any]"] = None, + broker: "BrokerUsecase[Any, Any]", /, asgi_routes: Sequence[tuple[str, "ASGIApp"]] = (), # regular broker args logger: Optional["LoggerProto"] = logger, + provider: Optional["Provider"] = None, + serializer: Optional["SerializerProto"] = EMPTY, lifespan: Optional["Lifespan"] = None, # hooks on_startup: Sequence["AnyCallable"] = (), @@ -88,8 +93,10 @@ def __init__( after_shutdown: Sequence["AnyCallable"] = (), ) -> None: super().__init__( - broker=broker, + broker, logger=logger, + provider=provider, + serializer=serializer, lifespan=lifespan, on_startup=on_startup, after_startup=after_startup, diff --git a/faststream/confluent/broker/broker.py b/faststream/confluent/broker/broker.py index 4451d0e8c0..8eb7aefd87 100644 --- a/faststream/confluent/broker/broker.py +++ b/faststream/confluent/broker/broker.py @@ -36,7 +36,8 @@ from types import TracebackType from confluent_kafka import Message - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant + from fast_depends.library.serializer import SerializerProto from faststream._internal.basic_types import ( AnyDict, @@ -266,7 +267,7 @@ def __init__( Doc("Custom parser object."), ] = None, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc("Dependencies to apply to all broker subscribers."), ] = (), middlewares: Annotated[ @@ -323,10 +324,7 @@ def __init__( bool, Doc("Whether to use FastDepends or not."), ] = True, - validate: Annotated[ - bool, - Doc("Whether to cast types using Pydantic validation."), - ] = True, + serializer: Optional["SerializerProto"] = EMPTY, _get_dependant: Annotated[ Optional[Callable[..., Any]], Doc("Custom library dependant generator callback."), @@ -397,7 +395,7 @@ def __init__( _get_dependant=_get_dependant, _call_decorators=_call_decorators, apply_types=apply_types, - validate=validate, + serializer=serializer, ) self.client_id = client_id self._producer = None diff --git a/faststream/confluent/broker/logging.py b/faststream/confluent/broker/logging.py index 276c571373..9e64efca0e 100644 --- a/faststream/confluent/broker/logging.py +++ b/faststream/confluent/broker/logging.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.context import ContextRepo class KafkaParamsStorage(DefaultLoggerStorage): @@ -35,7 +36,7 @@ def setup_log_contest(self, params: "AnyDict") -> None: ), ) - def get_logger(self) -> Optional["LoggerProto"]: + def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: message_id_ln = 10 # TODO: generate unique logger names to not share between brokers @@ -58,6 +59,7 @@ def get_logger(self) -> Optional["LoggerProto"]: f"%(message_id)-{message_id_ln}s ", "- %(message)s", )), + context=context, ) diff --git a/faststream/confluent/broker/registrator.py b/faststream/confluent/broker/registrator.py index 289ee413ac..8eb5c58326 100644 --- a/faststream/confluent/broker/registrator.py +++ b/faststream/confluent/broker/registrator.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from confluent_kafka import Message - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.types import ( CustomCallable, @@ -279,8 +279,8 @@ def subscriber( ] = None, # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -550,8 +550,8 @@ def subscriber( ] = None, # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -821,8 +821,8 @@ def subscriber( ] = None, # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -1095,8 +1095,8 @@ def subscriber( ] = None, # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], diff --git a/faststream/confluent/parser.py b/faststream/confluent/parser.py index 85063b53b2..5a04cc2248 100644 --- a/faststream/confluent/parser.py +++ b/faststream/confluent/parser.py @@ -1,23 +1,30 @@ from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Union -from faststream._internal.context.repository import context -from faststream.confluent.message import FAKE_CONSUMER, KafkaMessage from faststream.message import decode_message +from .message import FAKE_CONSUMER, KafkaMessage + if TYPE_CHECKING: from confluent_kafka import Message from faststream._internal.basic_types import DecodedMessage - from faststream.confluent.subscriber.usecase import LogicSubscriber - from faststream.message import StreamMessage + + from .message import ConsumerProtocol, StreamMessage class AsyncConfluentParser: """A class to parse Kafka messages.""" - @staticmethod + def __init__(self, is_manual: bool = False) -> None: + self.is_manual = is_manual + self._consumer: ConsumerProtocol = FAKE_CONSUMER + + def _setup(self, consumer: "ConsumerProtocol") -> None: + self._consumer = consumer + async def parse_message( + self, message: "Message", ) -> KafkaMessage: """Parses a Kafka message.""" @@ -27,8 +34,6 @@ async def parse_message( offset = message.offset() _, timestamp = message.timestamp() - handler: Optional[LogicSubscriber[Any]] = context.get_local("handler_") - return KafkaMessage( body=body, headers=headers, @@ -37,12 +42,12 @@ async def parse_message( message_id=f"{offset}-{timestamp}", correlation_id=headers.get("correlation_id"), raw_message=message, - consumer=getattr(handler, "consumer", None) or FAKE_CONSUMER, - is_manual=getattr(handler, "is_manual", True), + consumer=self._consumer, + is_manual=self.is_manual, ) - @staticmethod async def parse_message_batch( + self, message: tuple["Message", ...], ) -> KafkaMessage: """Parses a batch of messages from a Kafka consumer.""" @@ -60,8 +65,6 @@ async def parse_message_batch( _, first_timestamp = first.timestamp() - handler: Optional[LogicSubscriber[Any]] = context.get_local("handler_") - return KafkaMessage( body=body, headers=headers, @@ -71,24 +74,23 @@ async def parse_message_batch( message_id=f"{first.offset()}-{last.offset()}-{first_timestamp}", correlation_id=headers.get("correlation_id"), raw_message=message, - consumer=getattr(handler, "consumer", None) or FAKE_CONSUMER, - is_manual=getattr(handler, "is_manual", True), + consumer=self._consumer, + is_manual=self.is_manual, ) - @staticmethod async def decode_message( + self, msg: "StreamMessage[Message]", ) -> "DecodedMessage": """Decodes a message.""" return decode_message(msg) - @classmethod async def decode_message_batch( - cls, + self, msg: "StreamMessage[tuple[Message, ...]]", ) -> "DecodedMessage": """Decode a batch of messages.""" - return [decode_message(await cls.parse_message(m)) for m in msg.raw_message] + return [decode_message(await self.parse_message(m)) for m in msg.raw_message] def _parse_msg_headers( diff --git a/faststream/confluent/publisher/producer.py b/faststream/confluent/publisher/producer.py index 0ca1a00217..fcb4328638 100644 --- a/faststream/confluent/publisher/producer.py +++ b/faststream/confluent/publisher/producer.py @@ -26,7 +26,7 @@ def __init__( self._producer = producer # NOTE: register default parser to be compatible with request - default = AsyncConfluentParser + default = AsyncConfluentParser() self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) diff --git a/faststream/confluent/router.py b/faststream/confluent/router.py index 2d7f599d18..cd1fc9509c 100644 --- a/faststream/confluent/router.py +++ b/faststream/confluent/router.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from confluent_kafka import Message - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import SendableMessage from faststream._internal.types import ( @@ -365,8 +365,8 @@ def __init__( ] = None, # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -473,9 +473,9 @@ def __init__( ] = (), *, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc( - "Dependencies list (`[Depends(),]`) to apply to all routers' publishers/subscribers.", + "Dependencies list (`[Dependant(),]`) to apply to all routers' publishers/subscribers.", ), ] = (), middlewares: Annotated[ diff --git a/faststream/confluent/subscriber/factory.py b/faststream/confluent/subscriber/factory.py index c1d4a2d444..24107e56be 100644 --- a/faststream/confluent/subscriber/factory.py +++ b/faststream/confluent/subscriber/factory.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from confluent_kafka import Message as ConfluentMsg - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware @@ -36,7 +36,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[tuple[ConfluentMsg, ...]]"], # Specification args title_: Optional[str], @@ -60,7 +60,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[ConfluentMsg]"], # Specification args title_: Optional[str], @@ -84,7 +84,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConfluentMsg, tuple[ConfluentMsg, ...]]]" ], @@ -112,7 +112,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConfluentMsg, tuple[ConfluentMsg, ...]]]" ], diff --git a/faststream/confluent/subscriber/usecase.py b/faststream/confluent/subscriber/usecase.py index be1ce66dfa..470fb57840 100644 --- a/faststream/confluent/subscriber/usecase.py +++ b/faststream/confluent/subscriber/usecase.py @@ -20,7 +20,7 @@ from faststream.confluent.schemas import TopicPartition if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict, LoggerProto from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto @@ -42,6 +42,7 @@ class LogicSubscriber(ABC, SubscriberUsecase[MsgType]): builder: Optional[Callable[..., "AsyncConfluentConsumer"]] consumer: Optional["AsyncConfluentConsumer"] + parser: AsyncConfluentParser task: Optional["asyncio.Task[None]"] client_id: Optional[str] @@ -54,14 +55,13 @@ def __init__( # Kafka information group_id: Optional[str], connection_data: "AnyDict", - is_manual: bool, # Subscriber args default_parser: "AsyncCallable", default_decoder: "AsyncCallable", no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args title_: Optional[str], @@ -88,7 +88,6 @@ def __init__( self.group_id = group_id self.topics = topics self.partitions = partitions - self.is_manual = is_manual self.consumer = None self.task = None @@ -140,6 +139,7 @@ async def start(self) -> None: client_id=self.client_id, **self.__connection_data, ) + self.parser._setup(consumer) await consumer.start() await super().start() @@ -174,7 +174,10 @@ async def get_one( return await process_msg( msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) @@ -263,23 +266,24 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Message]"], # AsyncAPI args title_: Optional[str], description_: Optional[str], include_in_schema: bool, ) -> None: + self.parser = AsyncConfluentParser(is_manual=is_manual) + super().__init__( *topics, partitions=partitions, polling_interval=polling_interval, group_id=group_id, connection_data=connection_data, - is_manual=is_manual, # subscriber args - default_parser=AsyncConfluentParser.parse_message, - default_decoder=AsyncConfluentParser.decode_message, + default_parser=self.parser.parse_message, + default_decoder=self.parser.decode_message, # Propagated args no_ack=no_ack, no_reply=no_reply, @@ -327,7 +331,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[tuple[Message, ...]]"], # AsyncAPI args title_: Optional[str], @@ -336,16 +340,17 @@ def __init__( ) -> None: self.max_records = max_records + self.parser = AsyncConfluentParser(is_manual=is_manual) + super().__init__( *topics, partitions=partitions, polling_interval=polling_interval, group_id=group_id, connection_data=connection_data, - is_manual=is_manual, # subscriber args - default_parser=AsyncConfluentParser.parse_message_batch, - default_decoder=AsyncConfluentParser.decode_message_batch, + default_parser=self.parser.parse_message_batch, + default_decoder=self.parser.decode_message_batch, # Propagated args no_ack=no_ack, no_reply=no_reply, diff --git a/faststream/confluent/testing.py b/faststream/confluent/testing.py index 973a7fb58d..27a81703e3 100644 --- a/faststream/confluent/testing.py +++ b/faststream/confluent/testing.py @@ -94,7 +94,7 @@ class FakeProducer(AsyncConfluentFastProducer): def __init__(self, broker: KafkaBroker) -> None: self.broker = broker - default = AsyncConfluentParser + default = AsyncConfluentParser() self._parser = resolve_custom_func(broker._parser, default.parse_message) self._decoder = resolve_custom_func(broker._decoder, default.decode_message) diff --git a/faststream/kafka/broker/broker.py b/faststream/kafka/broker/broker.py index ce2969b12b..3564d52c9c 100644 --- a/faststream/kafka/broker/broker.py +++ b/faststream/kafka/broker/broker.py @@ -41,7 +41,8 @@ from aiokafka import ConsumerRecord from aiokafka.abc import AbstractTokenProvider - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant + from fast_depends.library.serializer import SerializerProto from typing_extensions import TypedDict, Unpack from faststream._internal.basic_types import ( @@ -440,7 +441,7 @@ def __init__( Doc("Custom parser object."), ] = None, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc("Dependencies to apply to all broker subscribers."), ] = (), middlewares: Annotated[ @@ -497,10 +498,7 @@ def __init__( bool, Doc("Whether to use FastDepends or not."), ] = True, - validate: Annotated[ - bool, - Doc("Whether to cast types using Pydantic validation."), - ] = True, + serializer: Optional["SerializerProto"] = EMPTY, _get_dependant: Annotated[ Optional[Callable[..., Any]], Doc("Custom library dependant generator callback."), @@ -578,7 +576,7 @@ def __init__( _get_dependant=_get_dependant, _call_decorators=_call_decorators, apply_types=apply_types, - validate=validate, + serializer=serializer, ) self.client_id = client_id diff --git a/faststream/kafka/broker/logging.py b/faststream/kafka/broker/logging.py index c39b71a604..9518a4f7ec 100644 --- a/faststream/kafka/broker/logging.py +++ b/faststream/kafka/broker/logging.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.context import ContextRepo class KafkaParamsStorage(DefaultLoggerStorage): @@ -35,7 +36,7 @@ def setup_log_contest(self, params: "AnyDict") -> None: ), ) - def get_logger(self) -> Optional["LoggerProto"]: + def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: message_id_ln = 10 # TODO: generate unique logger names to not share between brokers @@ -58,6 +59,7 @@ def get_logger(self) -> Optional["LoggerProto"]: f"%(message_id)-{message_id_ln}s ", "- %(message)s", )), + context=context, ) diff --git a/faststream/kafka/broker/registrator.py b/faststream/kafka/broker/registrator.py index 8a7efeff2f..b5b7c4b0d9 100644 --- a/faststream/kafka/broker/registrator.py +++ b/faststream/kafka/broker/registrator.py @@ -23,7 +23,7 @@ from aiokafka import TopicPartition from aiokafka.abc import ConsumerRebalanceListener from aiokafka.coordinator.assignors.abstract import AbstractPartitionAssignor - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.types import ( CustomCallable, @@ -381,8 +381,8 @@ def subscriber( ] = (), # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -751,8 +751,8 @@ def subscriber( ] = (), # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -1121,8 +1121,8 @@ def subscriber( ] = (), # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -1494,8 +1494,8 @@ def subscriber( ] = (), # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], diff --git a/faststream/kafka/parser.py b/faststream/kafka/parser.py index c4ce947a34..94275cc041 100644 --- a/faststream/kafka/parser.py +++ b/faststream/kafka/parser.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING, Any, Optional, cast -from faststream._internal.context.repository import context -from faststream.kafka.message import FAKE_CONSUMER, KafkaMessage +from faststream.kafka.message import FAKE_CONSUMER, ConsumerProtocol, KafkaMessage from faststream.message import decode_message if TYPE_CHECKING: @@ -10,7 +9,6 @@ from aiokafka import ConsumerRecord from faststream._internal.basic_types import DecodedMessage - from faststream.kafka.subscriber.usecase import LogicSubscriber from faststream.message import StreamMessage @@ -25,13 +23,17 @@ def __init__( self.msg_class = msg_class self.regex = regex + self._consumer: ConsumerProtocol = FAKE_CONSUMER + + def _setup(self, consumer: ConsumerProtocol) -> None: + self._consumer = consumer + async def parse_message( self, message: "ConsumerRecord", ) -> "StreamMessage[ConsumerRecord]": """Parses a Kafka message.""" headers = {i: j.decode() for i, j in message.headers} - handler: Optional[LogicSubscriber[Any]] = context.get_local("handler_") return self.msg_class( body=message.value, @@ -42,7 +44,7 @@ async def parse_message( correlation_id=headers.get("correlation_id"), raw_message=message, path=self.get_path(message.topic), - consumer=getattr(handler, "consumer", None) or FAKE_CONSUMER, + consumer=self._consumer, ) async def decode_message( @@ -76,8 +78,6 @@ async def parse_message( headers = next(iter(batch_headers), {}) - handler: Optional[LogicSubscriber[Any]] = context.get_local("handler_") - return self.msg_class( body=body, headers=headers, @@ -88,7 +88,7 @@ async def parse_message( correlation_id=headers.get("correlation_id"), raw_message=message, path=self.get_path(first.topic), - consumer=getattr(handler, "consumer", None) or FAKE_CONSUMER, + consumer=self._consumer, ) async def decode_message( diff --git a/faststream/kafka/router.py b/faststream/kafka/router.py index e79e422e8b..1652e52901 100644 --- a/faststream/kafka/router.py +++ b/faststream/kafka/router.py @@ -23,7 +23,7 @@ from aiokafka import ConsumerRecord, TopicPartition from aiokafka.abc import ConsumerRebalanceListener from aiokafka.coordinator.assignors.abstract import AbstractPartitionAssignor - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import SendableMessage from faststream._internal.types import ( @@ -468,8 +468,8 @@ def __init__( ] = (), # broker args dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -583,9 +583,9 @@ def __init__( ] = (), *, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc( - "Dependencies list (`[Depends(),]`) to apply to all routers' publishers/subscribers.", + "Dependencies list (`[Dependant(),]`) to apply to all routers' publishers/subscribers.", ), ] = (), middlewares: Annotated[ diff --git a/faststream/kafka/subscriber/factory.py b/faststream/kafka/subscriber/factory.py index b4ae8638f1..e53654283e 100644 --- a/faststream/kafka/subscriber/factory.py +++ b/faststream/kafka/subscriber/factory.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from aiokafka import ConsumerRecord, TopicPartition from aiokafka.abc import ConsumerRebalanceListener - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware @@ -39,7 +39,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[tuple[ConsumerRecord, ...]]"], # Specification args title_: Optional[str], @@ -65,7 +65,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[ConsumerRecord]"], # Specification args title_: Optional[str], @@ -91,7 +91,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConsumerRecord, tuple[ConsumerRecord, ...]]]" ], @@ -121,7 +121,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConsumerRecord, tuple[ConsumerRecord, ...]]]" ], diff --git a/faststream/kafka/subscriber/usecase.py b/faststream/kafka/subscriber/usecase.py index 4d12aa9abd..03dae21687 100644 --- a/faststream/kafka/subscriber/usecase.py +++ b/faststream/kafka/subscriber/usecase.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from aiokafka import AIOKafkaConsumer, ConsumerRecord from aiokafka.abc import ConsumerRebalanceListener - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict, LoggerProto from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto @@ -50,6 +50,7 @@ class LogicSubscriber(SubscriberUsecase[MsgType]): task: Optional["asyncio.Task[None]"] client_id: Optional[str] batch: bool + parser: AioKafkaParser def __init__( self, @@ -66,7 +67,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args title_: Optional[str], @@ -143,6 +144,8 @@ async def start(self) -> None: **self.__connection_args, ) + self.parser._setup(consumer) + if self.topics or self._pattern: consumer.subscribe( topics=self.topics, @@ -194,7 +197,10 @@ async def get_one( msg: StreamMessage[MsgType] = await process_msg( msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) @@ -289,7 +295,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[ConsumerRecord]"], # AsyncAPI args title_: Optional[str], @@ -306,7 +312,7 @@ def __init__( else: reg = None - parser = AioKafkaParser( + self.parser = AioKafkaParser( msg_class=KafkaAckableMessage if is_manual else KafkaMessage, regex=reg, ) @@ -319,8 +325,8 @@ def __init__( connection_args=connection_args, partitions=partitions, # subscriber args - default_parser=parser.parse_message, - default_decoder=parser.decode_message, + default_parser=self.parser.parse_message, + default_decoder=self.parser.decode_message, # Propagated args no_ack=no_ack, no_reply=no_reply, @@ -370,7 +376,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Sequence[tuple[ConsumerRecord, ...]]]" ], @@ -392,7 +398,7 @@ def __init__( else: reg = None - parser = AioKafkaBatchParser( + self.parser = AioKafkaBatchParser( msg_class=KafkaAckableMessage if is_manual else KafkaMessage, regex=reg, ) @@ -405,8 +411,8 @@ def __init__( connection_args=connection_args, partitions=partitions, # subscriber args - default_parser=parser.parse_message, - default_decoder=parser.decode_message, + default_parser=self.parser.parse_message, + default_decoder=self.parser.decode_message, # Propagated args no_ack=no_ack, no_reply=no_reply, diff --git a/faststream/middlewares/exception.py b/faststream/middlewares/exception.py index 8530bb8a77..eb18aa4f24 100644 --- a/faststream/middlewares/exception.py +++ b/faststream/middlewares/exception.py @@ -12,7 +12,6 @@ from typing_extensions import Literal, TypeAlias -from faststream._internal.context import context from faststream._internal.utils import apply_types from faststream._internal.utils.functions import sync_fake_context, to_async from faststream.exceptions import IgnoredException @@ -183,7 +182,7 @@ async def consume_scope( for handler_type, handler in self._publish_handlers: if issubclass(exc_type, handler_type): - return await handler(exc) + return await handler(exc, context__=self.context) raise @@ -199,13 +198,13 @@ async def after_processed( # TODO: remove it after context will be moved to middleware # In case parser/decoder error occurred scope: AbstractContextManager[Any] - if not context.get_local("message"): - scope = context.scope("message", self.msg) + if not self.context.get_local("message"): + scope = self.context.scope("message", self.msg) else: scope = sync_fake_context() with scope: - await handler(exc_val) + await handler(exc_val, context__=self.context) return True @@ -214,5 +213,8 @@ async def after_processed( return None -async def ignore_handler(exception: IgnoredException) -> NoReturn: +async def ignore_handler( + exception: IgnoredException, + **kwargs: Any, # suppress context +) -> NoReturn: raise exception diff --git a/faststream/middlewares/logging.py b/faststream/middlewares/logging.py index f24d507266..2c82299532 100644 --- a/faststream/middlewares/logging.py +++ b/faststream/middlewares/logging.py @@ -1,7 +1,6 @@ import logging from typing import TYPE_CHECKING, Any, Optional -from faststream._internal.context.repository import context from faststream.exceptions import IgnoredException from faststream.message.source_type import SourceType @@ -59,7 +58,7 @@ async def consume_scope( if source_type is not SourceType.Response: self.logger.log( "Received", - extra=context.get_local("log_context", {}), + extra=self.context.get_local("log_context", {}), ) return await call_next(msg) @@ -72,7 +71,7 @@ async def __aexit__( ) -> bool: """Asynchronously called after processing.""" if self._source_type is not SourceType.Response: - c = context.get_local("log_context", {}) + c = self.context.get_local("log_context", {}) if exc_type: if issubclass(exc_type, IgnoredException): diff --git a/faststream/nats/broker/broker.py b/faststream/nats/broker/broker.py index 09aed61b54..4aa6812c6a 100644 --- a/faststream/nats/broker/broker.py +++ b/faststream/nats/broker/broker.py @@ -42,12 +42,14 @@ from .logging import make_nats_logger_state from .registrator import NatsRegistrator +from .state import BrokerState, ConnectedState, EmptyBrokerState if TYPE_CHECKING: import ssl from types import TracebackType - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant + from fast_depends.library.serializer import SerializerProto from nats.aio.client import ( Callback, Credentials, @@ -56,7 +58,6 @@ SignatureCallback, ) from nats.js.api import Placement, RePublish, StorageType - from nats.js.client import JetStreamContext from nats.js.kv import KeyValue from nats.js.object_store import ObjectStore from typing_extensions import TypedDict, Unpack @@ -223,12 +224,11 @@ class NatsBroker( """A class to represent a NATS broker.""" url: list[str] - stream: Optional["JetStreamContext"] - _producer: Optional["NatsFastProducer"] - _js_producer: Optional["NatsJSFastProducer"] - _kv_declarer: Optional["KVBucketDeclarer"] - _os_declarer: Optional["OSBucketDeclarer"] + _producer: "NatsFastProducer" + _js_producer: "NatsJSFastProducer" + _kv_declarer: "KVBucketDeclarer" + _os_declarer: "OSBucketDeclarer" def __init__( self, @@ -387,7 +387,7 @@ def __init__( Doc("Custom parser object."), ] = None, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc("Dependencies to apply to all broker subscribers."), ] = (), middlewares: Annotated[ @@ -439,10 +439,7 @@ def __init__( bool, Doc("Whether to use FastDepends or not."), ] = True, - validate: Annotated[ - bool, - Doc("Whether to cast types using Pydantic validation."), - ] = True, + serializer: Optional["SerializerProto"] = EMPTY, _get_dependant: Annotated[ Optional[Callable[..., Any]], Doc("Custom library dependant generator callback."), @@ -545,19 +542,25 @@ def __init__( ), # FastDepends args apply_types=apply_types, - validate=validate, + serializer=serializer, _get_dependant=_get_dependant, _call_decorators=_call_decorators, ) - self.__is_connected = False - self._producer = None + self._producer = NatsFastProducer( + decoder=self._decoder, + parser=self._parser, + ) - # JS options - self.stream = None - self._js_producer = None - self._kv_declarer = None - self._os_declarer = None + self._js_producer = NatsJSFastProducer( + decoder=self._decoder, + parser=self._parser, + ) + + self._kv_declarer = KVBucketDeclarer() + self._os_declarer = OSBucketDeclarer() + + self._connection_state: BrokerState = EmptyBrokerState() @override async def connect( # type: ignore[override] @@ -583,25 +586,17 @@ async def connect( # type: ignore[override] return await super().connect(**connect_kwargs) async def _connect(self, **kwargs: Any) -> "Client": - self.__is_connected = True connection = await nats.connect(**kwargs) - self._producer = NatsFastProducer( - connection=connection, - decoder=self._decoder, - parser=self._parser, - ) + stream = connection.jetstream() - stream = self.stream = connection.jetstream() + self._producer.connect(connection) + self._js_producer.connect(stream) - self._js_producer = NatsJSFastProducer( - connection=stream, - decoder=self._decoder, - parser=self._parser, - ) + self._kv_declarer.connect(stream) + self._os_declarer.connect(stream) - self._kv_declarer = KVBucketDeclarer(stream) - self._os_declarer = OSBucketDeclarer(stream) + self._connection_state = ConnectedState(connection, stream) return connection @@ -617,24 +612,26 @@ async def close( await self._connection.drain() self._connection = None - self.stream = None - self._producer = None - self._js_producer = None - self.__is_connected = False + self._producer.disconnect() + self._js_producer.disconnect() + self._kv_declarer.disconnect() + self._os_declarer.disconnect() + + self._connection_state = EmptyBrokerState() async def start(self) -> None: """Connect broker to NATS cluster and startup all subscribers.""" await self.connect() self._setup() - assert self.stream, "Broker should be started already" # nosec B101 + stream_context = self._connection_state.stream for stream in filter( lambda x: x.declare, self._stream_builder.objects.values(), ): try: - await self.stream.add_stream( + await stream_context.add_stream( config=stream.config, subjects=stream.subjects, ) @@ -651,15 +648,14 @@ async def start(self) -> None: e.description == "stream name already in use with a different configuration" ): - old_config = (await self.stream.stream_info(stream.name)).config + old_config = (await stream_context.stream_info(stream.name)).config self._state.logger_state.log(str(e), logging.WARNING, log_context) - await self.stream.update_stream( - config=stream.config, - subjects=tuple( - set(old_config.subjects or ()).union(stream.subjects), - ), - ) + + for subject in old_config.subjects or (): + stream.add_subject(subject) + + await stream_context.update_stream(config=stream.config) else: # pragma: no cover self._state.logger_state.log( @@ -803,29 +799,11 @@ def setup_subscriber( # type: ignore[override] self, subscriber: "SpecificationSubscriber", ) -> None: - connection: Union[ - Client, - JetStreamContext, - KVBucketDeclarer, - OSBucketDeclarer, - None, - ] = None - - if getattr(subscriber, "kv_watch", None): - connection = self._kv_declarer - - elif getattr(subscriber, "obj_watch", None): - connection = self._os_declarer - - elif getattr(subscriber, "stream", None): - connection = self.stream - - else: - connection = self._connection - return super().setup_subscriber( subscriber, - connection=connection, + connection_state=self._connection_state, + kv_declarer=self._kv_declarer, + os_declarer=self._os_declarer, ) @override @@ -833,14 +811,7 @@ def setup_publisher( # type: ignore[override] self, publisher: "SpecificationPublisher", ) -> None: - producer: Optional[ProducerProto] = None - - if publisher.stream is not None: - if self._js_producer is not None: - producer = self._js_producer - - elif self._producer is not None: - producer = self._producer + producer = self._producer if publisher.stream is None else self._js_producer super().setup_publisher(publisher, producer=producer) @@ -861,8 +832,6 @@ async def key_value( # custom declare: bool = True, ) -> "KeyValue": - assert self._kv_declarer, "Broker should be connected already." # nosec B101 - return await self._kv_declarer.create_key_value( bucket=bucket, description=description, @@ -891,8 +860,6 @@ async def object_storage( # custom declare: bool = True, ) -> "ObjectStore": - assert self._os_declarer, "Broker should be connected already." # nosec B101 - return await self._os_declarer.create_object_store( bucket=bucket, description=description, @@ -914,14 +881,14 @@ async def wrapper(err: Exception) -> None: if error_cb is not None: await error_cb(err) - if isinstance(err, Error) and self.__is_connected: + if isinstance(err, Error) and self._connection_state: self._state.logger_state.log( f"Connection broken with {err!r}", logging.WARNING, c, exc_info=err, ) - self.__is_connected = False + self._connection_state = self._connection_state.brake() return wrapper @@ -935,9 +902,9 @@ async def wrapper() -> None: if cb is not None: await cb() - if not self.__is_connected: + if not self._connection_state: self._state.logger_state.log("Connection established", logging.INFO, c) - self.__is_connected = True + self._connection_state = self._connection_state.reconnect() return wrapper diff --git a/faststream/nats/broker/logging.py b/faststream/nats/broker/logging.py index f4e2500cdb..0238e2208d 100644 --- a/faststream/nats/broker/logging.py +++ b/faststream/nats/broker/logging.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.context import ContextRepo class NatsParamsStorage(DefaultLoggerStorage): @@ -42,7 +43,7 @@ def setup_log_contest(self, params: "AnyDict") -> None: ), ) - def get_logger(self) -> Optional["LoggerProto"]: + def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: message_id_ln = 10 # TODO: generate unique logger names to not share between brokers @@ -67,6 +68,7 @@ def get_logger(self) -> Optional["LoggerProto"]: f"%(message_id)-{message_id_ln}s - ", "%(message)s", )), + context=context, ) diff --git a/faststream/nats/broker/registrator.py b/faststream/nats/broker/registrator.py index 811f1ab847..4ff8f0fbda 100644 --- a/faststream/nats/broker/registrator.py +++ b/faststream/nats/broker/registrator.py @@ -13,7 +13,7 @@ from faststream.nats.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from nats.aio.msg import Msg from faststream._internal.types import ( @@ -141,8 +141,8 @@ def subscriber( # type: ignore[override] ] = None, # broker arguments dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -346,7 +346,7 @@ def include_router( # type: ignore[override] router: "NatsRegistrator", *, prefix: str = "", - dependencies: Iterable["Depends"] = (), + dependencies: Iterable["Dependant"] = (), middlewares: Iterable["BrokerMiddleware[Msg]"] = (), include_in_schema: Optional[bool] = None, ) -> None: diff --git a/faststream/nats/broker/state.py b/faststream/nats/broker/state.py new file mode 100644 index 0000000000..08b5821597 --- /dev/null +++ b/faststream/nats/broker/state.py @@ -0,0 +1,78 @@ +from typing import TYPE_CHECKING, Protocol + +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from nats.aio.client import Client + from nats.js import JetStreamContext + + +class BrokerState(Protocol): + stream: "JetStreamContext" + connection: "Client" + + def __bool__(self) -> bool: ... + + def brake(self) -> "BrokerState": ... + + def reconnect(self) -> "BrokerState": ... + + +class EmptyBrokerState(BrokerState): + @property + def connection(self) -> "Client": + msg = "Connection is not available yet. Please, connect the broker first." + raise IncorrectState(msg) + + @property + def stream(self) -> "JetStreamContext": + msg = "Stream is not available yet. Please, connect the broker first." + raise IncorrectState(msg) + + def brake(self) -> "BrokerState": + return self + + def reconnect(self) -> "BrokerState": + msg = "You can't reconnect an empty state. Please, connect the broker first." + raise IncorrectState(msg) + + def __bool__(self) -> bool: + return False + + +class ConnectedState(BrokerState): + def __init__( + self, + connection: "Client", + stream: "JetStreamContext", + ) -> None: + self.connection = connection + self.stream = stream + + def __bool__(self) -> bool: + return True + + def brake(self) -> "ConnectionBrokenState": + return ConnectionBrokenState( + connection=self.connection, + stream=self.stream, + ) + + +class ConnectionBrokenState(BrokerState): + def __init__( + self, + connection: "Client", + stream: "JetStreamContext", + ) -> None: + self.connection = connection + self.stream = stream + + def __bool__(self) -> bool: + return False + + def reconnect(self) -> "ConnectedState": + return ConnectedState( + connection=self.connection, + stream=self.stream, + ) diff --git a/faststream/nats/fastapi/__init__.py b/faststream/nats/fastapi/__init__.py index 18a28e4e8d..b7aa38c664 100644 --- a/faststream/nats/fastapi/__init__.py +++ b/faststream/nats/fastapi/__init__.py @@ -6,7 +6,6 @@ from faststream._internal.fastapi.context import Context, ContextRepo, Logger from faststream.nats.broker import NatsBroker as NB from faststream.nats.message import NatsMessage as NM -from faststream.nats.publisher.producer import NatsFastProducer, NatsJSFastProducer from .fastapi import NatsRouter @@ -14,8 +13,6 @@ NatsBroker = Annotated[NB, Context("broker")] Client = Annotated[NatsClient, Context("broker._connection")] JsClient = Annotated[JetStreamContext, Context("broker._stream")] -NatsProducer = Annotated[NatsFastProducer, Context("broker._producer")] -NatsJsProducer = Annotated[NatsJSFastProducer, Context("broker._js_producer")] __all__ = ( "Client", @@ -24,8 +21,6 @@ "JsClient", "Logger", "NatsBroker", - "NatsJsProducer", "NatsMessage", - "NatsProducer", "NatsRouter", ) diff --git a/faststream/nats/helpers/bucket_declarer.py b/faststream/nats/helpers/bucket_declarer.py index 2a0c3f68e4..617eb4edad 100644 --- a/faststream/nats/helpers/bucket_declarer.py +++ b/faststream/nats/helpers/bucket_declarer.py @@ -2,6 +2,8 @@ from nats.js.api import KeyValueConfig +from .state import ConnectedState, ConnectionState, EmptyConnectionState + if TYPE_CHECKING: from nats.js import JetStreamContext from nats.js.api import Placement, RePublish, StorageType @@ -11,10 +13,17 @@ class KVBucketDeclarer: buckets: dict[str, "KeyValue"] - def __init__(self, connection: "JetStreamContext") -> None: - self._connection = connection + def __init__(self) -> None: self.buckets = {} + self.__state: ConnectionState[JetStreamContext] = EmptyConnectionState() + + def connect(self, connection: "JetStreamContext") -> None: + self.__state = ConnectedState(connection) + + def disconnect(self) -> None: + self.__state = EmptyConnectionState() + async def create_key_value( self, bucket: str, @@ -34,7 +43,7 @@ async def create_key_value( ) -> "KeyValue": if (key_value := self.buckets.get(bucket)) is None: if declare: - key_value = await self._connection.create_key_value( + key_value = await self.__state.connection.create_key_value( config=KeyValueConfig( bucket=bucket, description=description, @@ -50,7 +59,7 @@ async def create_key_value( ), ) else: - key_value = await self._connection.key_value(bucket) + key_value = await self.__state.connection.key_value(bucket) self.buckets[bucket] = key_value diff --git a/faststream/nats/helpers/obj_storage_declarer.py b/faststream/nats/helpers/obj_storage_declarer.py index f137fa1586..f0f31918d7 100644 --- a/faststream/nats/helpers/obj_storage_declarer.py +++ b/faststream/nats/helpers/obj_storage_declarer.py @@ -2,6 +2,8 @@ from nats.js.api import ObjectStoreConfig +from .state import ConnectedState, ConnectionState, EmptyConnectionState + if TYPE_CHECKING: from nats.js import JetStreamContext from nats.js.api import Placement, StorageType @@ -11,10 +13,17 @@ class OSBucketDeclarer: buckets: dict[str, "ObjectStore"] - def __init__(self, connection: "JetStreamContext") -> None: - self._connection = connection + def __init__(self) -> None: self.buckets = {} + self.__state: ConnectionState[JetStreamContext] = EmptyConnectionState() + + def connect(self, connection: "JetStreamContext") -> None: + self.__state = ConnectedState(connection) + + def disconnect(self) -> None: + self.__state = EmptyConnectionState() + async def create_object_store( self, bucket: str, @@ -30,7 +39,7 @@ async def create_object_store( ) -> "ObjectStore": if (object_store := self.buckets.get(bucket)) is None: if declare: - object_store = await self._connection.create_object_store( + object_store = await self.__state.connection.create_object_store( bucket=bucket, config=ObjectStoreConfig( bucket=bucket, @@ -43,7 +52,7 @@ async def create_object_store( ), ) else: - object_store = await self._connection.object_store(bucket) + object_store = await self.__state.connection.object_store(bucket) self.buckets[bucket] = object_store diff --git a/faststream/nats/helpers/state.py b/faststream/nats/helpers/state.py new file mode 100644 index 0000000000..91c9f84ff7 --- /dev/null +++ b/faststream/nats/helpers/state.py @@ -0,0 +1,27 @@ +from typing import Protocol, TypeVar + +from nats.aio.client import Client +from nats.js import JetStreamContext + +from faststream.exceptions import IncorrectState + +ClientT = TypeVar("ClientT", Client, JetStreamContext) + + +class ConnectionState(Protocol[ClientT]): + connection: ClientT + + +class EmptyConnectionState(ConnectionState[ClientT]): + __slots__ = () + + @property + def connection(self) -> ClientT: + raise IncorrectState + + +class ConnectedState(ConnectionState[ClientT]): + __slots__ = ("connection",) + + def __init__(self, connection: ClientT) -> None: + self.connection = connection diff --git a/faststream/nats/publisher/producer.py b/faststream/nats/publisher/producer.py index 2af4fdfc48..f2ed4715e0 100644 --- a/faststream/nats/publisher/producer.py +++ b/faststream/nats/publisher/producer.py @@ -9,6 +9,11 @@ from faststream._internal.subscriber.utils import resolve_custom_func from faststream.exceptions import FeatureNotSupportedException from faststream.message import encode_message +from faststream.nats.helpers.state import ( + ConnectedState, + ConnectionState, + EmptyConnectionState, +) from faststream.nats.parser import NatsParser if TYPE_CHECKING: @@ -32,16 +37,21 @@ class NatsFastProducer(ProducerProto): def __init__( self, *, - connection: "Client", parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - self._connection = connection - default = NatsParser(pattern="", no_ack=False) self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) + self.__state: ConnectionState[Client] = EmptyConnectionState() + + def connect(self, connection: "Client") -> None: + self.__state = ConnectedState(connection) + + def disconnect(self) -> None: + self.__state = EmptyConnectionState() + @override async def publish( # type: ignore[override] self, @@ -54,7 +64,7 @@ async def publish( # type: ignore[override] **cmd.headers_to_publish(), } - await self._connection.publish( + await self.__state.connection.publish( subject=cmd.destination, payload=payload, reply=cmd.reply_to, @@ -73,7 +83,7 @@ async def request( # type: ignore[override] **cmd.headers_to_publish(), } - return await self._connection.request( + return await self.__state.connection.request( subject=cmd.destination, payload=payload, headers=headers_to_send, @@ -98,16 +108,21 @@ class NatsJSFastProducer(ProducerProto): def __init__( self, *, - connection: "JetStreamContext", parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - self._connection = connection - default = NatsParser(pattern="", no_ack=False) self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) + self.__state: ConnectionState[JetStreamContext] = EmptyConnectionState() + + def connect(self, connection: "Client") -> None: + self.__state = ConnectedState(connection) + + def disconnect(self) -> None: + self.__state = EmptyConnectionState() + @override async def publish( # type: ignore[override] self, @@ -120,7 +135,7 @@ async def publish( # type: ignore[override] **cmd.headers_to_publish(js=True), } - await self._connection.publish( + await self.__state.connection.publish( subject=cmd.destination, payload=payload, headers=headers_to_send, @@ -137,9 +152,11 @@ async def request( # type: ignore[override] ) -> "Msg": payload, content_type = encode_message(cmd.body) - reply_to = self._connection._nc.new_inbox() + reply_to = self.__state.connection._nc.new_inbox() future: asyncio.Future[Msg] = asyncio.Future() - sub = await self._connection._nc.subscribe(reply_to, future=future, max_msgs=1) + sub = await self.__state.connection._nc.subscribe( + reply_to, future=future, max_msgs=1 + ) await sub.unsubscribe(limit=1) headers_to_send = { @@ -149,7 +166,7 @@ async def request( # type: ignore[override] } with anyio.fail_after(cmd.timeout): - await self._connection.publish( + await self.__state.connection.publish( subject=cmd.destination, payload=payload, headers=headers_to_send, diff --git a/faststream/nats/router.py b/faststream/nats/router.py index 982f274b50..3b98073050 100644 --- a/faststream/nats/router.py +++ b/faststream/nats/router.py @@ -19,7 +19,7 @@ from faststream.nats.broker.registrator import NatsRegistrator if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from nats.aio.msg import Msg from faststream._internal.basic_types import SendableMessage @@ -231,8 +231,8 @@ def __init__( ] = None, # broker arguments dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -334,9 +334,9 @@ def __init__( ] = (), *, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc( - "Dependencies list (`[Depends(),]`) to apply to all routers' publishers/subscribers.", + "Dependencies list (`[Dependant(),]`) to apply to all routers' publishers/subscribers.", ), ] = (), middlewares: Annotated[ diff --git a/faststream/nats/schemas/js_stream.py b/faststream/nats/schemas/js_stream.py index da6e41f3bf..0d9b23ac4f 100644 --- a/faststream/nats/schemas/js_stream.py +++ b/faststream/nats/schemas/js_stream.py @@ -194,6 +194,7 @@ def __init__( self.subjects = subjects self.declare = declare + self.config = StreamConfig( name=name, description=description, diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index f2853f06b9..613a76a535 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -25,7 +25,7 @@ ) if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from nats.js import api from faststream._internal.basic_types import AnyDict @@ -62,7 +62,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Any]"], # Specification information title_: Optional[str], diff --git a/faststream/nats/subscriber/specified.py b/faststream/nats/subscriber/specified.py index 09320906a4..ee3bf32bdb 100644 --- a/faststream/nats/subscriber/specified.py +++ b/faststream/nats/subscriber/specified.py @@ -21,7 +21,7 @@ from faststream.specification.schema.operation import Operation -class SpecificationSubscriber(LogicSubscriber[Any, Any]): +class SpecificationSubscriber(LogicSubscriber[Any]): """A class to represent a NATS handler.""" def get_name(self) -> str: diff --git a/faststream/nats/subscriber/state.py b/faststream/nats/subscriber/state.py new file mode 100644 index 0000000000..d8e2825d83 --- /dev/null +++ b/faststream/nats/subscriber/state.py @@ -0,0 +1,60 @@ +from typing import TYPE_CHECKING, Protocol + +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from nats.aio.client import Client + from nats.js import JetStreamContext + + from faststream.nats.broker.state import BrokerState + from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer + + +class SubscriberState(Protocol): + client: "Client" + js: "JetStreamContext" + kv_declarer: "KVBucketDeclarer" + os_declarer: "OSBucketDeclarer" + + +class EmptySubscriberState(SubscriberState): + @property + def client(self) -> "Client": + msg = "Connection is not available yet. Please, setup the subscriber first." + raise IncorrectState(msg) + + @property + def js(self) -> "JetStreamContext": + msg = "Stream is not available yet. Please, setup the subscriber first." + raise IncorrectState(msg) + + @property + def kv_declarer(self) -> "KVBucketDeclarer": + msg = "KeyValue is not available yet. Please, setup the subscriber first." + raise IncorrectState(msg) + + @property + def os_declarer(self) -> "OSBucketDeclarer": + msg = "ObjectStorage is not available yet. Please, setup the subscriber first." + raise IncorrectState(msg) + + +class ConnectedSubscriberState(SubscriberState): + def __init__( + self, + *, + parent_state: "BrokerState", + kv_declarer: "KVBucketDeclarer", + os_declarer: "OSBucketDeclarer", + ) -> None: + self._parent_state = parent_state + self.kv_declarer = kv_declarer + self.os_declarer = os_declarer + + @property + def client(self) -> "Client": + return self._parent_state.connection + + @property + def js(self) -> "JetStreamContext": + return self._parent_state.stream diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index b3493d69e2..9f15f00456 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -8,23 +8,21 @@ Callable, Generic, Optional, - TypeVar, Union, cast, ) import anyio -from fast_depends.dependencies import Depends +from fast_depends.dependencies import Dependant from nats.errors import ConnectionClosedError, TimeoutError from nats.js.api import ConsumerConfig, ObjectInfo from typing_extensions import Doc, override -from faststream._internal.context.repository import context from faststream._internal.subscriber.mixins import ConcurrentMixin, TasksMixin from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import MsgType -from faststream.exceptions import NOT_CONNECTED_YET +from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer from faststream.nats.message import NatsMessage from faststream.nats.parser import ( BatchParser, @@ -40,8 +38,9 @@ Unsubscriptable, ) +from .state import ConnectedSubscriberState, EmptySubscriberState, SubscriberState + if TYPE_CHECKING: - from nats.aio.client import Client from nats.aio.msg import Msg from nats.aio.subscription import Subscription from nats.js import JetStreamContext @@ -61,21 +60,18 @@ CustomCallable, ) from faststream.message import StreamMessage + from faststream.nats.broker.state import BrokerState from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer from faststream.nats.message import NatsKvMessage, NatsObjMessage from faststream.nats.schemas import JStream, KvWatch, ObjWatch, PullSub -ConnectionType = TypeVar("ConnectionType") - - -class LogicSubscriber(SubscriberUsecase[MsgType], Generic[ConnectionType, MsgType]): +class LogicSubscriber(SubscriberUsecase[MsgType], Generic[MsgType]): """A class to represent a NATS handler.""" subscription: Optional[Unsubscriptable] _fetch_sub: Optional[Unsubscriptable] producer: Optional["ProducerProto"] - _connection: Optional[ConnectionType] def __init__( self, @@ -89,7 +85,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args title_: Optional[str], @@ -116,16 +112,19 @@ def __init__( include_in_schema=include_in_schema, ) - self._connection = None self._fetch_sub = None self.subscription = None self.producer = None + self._connection_state: SubscriberState = EmptySubscriberState() + @override def _setup( # type: ignore[override] self, *, - connection: ConnectionType, + connection_state: "BrokerState", + os_declarer: "OSBucketDeclarer", + kv_declarer: "KVBucketDeclarer", # basic args logger: Optional["LoggerProto"], producer: Optional["ProducerProto"], @@ -137,7 +136,11 @@ def _setup( # type: ignore[override] # dependant args state: "SetupState", ) -> None: - self._connection = connection + self._connection_state = ConnectedSubscriberState( + parent_state=connection_state, + os_declarer=os_declarer, + kv_declarer=kv_declarer, + ) super()._setup( logger=logger, @@ -157,12 +160,10 @@ def clear_subject(self) -> str: async def start(self) -> None: """Create NATS subscription and start consume tasks.""" - assert self._connection, NOT_CONNECTED_YET # nosec B101 - await super().start() if self.calls: - await self._create_subscription(connection=self._connection) + await self._create_subscription() async def close(self) -> None: """Clean up handler subscription, cancel consume task in graceful mode.""" @@ -177,11 +178,7 @@ async def close(self) -> None: self.subscription = None @abstractmethod - async def _create_subscription( - self, - *, - connection: ConnectionType, - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription object to consume messages.""" raise NotImplementedError @@ -227,7 +224,7 @@ def _resolved_subject_string(self) -> str: return self.subject or ", ".join(self.config.filter_subjects or ()) -class _DefaultSubscriber(LogicSubscriber[ConnectionType, MsgType]): +class _DefaultSubscriber(LogicSubscriber[MsgType]): def __init__( self, *, @@ -241,7 +238,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args title_: Optional[str], @@ -296,7 +293,7 @@ def get_log_context( ) -class CoreSubscriber(_DefaultSubscriber["Client", "Msg"]): +class CoreSubscriber(_DefaultSubscriber["Msg"]): subscription: Optional["Subscription"] _fetch_sub: Optional["Subscription"] @@ -312,7 +309,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -348,13 +345,12 @@ async def get_one( *, timeout: float = 5.0, ) -> "Optional[NatsMessage]": - assert self._connection, "Please, start() subscriber first" # nosec B101 assert ( # nosec B101 not self.calls ), "You can't use `get_one` method if subscriber has registered handlers." if self._fetch_sub is None: - fetch_sub = self._fetch_sub = await self._connection.subscribe( + fetch_sub = self._fetch_sub = await self._connection_state.client.subscribe( subject=self.clear_subject, queue=self.queue, **self.extra_options, @@ -369,23 +365,22 @@ async def get_one( msg: NatsMessage = await process_msg( # type: ignore[assignment] msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) return msg @override - async def _create_subscription( - self, - *, - connection: "Client", - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription and start consume task.""" if self.subscription: return - self.subscription = await connection.subscribe( + self.subscription = await self._connection_state.client.subscribe( subject=self.clear_subject, queue=self.queue, cb=self.consume, @@ -424,7 +419,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -451,18 +446,14 @@ def __init__( ) @override - async def _create_subscription( - self, - *, - connection: "Client", - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription and start consume task.""" if self.subscription: return self.start_consume_task() - self.subscription = await connection.subscribe( + self.subscription = await self._connection_state.client.subscribe( subject=self.clear_subject, queue=self.queue, cb=self._put_msg, @@ -470,7 +461,7 @@ async def _create_subscription( ) -class _StreamSubscriber(_DefaultSubscriber["JetStreamContext", "Msg"]): +class _StreamSubscriber(_DefaultSubscriber["Msg"]): _fetch_sub: Optional["JetStreamContext.PullSubscription"] def __init__( @@ -486,7 +477,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -538,7 +529,6 @@ async def get_one( *, timeout: float = 5, ) -> Optional["NatsMessage"]: - assert self._connection, "Please, start() subscriber first" # nosec B101 assert ( # nosec B101 not self.calls ), "You can't use `get_one` method if subscriber has registered handlers." @@ -553,7 +543,7 @@ async def get_one( if inbox_prefix := self.extra_options.get("inbox_prefix"): extra_options["inbox_prefix"] = inbox_prefix - self._fetch_sub = await self._connection.pull_subscribe( + self._fetch_sub = await self._connection_state.js.pull_subscribe( subject=self.clear_subject, config=self.config, **extra_options, @@ -571,7 +561,10 @@ async def get_one( msg: NatsMessage = await process_msg( # type: ignore[assignment] msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) @@ -582,16 +575,12 @@ class PushStreamSubscription(_StreamSubscriber): subscription: Optional["JetStreamContext.PushSubscription"] @override - async def _create_subscription( - self, - *, - connection: "JetStreamContext", - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription and start consume task.""" if self.subscription: return - self.subscription = await connection.subscribe( + self.subscription = await self._connection_state.js.subscribe( subject=self.clear_subject, queue=self.queue, cb=self.consume, @@ -620,7 +609,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -648,18 +637,14 @@ def __init__( ) @override - async def _create_subscription( - self, - *, - connection: "JetStreamContext", - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription and start consume task.""" if self.subscription: return self.start_consume_task() - self.subscription = await connection.subscribe( + self.subscription = await self._connection_state.js.subscribe( subject=self.clear_subject, queue=self.queue, cb=self._put_msg, @@ -687,7 +672,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -716,16 +701,12 @@ def __init__( ) @override - async def _create_subscription( - self, - *, - connection: "JetStreamContext", - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription and start consume task.""" if self.subscription: return - self.subscription = await connection.pull_subscribe( + self.subscription = await self._connection_state.js.pull_subscribe( subject=self.clear_subject, config=self.config, **self.extra_options, @@ -771,7 +752,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -799,18 +780,14 @@ def __init__( ) @override - async def _create_subscription( - self, - *, - connection: "JetStreamContext", - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription and start consume task.""" if self.subscription: return self.start_consume_task() - self.subscription = await connection.pull_subscribe( + self.subscription = await self._connection_state.js.pull_subscribe( subject=self.clear_subject, config=self.config, **self.extra_options, @@ -820,7 +797,7 @@ async def _create_subscription( class BatchPullStreamSubscriber( TasksMixin, - _DefaultSubscriber["JetStreamContext", list["Msg"]], + _DefaultSubscriber[list["Msg"]], ): """Batch-message consumer class.""" @@ -840,7 +817,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[list[Msg]]"], # AsyncAPI args title_: Optional[str], @@ -877,13 +854,14 @@ async def get_one( *, timeout: float = 5, ) -> Optional["NatsMessage"]: - assert self._connection, "Please, start() subscriber first" # nosec B101 assert ( # nosec B101 not self.calls ), "You can't use `get_one` method if subscriber has registered handlers." if not self._fetch_sub: - fetch_sub = self._fetch_sub = await self._connection.pull_subscribe( + fetch_sub = ( + self._fetch_sub + ) = await self._connection_state.js.pull_subscribe( subject=self.clear_subject, config=self.config, **self.extra_options, @@ -903,23 +881,22 @@ async def get_one( NatsMessage, await process_msg( msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ), ) @override - async def _create_subscription( - self, - *, - connection: "JetStreamContext", - ) -> None: + async def _create_subscription(self) -> None: """Create NATS subscription and start consume task.""" if self.subscription: return - self.subscription = await connection.pull_subscribe( + self.subscription = await self._connection_state.js.pull_subscribe( subject=self.clear_subject, config=self.config, **self.extra_options, @@ -943,7 +920,7 @@ async def _consume_pull(self) -> None: class KeyValueWatchSubscriber( TasksMixin, - LogicSubscriber["KVBucketDeclarer", "KeyValue.Entry"], + LogicSubscriber["KeyValue.Entry"], ): subscription: Optional["UnsubscribeAdapter[KeyValue.KeyWatcher]"] _fetch_sub: Optional[UnsubscribeAdapter["KeyValue.KeyWatcher"]] @@ -954,7 +931,7 @@ def __init__( subject: str, config: "ConsumerConfig", kv_watch: "KvWatch", - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[KeyValue.Entry]"], # AsyncAPI args title_: Optional[str], @@ -987,13 +964,12 @@ async def get_one( *, timeout: float = 5, ) -> Optional["NatsKvMessage"]: - assert self._connection, "Please, start() subscriber first" # nosec B101 assert ( # nosec B101 not self.calls ), "You can't use `get_one` method if subscriber has registered handlers." if not self._fetch_sub: - bucket = await self._connection.create_key_value( + bucket = await self._connection_state.kv_declarer.create_key_value( bucket=self.kv_watch.name, declare=self.kv_watch.declare, ) @@ -1014,28 +990,28 @@ async def get_one( sleep_interval = timeout / 10 with anyio.move_on_after(timeout): while ( # noqa: ASYNC110 - raw_message := await fetch_sub.obj.updates(timeout) # type: ignore[no-untyped-call] + # type: ignore[no-untyped-call] + raw_message := await fetch_sub.obj.updates(timeout) ) is None: await anyio.sleep(sleep_interval) msg: NatsKvMessage = await process_msg( msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) return msg @override - async def _create_subscription( - self, - *, - connection: "KVBucketDeclarer", - ) -> None: + async def _create_subscription(self) -> None: if self.subscription: return - bucket = await connection.create_key_value( + bucket = await self._connection_state.kv_declarer.create_key_value( bucket=self.kv_watch.name, declare=self.kv_watch.declare, ) @@ -1050,9 +1026,9 @@ async def _create_subscription( ), ) - self.add_task(self._consume_watch()) + self.add_task(self.__consume_watch()) - async def _consume_watch(self) -> None: + async def __consume_watch(self) -> None: assert self.subscription, "You should call `create_subscription` at first." # nosec B101 key_watcher = self.subscription.obj @@ -1061,7 +1037,8 @@ async def _consume_watch(self) -> None: with suppress(ConnectionClosedError, TimeoutError): message = cast( Optional["KeyValue.Entry"], - await key_watcher.updates(self.kv_watch.timeout), # type: ignore[no-untyped-call] + # type: ignore[no-untyped-call] + await key_watcher.updates(self.kv_watch.timeout), ) if message: @@ -1097,7 +1074,7 @@ def get_log_context( class ObjStoreWatchSubscriber( TasksMixin, - LogicSubscriber["OSBucketDeclarer", ObjectInfo], + LogicSubscriber[ObjectInfo], ): subscription: Optional["UnsubscribeAdapter[ObjectStore.ObjectWatcher]"] _fetch_sub: Optional[UnsubscribeAdapter["ObjectStore.ObjectWatcher"]] @@ -1108,7 +1085,7 @@ def __init__( subject: str, config: "ConsumerConfig", obj_watch: "ObjWatch", - broker_dependencies: Iterable[Depends], + broker_dependencies: Iterable[Dependant], broker_middlewares: Iterable["BrokerMiddleware[list[Msg]]"], # AsyncAPI args title_: Optional[str], @@ -1143,13 +1120,12 @@ async def get_one( *, timeout: float = 5, ) -> Optional["NatsObjMessage"]: - assert self._connection, "Please, start() subscriber first" # nosec B101 assert ( # nosec B101 not self.calls ), "You can't use `get_one` method if subscriber has registered handlers." if not self._fetch_sub: - self.bucket = await self._connection.create_object_store( + self.bucket = await self._connection_state.os_declarer.create_object_store( bucket=self.subject, declare=self.obj_watch.declare, ) @@ -1169,35 +1145,35 @@ async def get_one( sleep_interval = timeout / 10 with anyio.move_on_after(timeout): while ( # noqa: ASYNC110 - raw_message := await fetch_sub.obj.updates(timeout) # type: ignore[no-untyped-call] + # type: ignore[no-untyped-call] + raw_message := await fetch_sub.obj.updates(timeout) ) is None: await anyio.sleep(sleep_interval) msg: NatsObjMessage = await process_msg( msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) return msg @override - async def _create_subscription( - self, - *, - connection: "OSBucketDeclarer", - ) -> None: + async def _create_subscription(self) -> None: if self.subscription: return - self.bucket = await connection.create_object_store( + self.bucket = await self._connection_state.os_declarer.create_object_store( bucket=self.subject, declare=self.obj_watch.declare, ) - self.add_task(self._consume_watch()) + self.add_task(self.__consume_watch()) - async def _consume_watch(self) -> None: + async def __consume_watch(self) -> None: assert self.bucket, "You should call `create_subscription` at first." # nosec B101 # Should be created inside task to avoid nats-py lock @@ -1213,11 +1189,14 @@ async def _consume_watch(self) -> None: with suppress(TimeoutError): message = cast( Optional["ObjectInfo"], - await obj_watch.updates(self.obj_watch.timeout), # type: ignore[no-untyped-call] + # type: ignore[no-untyped-call] + await obj_watch.updates(self.obj_watch.timeout), ) if message: - with context.scope(OBJECT_STORAGE_CONTEXT_KEY, self.bucket): + with self._state.depends_params.context.scope( + OBJECT_STORAGE_CONTEXT_KEY, self.bucket + ): await self.consume(message) def _make_response_publisher( diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index 80e2b91f7e..4ee0d1602c 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -16,6 +16,7 @@ from faststream.exceptions import SubscriberNotFound from faststream.message import encode_message, gen_cor_id from faststream.nats.broker import NatsBroker +from faststream.nats.broker.state import ConnectedState from faststream.nats.parser import NatsParser from faststream.nats.publisher.producer import NatsFastProducer from faststream.nats.schemas.js_stream import is_subject_match_wildcard @@ -58,7 +59,7 @@ async def _fake_connect( # type: ignore[override] *args: Any, **kwargs: Any, ) -> AsyncMock: - broker.stream = AsyncMock() + broker._connection_state = ConnectedState(AsyncMock(), AsyncMock()) broker._js_producer = broker._producer = FakeProducer( # type: ignore[assignment] broker, ) diff --git a/faststream/prometheus/middleware.py b/faststream/prometheus/middleware.py index 3d7f256f05..54270ae6c9 100644 --- a/faststream/prometheus/middleware.py +++ b/faststream/prometheus/middleware.py @@ -16,10 +16,10 @@ if TYPE_CHECKING: from prometheus_client import CollectorRegistry + from faststream._internal.basic_types import AsyncFunc, AsyncFuncAny from faststream._internal.context.repository import ContextRepo from faststream.message.message import StreamMessage from faststream.response.response import PublishCommand - from faststream.types import AsyncFunc, AsyncFuncAny class PrometheusMiddleware: diff --git a/faststream/rabbit/broker/broker.py b/faststream/rabbit/broker/broker.py index 80b30305e6..49286ea81b 100644 --- a/faststream/rabbit/broker/broker.py +++ b/faststream/rabbit/broker/broker.py @@ -46,7 +46,8 @@ RobustQueue, ) from aio_pika.abc import DateType, HeadersType, SSLOptions, TimeoutType - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant + from fast_depends.library.serializer import SerializerProto from pamqp.common import FieldTable from yarl import URL @@ -163,7 +164,7 @@ def __init__( Doc("Custom parser object."), ] = None, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc("Dependencies to apply to all broker subscribers."), ] = (), middlewares: Annotated[ @@ -215,10 +216,7 @@ def __init__( bool, Doc("Whether to use FastDepends or not."), ] = True, - validate: Annotated[ - bool, - Doc("Whether to cast types using Pydantic validation."), - ] = True, + serializer: Optional["SerializerProto"] = EMPTY, _get_dependant: Annotated[ Optional[Callable[..., Any]], Doc("Custom library dependant generator callback."), @@ -282,7 +280,7 @@ def __init__( ), # FastDepends args apply_types=apply_types, - validate=validate, + serializer=serializer, _get_dependant=_get_dependant, _call_decorators=_call_decorators, ) diff --git a/faststream/rabbit/broker/logging.py b/faststream/rabbit/broker/logging.py index 4074d3e6df..1d1451cca2 100644 --- a/faststream/rabbit/broker/logging.py +++ b/faststream/rabbit/broker/logging.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.context import ContextRepo class RabbitParamsStorage(DefaultLoggerStorage): @@ -31,7 +32,7 @@ def setup_log_contest(self, params: "AnyDict") -> None: len(params.get("queue", "")), ) - def get_logger(self) -> "LoggerProto": + def get_logger(self, *, context: "ContextRepo") -> "LoggerProto": message_id_ln = 10 # TODO: generate unique logger names to not share between brokers @@ -50,6 +51,7 @@ def get_logger(self) -> "LoggerProto": f"%(message_id)-{message_id_ln}s " "- %(message)s" ), + context=context, ) diff --git a/faststream/rabbit/broker/registrator.py b/faststream/rabbit/broker/registrator.py index 5b6ffbb606..20893edbd0 100644 --- a/faststream/rabbit/broker/registrator.py +++ b/faststream/rabbit/broker/registrator.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from aio_pika import IncomingMessage # noqa: F401 from aio_pika.abc import DateType, HeadersType, TimeoutType - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict from faststream._internal.types import ( @@ -59,8 +59,8 @@ def subscriber( # type: ignore[override] ] = None, # broker arguments dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], diff --git a/faststream/rabbit/publisher/usecase.py b/faststream/rabbit/publisher/usecase.py index d8aafe7df8..71a357576f 100644 --- a/faststream/rabbit/publisher/usecase.py +++ b/faststream/rabbit/publisher/usecase.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: import aiormq + from faststream._internal.setup import SetupState from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.rabbit.message import RabbitMessage from faststream.rabbit.publisher.producer import AioPikaFastProducer @@ -107,6 +108,7 @@ def _setup( # type: ignore[override] producer: Optional["AioPikaFastProducer"], app_id: Optional[str], virtual_host: str, + state: "SetupState", ) -> None: if app_id: self.message_options["app_id"] = app_id @@ -114,7 +116,7 @@ def _setup( # type: ignore[override] self.virtual_host = virtual_host - super()._setup(producer=producer) + super()._setup(producer=producer, state=state) @property def routing(self) -> str: diff --git a/faststream/rabbit/router.py b/faststream/rabbit/router.py index 8106d48078..a11b5ddbb6 100644 --- a/faststream/rabbit/router.py +++ b/faststream/rabbit/router.py @@ -13,13 +13,13 @@ if TYPE_CHECKING: from aio_pika.abc import DateType, HeadersType, TimeoutType from aio_pika.message import IncomingMessage - from broker.types import PublisherMiddleware - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict from faststream._internal.types import ( BrokerMiddleware, CustomCallable, + PublisherMiddleware, SubscriberMiddleware, ) from faststream.rabbit.message import RabbitMessage @@ -214,8 +214,8 @@ def __init__( ] = None, # broker arguments dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -297,9 +297,9 @@ def __init__( ] = (), *, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc( - "Dependencies list (`[Depends(),]`) to apply to all routers' publishers/subscribers.", + "Dependencies list (`[Dependant(),]`) to apply to all routers' publishers/subscribers.", ), ] = (), middlewares: Annotated[ diff --git a/faststream/rabbit/subscriber/factory.py b/faststream/rabbit/subscriber/factory.py index 23b190b13e..b82210bd3d 100644 --- a/faststream/rabbit/subscriber/factory.py +++ b/faststream/rabbit/subscriber/factory.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from aio_pika import IncomingMessage - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware @@ -21,7 +21,7 @@ def create_subscriber( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[IncomingMessage]"], # AsyncAPI args title_: Optional[str], diff --git a/faststream/rabbit/subscriber/usecase.py b/faststream/rabbit/subscriber/usecase.py index 67566d0fb1..aeae29d970 100644 --- a/faststream/rabbit/subscriber/usecase.py +++ b/faststream/rabbit/subscriber/usecase.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from aio_pika import IncomingMessage, RobustQueue - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict, LoggerProto from faststream._internal.publisher.proto import BasePublisherProto @@ -57,7 +57,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: Union[bool, int], - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[IncomingMessage]"], # AsyncAPI args title_: Optional[str], @@ -197,7 +197,10 @@ async def get_one( msg: Optional[RabbitMessage] = await process_msg( # type: ignore[assignment] msg=raw_message, - middlewares=self._broker_middlewares, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) diff --git a/faststream/redis/broker/broker.py b/faststream/redis/broker/broker.py index 4ddcdeac05..edfc5774c6 100644 --- a/faststream/redis/broker/broker.py +++ b/faststream/redis/broker/broker.py @@ -1,6 +1,5 @@ import logging from collections.abc import Iterable, Mapping -from functools import partial from typing import ( TYPE_CHECKING, Annotated, @@ -27,7 +26,6 @@ from faststream.__about__ import __version__ from faststream._internal.broker.broker import BrokerUsecase from faststream._internal.constants import EMPTY -from faststream._internal.context.repository import context from faststream.message import gen_cor_id from faststream.redis.message import UnifyRedisDict from faststream.redis.publisher.producer import RedisFastProducer @@ -41,13 +39,13 @@ if TYPE_CHECKING: from types import TracebackType - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant + from fast_depends.library.serializer import SerializerProto from redis.asyncio.connection import BaseParser from typing_extensions import TypedDict, Unpack from faststream._internal.basic_types import ( AnyDict, - AsyncFunc, Decorator, LoggerProto, SendableMessage, @@ -133,7 +131,7 @@ def __init__( Doc("Custom parser object."), ] = None, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc("Dependencies to apply to all broker subscribers."), ] = (), middlewares: Annotated[ @@ -185,10 +183,7 @@ def __init__( bool, Doc("Whether to use FastDepends or not."), ] = True, - validate: Annotated[ - bool, - Doc("Whether to cast types using Pydantic validation."), - ] = True, + serializer: Optional["SerializerProto"] = EMPTY, _get_dependant: Annotated[ Optional[Callable[..., Any]], Doc("Custom library dependant generator callback."), @@ -250,7 +245,7 @@ def __init__( ), # FastDepends args apply_types=apply_types, - validate=validate, + serializer=serializer, _get_dependant=_get_dependant, _call_decorators=_call_decorators, ) @@ -490,11 +485,6 @@ async def publish_batch( _publish_type=PublishType.Publish, ) - call: AsyncFunc = self._producer.publish_batch - - for m in self._middlewares: - call = partial(m(None, context=context).publish_scope, call) - await self._basic_publish_batch(cmd, producer=self._producer) @override diff --git a/faststream/redis/broker/logging.py b/faststream/redis/broker/logging.py index fba197c5da..3855efbdf8 100644 --- a/faststream/redis/broker/logging.py +++ b/faststream/redis/broker/logging.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.context import ContextRepo class RedisParamsStorage(DefaultLoggerStorage): @@ -28,7 +29,7 @@ def setup_log_contest(self, params: "AnyDict") -> None: ), ) - def get_logger(self) -> Optional["LoggerProto"]: + def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: message_id_ln = 10 # TODO: generate unique logger names to not share between brokers @@ -45,6 +46,7 @@ def get_logger(self) -> Optional["LoggerProto"]: f"%(message_id)-{message_id_ln}s " "- %(message)s" ), + context=context, ) diff --git a/faststream/redis/broker/registrator.py b/faststream/redis/broker/registrator.py index 9f852284d3..9b10aae10e 100644 --- a/faststream/redis/broker/registrator.py +++ b/faststream/redis/broker/registrator.py @@ -11,7 +11,7 @@ from faststream.redis.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict from faststream._internal.types import ( @@ -48,8 +48,8 @@ def subscriber( # type: ignore[override] ] = None, # broker arguments dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], diff --git a/faststream/redis/router.py b/faststream/redis/router.py index 4e651c78eb..f7e60051fe 100644 --- a/faststream/redis/router.py +++ b/faststream/redis/router.py @@ -12,7 +12,7 @@ from faststream.redis.message import BaseMessage if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict, SendableMessage from faststream._internal.types import ( @@ -130,8 +130,8 @@ def __init__( ] = None, # broker arguments dependencies: Annotated[ - Iterable["Depends"], - Doc("Dependencies list (`[Depends(),]`) to apply to the subscriber."), + Iterable["Dependant"], + Doc("Dependencies list (`[Dependant(),]`) to apply to the subscriber."), ] = (), parser: Annotated[ Optional["CustomCallable"], @@ -215,9 +215,9 @@ def __init__( ] = (), *, dependencies: Annotated[ - Iterable["Depends"], + Iterable["Dependant"], Doc( - "Dependencies list (`[Depends(),]`) to apply to all routers' publishers/subscribers.", + "Dependencies list (`[Dependant(),]`) to apply to all routers' publishers/subscribers.", ), ] = (), middlewares: Annotated[ diff --git a/faststream/redis/subscriber/factory.py b/faststream/redis/subscriber/factory.py index 248ce141cf..9238628332 100644 --- a/faststream/redis/subscriber/factory.py +++ b/faststream/redis/subscriber/factory.py @@ -15,7 +15,7 @@ ) if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.types import BrokerMiddleware from faststream.redis.message import UnifyRedisDict @@ -38,7 +38,7 @@ def create_subscriber( no_ack: bool = False, no_reply: bool = False, retry: bool = False, - broker_dependencies: Iterable["Depends"] = (), + broker_dependencies: Iterable["Dependant"] = (), broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"] = (), # AsyncAPI args title_: Optional[str] = None, diff --git a/faststream/redis/subscriber/usecase.py b/faststream/redis/subscriber/usecase.py index 769aaf34e5..70ccaf73e9 100644 --- a/faststream/redis/subscriber/usecase.py +++ b/faststream/redis/subscriber/usecase.py @@ -43,7 +43,7 @@ from faststream.redis.schemas import ListSub, PubSub, StreamSub if TYPE_CHECKING: - from fast_depends.dependencies import Depends + from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AnyDict, LoggerProto from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto @@ -74,7 +74,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], @@ -218,7 +218,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], @@ -290,15 +290,18 @@ async def get_one( # type: ignore[override] sleep_interval = timeout / 10 - message: Optional[PubSubMessage] = None + raw_message: Optional[PubSubMessage] = None with anyio.move_on_after(timeout): - while (message := await self._get_message(self.subscription)) is None: # noqa: ASYNC110 + while (raw_message := await self._get_message(self.subscription)) is None: # noqa: ASYNC110 await anyio.sleep(sleep_interval) msg: Optional[RedisMessage] = await process_msg( # type: ignore[assignment] - msg=message, - middlewares=self._broker_middlewares, # type: ignore[arg-type] + msg=raw_message, + middlewares=( + m(raw_message, context=self._state.depends_params.context) + for m in self._broker_middlewares + ), parser=self._parser, decoder=self._decoder, ) @@ -341,7 +344,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], @@ -416,13 +419,18 @@ async def get_one( # type: ignore[override] if not raw_message: return None + redis_incoming_msg = DefaultListMessage( + type="list", + data=raw_message, + channel=self.list_sub.name, + ) + msg: RedisListMessage = await process_msg( # type: ignore[assignment] - msg=DefaultListMessage( - type="list", - data=raw_message, - channel=self.list_sub.name, + msg=redis_incoming_msg, + middlewares=( + m(redis_incoming_msg, context=self._state.depends_params.context) + for m in self._broker_middlewares ), - middlewares=self._broker_middlewares, # type: ignore[arg-type] parser=self._parser, decoder=self._decoder, ) @@ -443,7 +451,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], @@ -492,7 +500,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], @@ -546,7 +554,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], @@ -704,14 +712,19 @@ async def get_one( # type: ignore[override] self.last_id = message_id.decode() + redis_incoming_msg = DefaultStreamMessage( + type="stream", + channel=stream_name.decode(), + message_ids=[message_id], + data=raw_message, + ) + msg: RedisStreamMessage = await process_msg( # type: ignore[assignment] - msg=DefaultStreamMessage( - type="stream", - channel=stream_name.decode(), - message_ids=[message_id], - data=raw_message, + msg=redis_incoming_msg, + middlewares=( + m(redis_incoming_msg, context=self._state.depends_params.context) + for m in self._broker_middlewares ), - middlewares=self._broker_middlewares, # type: ignore[arg-type] parser=self._parser, decoder=self._decoder, ) @@ -732,7 +745,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], @@ -801,7 +814,7 @@ def __init__( no_ack: bool, no_reply: bool, retry: bool, - broker_dependencies: Iterable["Depends"], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args title_: Optional[str], diff --git a/faststream/specification/asyncapi/factory.py b/faststream/specification/asyncapi/factory.py index 720a7ebea4..7b537be3c8 100644 --- a/faststream/specification/asyncapi/factory.py +++ b/faststream/specification/asyncapi/factory.py @@ -3,9 +3,6 @@ from faststream.specification.base.specification import Specification -from .v2_6_0.facade import AsyncAPI2 -from .v3_0_0.facade import AsyncAPI3 - if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, AnyHttpUrl from faststream._internal.broker.broker import BrokerUsecase @@ -34,6 +31,8 @@ def __new__( # type: ignore[misc] identifier: Optional[str] = None, ) -> Specification: if schema_version.startswith("3.0."): + from .v3_0_0.facade import AsyncAPI3 + return AsyncAPI3( broker, title=title, @@ -48,6 +47,8 @@ def __new__( # type: ignore[misc] external_docs=external_docs, ) if schema_version.startswith("2.6."): + from .v2_6_0.facade import AsyncAPI2 + return AsyncAPI2( broker, title=title, diff --git a/faststream/specification/asyncapi/message.py b/faststream/specification/asyncapi/message.py index d105200f6f..187cc70af0 100644 --- a/faststream/specification/asyncapi/message.py +++ b/faststream/specification/asyncapi/message.py @@ -1,6 +1,6 @@ from collections.abc import Sequence from inspect import isclass -from typing import TYPE_CHECKING, Any, Optional, overload +from typing import TYPE_CHECKING, Optional, overload from pydantic import BaseModel, create_model @@ -16,15 +16,15 @@ from fast_depends.core import CallModel -def parse_handler_params(call: "CallModel[Any, Any]", prefix: str = "") -> AnyDict: +def parse_handler_params(call: "CallModel", prefix: str = "") -> AnyDict: """Parses the handler parameters.""" - model = call.model + model = getattr(call, "serializer", call).model assert model # nosec B101 body = get_model_schema( create_model( # type: ignore[call-overload] model.__name__, - **call.flat_params, + **{p.field_name: (p.field_type, p.default_value) for p in call.flat_params}, ), prefix=prefix, exclude=tuple(call.custom_fields.keys()), @@ -41,11 +41,11 @@ def get_response_schema(call: None, prefix: str = "") -> None: ... @overload -def get_response_schema(call: "CallModel[Any, Any]", prefix: str = "") -> AnyDict: ... +def get_response_schema(call: "CallModel", prefix: str = "") -> AnyDict: ... def get_response_schema( - call: Optional["CallModel[Any, Any]"], + call: Optional["CallModel"], prefix: str = "", ) -> Optional[AnyDict]: """Get the response schema for a given call.""" diff --git a/pyproject.toml b/pyproject.toml index 1a600d1dd9..4cfce9283a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dynamic = ["version"] dependencies = [ "anyio>=3.7.1,<5", - "fast-depends>=2.4.0b0,<3.0.0", + "fast-depends[pydantic]>=3.0.0a2,<4.0.0", "typing-extensions>=4.8.0", ] diff --git a/tests/a_docs/getting_started/cli/confluent/test_confluent_context.py b/tests/a_docs/getting_started/cli/confluent/test_confluent_context.py index cb6aa4d83e..3615e32d80 100644 --- a/tests/a_docs/getting_started/cli/confluent/test_confluent_context.py +++ b/tests/a_docs/getting_started/cli/confluent/test_confluent_context.py @@ -1,6 +1,6 @@ import pytest -from faststream import TestApp, context +from faststream import TestApp from faststream.confluent import TestKafkaBroker from tests.marks import pydantic_v2 from tests.mocks import mock_pydantic_settings_env @@ -13,4 +13,4 @@ async def test() -> None: from docs.docs_src.getting_started.cli.confluent_context import app, broker async with TestKafkaBroker(broker), TestApp(app, {"env": ""}): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" diff --git a/tests/a_docs/getting_started/cli/kafka/test_kafka_context.py b/tests/a_docs/getting_started/cli/kafka/test_kafka_context.py index a3d1d24557..9b26e90f34 100644 --- a/tests/a_docs/getting_started/cli/kafka/test_kafka_context.py +++ b/tests/a_docs/getting_started/cli/kafka/test_kafka_context.py @@ -1,6 +1,6 @@ import pytest -from faststream import TestApp, context +from faststream import TestApp from faststream.kafka import TestKafkaBroker from tests.marks import pydantic_v2 from tests.mocks import mock_pydantic_settings_env @@ -13,4 +13,4 @@ async def test() -> None: from docs.docs_src.getting_started.cli.kafka_context import app, broker async with TestKafkaBroker(broker), TestApp(app, {"env": ""}): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" diff --git a/tests/a_docs/getting_started/cli/nats/test_nats_context.py b/tests/a_docs/getting_started/cli/nats/test_nats_context.py index f562be059b..fcb8ed5bb9 100644 --- a/tests/a_docs/getting_started/cli/nats/test_nats_context.py +++ b/tests/a_docs/getting_started/cli/nats/test_nats_context.py @@ -1,6 +1,6 @@ import pytest -from faststream import TestApp, context +from faststream import TestApp from faststream.nats import TestNatsBroker from tests.marks import pydantic_v2 from tests.mocks import mock_pydantic_settings_env @@ -13,4 +13,4 @@ async def test() -> None: from docs.docs_src.getting_started.cli.nats_context import app, broker async with TestNatsBroker(broker), TestApp(app, {"env": ""}): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" diff --git a/tests/a_docs/getting_started/cli/rabbit/test_rabbit_context.py b/tests/a_docs/getting_started/cli/rabbit/test_rabbit_context.py index 99d8cb9951..2fef4df0cd 100644 --- a/tests/a_docs/getting_started/cli/rabbit/test_rabbit_context.py +++ b/tests/a_docs/getting_started/cli/rabbit/test_rabbit_context.py @@ -1,6 +1,6 @@ import pytest -from faststream import TestApp, context +from faststream import TestApp from faststream.rabbit import TestRabbitBroker from tests.marks import pydantic_v2 from tests.mocks import mock_pydantic_settings_env @@ -16,6 +16,6 @@ async def test() -> None: async with TestRabbitBroker(broker), TestApp(app, {"env": ".env"}): assert ( - context.get("settings").host + app.context.get("settings").host == "amqp://guest:guest@localhost:5673/" # pragma: allowlist secret ) diff --git a/tests/a_docs/getting_started/cli/redis/test_redis_context.py b/tests/a_docs/getting_started/cli/redis/test_redis_context.py index 283934f6f3..07536cbfdc 100644 --- a/tests/a_docs/getting_started/cli/redis/test_redis_context.py +++ b/tests/a_docs/getting_started/cli/redis/test_redis_context.py @@ -1,6 +1,6 @@ import pytest -from faststream import TestApp, context +from faststream import TestApp from faststream.redis import TestRedisBroker from tests.marks import pydantic_v2 from tests.mocks import mock_pydantic_settings_env @@ -13,4 +13,4 @@ async def test() -> None: from docs.docs_src.getting_started.cli.redis_context import app, broker async with TestRedisBroker(broker), TestApp(app, {"env": ".env"}): - assert context.get("settings").host == "redis://localhost:6380" + assert app.context.get("settings").host == "redis://localhost:6380" diff --git a/tests/a_docs/getting_started/context/test_initial.py b/tests/a_docs/getting_started/context/test_initial.py index 8291ffca80..7b973b8dfc 100644 --- a/tests/a_docs/getting_started/context/test_initial.py +++ b/tests/a_docs/getting_started/context/test_initial.py @@ -1,6 +1,5 @@ import pytest -from faststream import context from tests.marks import ( python39, require_aiokafka, @@ -22,8 +21,8 @@ async def test_kafka() -> None: await br.publish("", "test-topic") await br.publish("", "test-topic") - assert context.get("collector") == ["", ""] - context.clear() + assert broker.context.get("collector") == ["", ""] + broker.context.clear() @pytest.mark.asyncio() @@ -37,8 +36,8 @@ async def test_confluent() -> None: await br.publish("", "test-topic") await br.publish("", "test-topic") - assert context.get("collector") == ["", ""] - context.clear() + assert broker.context.get("collector") == ["", ""] + broker.context.clear() @pytest.mark.asyncio() @@ -52,8 +51,8 @@ async def test_rabbit() -> None: await br.publish("", "test-queue") await br.publish("", "test-queue") - assert context.get("collector") == ["", ""] - context.clear() + assert broker.context.get("collector") == ["", ""] + broker.context.clear() @pytest.mark.asyncio() @@ -67,8 +66,8 @@ async def test_nats() -> None: await br.publish("", "test-subject") await br.publish("", "test-subject") - assert context.get("collector") == ["", ""] - context.clear() + assert broker.context.get("collector") == ["", ""] + broker.context.clear() @pytest.mark.asyncio() @@ -82,5 +81,5 @@ async def test_redis() -> None: await br.publish("", "test-channel") await br.publish("", "test-channel") - assert context.get("collector") == ["", ""] - context.clear() + assert broker.context.get("collector") == ["", ""] + broker.context.clear() diff --git a/tests/a_docs/getting_started/lifespan/test_basic.py b/tests/a_docs/getting_started/lifespan/test_basic.py index 6b4e98cbc9..97706e94ab 100644 --- a/tests/a_docs/getting_started/lifespan/test_basic.py +++ b/tests/a_docs/getting_started/lifespan/test_basic.py @@ -1,6 +1,6 @@ import pytest -from faststream import TestApp, context +from faststream import TestApp from tests.marks import ( pydantic_v2, require_aiokafka, @@ -22,7 +22,7 @@ async def test_rabbit_basic_lifespan() -> None: from docs.docs_src.getting_started.lifespan.rabbit.basic import app, broker async with TestRabbitBroker(broker), TestApp(app): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" @pydantic_v2 @@ -35,7 +35,7 @@ async def test_kafka_basic_lifespan() -> None: from docs.docs_src.getting_started.lifespan.kafka.basic import app, broker async with TestKafkaBroker(broker), TestApp(app): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" @pydantic_v2 @@ -48,7 +48,7 @@ async def test_confluent_basic_lifespan() -> None: from docs.docs_src.getting_started.lifespan.confluent.basic import app, broker async with TestConfluentKafkaBroker(broker), TestApp(app): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" @pydantic_v2 @@ -61,7 +61,7 @@ async def test_nats_basic_lifespan() -> None: from docs.docs_src.getting_started.lifespan.nats.basic import app, broker async with TestNatsBroker(broker), TestApp(app): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" @pydantic_v2 @@ -74,4 +74,4 @@ async def test_redis_basic_lifespan() -> None: from docs.docs_src.getting_started.lifespan.redis.basic import app, broker async with TestRedisBroker(broker), TestApp(app): - assert context.get("settings").host == "localhost" + assert app.context.get("settings").host == "localhost" diff --git a/tests/a_docs/getting_started/lifespan/test_multi.py b/tests/a_docs/getting_started/lifespan/test_multi.py index 02bbfedfae..8d4b0e2a98 100644 --- a/tests/a_docs/getting_started/lifespan/test_multi.py +++ b/tests/a_docs/getting_started/lifespan/test_multi.py @@ -1,6 +1,6 @@ import pytest -from faststream import TestApp, context +from faststream import TestApp @pytest.mark.asyncio() @@ -8,4 +8,4 @@ async def test_multi_lifespan() -> None: from docs.docs_src.getting_started.lifespan.multiple import app async with TestApp(app): - assert context.get("field") == 1 + assert app.context.get("field") == 1 diff --git a/tests/a_docs/getting_started/subscription/test_annotated.py b/tests/a_docs/getting_started/subscription/test_annotated.py index 07c2e841a2..0c9d24a927 100644 --- a/tests/a_docs/getting_started/subscription/test_annotated.py +++ b/tests/a_docs/getting_started/subscription/test_annotated.py @@ -1,7 +1,7 @@ from typing import Any import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from typing_extensions import TypeAlias from faststream._internal.broker.broker import BrokerUsecase diff --git a/tests/asgi/testcase.py b/tests/asgi/testcase.py index 47fdafe0e4..20f4ed6fc7 100644 --- a/tests/asgi/testcase.py +++ b/tests/asgi/testcase.py @@ -1,4 +1,5 @@ from typing import Any +from unittest.mock import AsyncMock import pytest from starlette.testclient import TestClient @@ -22,14 +23,14 @@ def get_test_broker(self, broker) -> Any: raise NotImplementedError def test_not_found(self) -> None: - app = AsgiFastStream() + app = AsgiFastStream(AsyncMock()) with TestClient(app) as client: response = client.get("/") assert response.status_code == 404 def test_ws_not_found(self) -> None: - app = AsgiFastStream() + app = AsgiFastStream(AsyncMock()) with TestClient(app) as client: # noqa: SIM117 with pytest.raises(WebSocketDisconnect): @@ -40,6 +41,7 @@ def test_asgi_ping_unhealthy(self) -> None: broker = self.get_broker() app = AsgiFastStream( + AsyncMock(), asgi_routes=[ ("/health", make_ping_asgi(broker, timeout=5.0)), ], @@ -68,7 +70,8 @@ async def test_asyncapi_asgi(self) -> None: broker = self.get_broker() app = AsgiFastStream( - broker, asgi_routes=[("/docs", make_asyncapi_asgi(AsyncAPI(broker)))] + broker, + asgi_routes=[("/docs", make_asyncapi_asgi(AsyncAPI(broker)))], ) async with self.get_test_broker(broker): @@ -82,7 +85,7 @@ def test_get_decorator(self) -> None: async def some_handler(scope) -> AsgiResponse: return AsgiResponse(body=b"test", status_code=200) - app = AsgiFastStream(asgi_routes=[("/test", some_handler)]) + app = AsgiFastStream(AsyncMock(), asgi_routes=[("/test", some_handler)]) with TestClient(app) as client: response = client.get("/test") diff --git a/tests/brokers/base/consume.py b/tests/brokers/base/consume.py index 9f0acf2806..62d68072ca 100644 --- a/tests/brokers/base/consume.py +++ b/tests/brokers/base/consume.py @@ -205,7 +205,7 @@ async def test_consume_validate_false( ) -> None: consume_broker = self.get_broker( apply_types=True, - validate=False, + serializer=None, ) class Foo(BaseModel): diff --git a/tests/brokers/base/fastapi.py b/tests/brokers/base/fastapi.py index 0523511cf1..53725c23cb 100644 --- a/tests/brokers/base/fastapi.py +++ b/tests/brokers/base/fastapi.py @@ -8,7 +8,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.testclient import TestClient -from faststream import Response, context +from faststream import Response from faststream._internal.broker.broker import BrokerUsecase from faststream._internal.broker.router import BrokerRouter from faststream._internal.fastapi.context import Context @@ -79,6 +79,10 @@ async def hello(msg, tasks: BackgroundTasks) -> None: async def test_context(self, mock: Mock, queue: str, event: asyncio.Event) -> None: router = self.router_class() + context = router.context + from loguru import logger + + logger.debug(context) context_key = "message.headers" @@ -86,14 +90,19 @@ async def test_context(self, mock: Mock, queue: str, event: asyncio.Event) -> No @router.subscriber(*args, **kwargs) async def hello(msg=Context(context_key)): - event.set() - return mock(msg == context.resolve(context_key)) + try: + mock(msg == context.resolve(context_key) and msg["1"] == "1") + finally: + event.set() + router._setup() async with router.broker: await router.broker.start() await asyncio.wait( ( - asyncio.create_task(router.broker.publish("", queue)), + asyncio.create_task( + router.broker.publish("", queue, headers={"1": "1"}) + ), asyncio.create_task(event.wait()), ), timeout=self.timeout, @@ -104,6 +113,7 @@ async def hello(msg=Context(context_key)): async def test_initial_context(self, queue: str, event: asyncio.Event) -> None: router = self.router_class() + context = router.context args, kwargs = self.get_subscriber_params(queue) @@ -113,6 +123,7 @@ async def hello(msg: int, data=Context(queue, initial=set)) -> None: if len(data) == 2: event.set() + router._setup() async with router.broker: await router.broker.start() await asyncio.wait( diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 8398f4a286..21900fdd09 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -13,12 +13,7 @@ def broker(): @pytest.fixture() def app_without_logger(broker): - return FastStream(broker, None) - - -@pytest.fixture() -def app_without_broker(): - return FastStream() + return FastStream(broker, logger=None) @pytest.fixture() diff --git a/tests/cli/rabbit/test_app.py b/tests/cli/rabbit/test_app.py index 2dcfbc29ea..c21e26d996 100644 --- a/tests/cli/rabbit/test_app.py +++ b/tests/cli/rabbit/test_app.py @@ -17,20 +17,6 @@ def test_init(app: FastStream, broker) -> None: assert app.logger is logger -def test_init_without_broker(app_without_broker: FastStream) -> None: - assert app_without_broker.broker is None - - -def test_init_without_logger(app_without_logger: FastStream) -> None: - assert app_without_logger.logger is None - - -def test_set_broker(broker, app_without_broker: FastStream) -> None: - assert app_without_broker.broker is None - app_without_broker.set_broker(broker) - assert app_without_broker.broker is broker - - def test_log(app: FastStream, app_without_logger: FastStream) -> None: app._log(logging.INFO, "test") app_without_logger._log(logging.INFO, "test") @@ -46,7 +32,7 @@ async def call2() -> None: await async_mock.call_start2() assert mock.call_start1.call_count == 1 - test_app = FastStream(on_startup=[call1, call2]) + test_app = FastStream(AsyncMock(), on_startup=[call1, call2]) await test_app.start() @@ -56,7 +42,9 @@ async def call2() -> None: @pytest.mark.asyncio() async def test_startup_calls_lifespans( - mock: Mock, app_without_broker: FastStream + mock: Mock, + app: FastStream, + async_mock: AsyncMock, ) -> None: def call1() -> None: mock.call_start1() @@ -66,10 +54,11 @@ def call2() -> None: mock.call_start2() assert mock.call_start1.call_count == 1 - app_without_broker.on_startup(call1) - app_without_broker.on_startup(call2) + app.on_startup(call1) + app.on_startup(call2) - await app_without_broker.start() + with patch.object(app.broker, "start", async_mock): + await app.start() mock.call_start1.assert_called_once() mock.call_start2.assert_called_once() @@ -85,7 +74,7 @@ async def call2() -> None: await async_mock.call_stop2() assert mock.call_stop1.call_count == 1 - test_app = FastStream(on_shutdown=[call1, call2]) + test_app = FastStream(AsyncMock(), on_shutdown=[call1, call2]) await test_app.stop() @@ -94,9 +83,9 @@ async def call2() -> None: @pytest.mark.asyncio() -async def test_shutdown_calls_lifespans( - mock: Mock, app_without_broker: FastStream -) -> None: +async def test_shutdown_calls_lifespans(mock: Mock) -> None: + app = FastStream(AsyncMock()) + def call1() -> None: mock.call_stop1() assert not mock.call_stop2.called @@ -105,10 +94,10 @@ def call2() -> None: mock.call_stop2() assert mock.call_stop1.call_count == 1 - app_without_broker.on_shutdown(call1) - app_without_broker.on_shutdown(call2) + app.on_shutdown(call1) + app.on_shutdown(call2) - await app_without_broker.stop() + await app.stop() mock.call_stop1.assert_called_once() mock.call_stop2.assert_called_once() @@ -126,14 +115,7 @@ async def call2() -> None: test_app = FastStream(broker, after_startup=[call1, call2]) - with ( - patch.object(test_app.broker, "start", async_mock.broker_start), - patch.object( - test_app.broker, - "connect", - async_mock.broker_connect, - ), - ): + with patch.object(test_app.broker, "start", async_mock.broker_start): await test_app.start() mock.after_startup1.assert_called_once() @@ -227,16 +209,10 @@ async def test_running(async_mock: AsyncMock, app: FastStream) -> None: with ( patch.object(app.broker, "start", async_mock.broker_run), - patch.object( - app.broker, - "connect", - async_mock.broker_connect, - ), patch.object(app.broker, "close", async_mock.broker_stopped), ): await app.run() - async_mock.broker_connect.assert_called_once() async_mock.broker_run.assert_called_once() async_mock.broker_stopped.assert_called_once() @@ -265,23 +241,13 @@ async def lifespan(env: str): yield mock.off() - app = FastStream(app.broker, lifespan=lifespan) + app = FastStream(async_mock, lifespan=lifespan) app.exit() - with ( - patch.object(app.broker, "start", async_mock.broker_run), - patch.object( - app.broker, - "connect", - async_mock.broker_connect, - ), - patch.object(app.broker, "close", async_mock.broker_stopped), - ): - await app.run(run_extra_options={"env": "test"}) + await app.run(run_extra_options={"env": "test"}) - async_mock.broker_connect.assert_called_once() - async_mock.broker_run.assert_called_once() - async_mock.broker_stopped.assert_called_once() + async_mock.start.assert_called_once() + async_mock.close.assert_called_once() mock.on.assert_called_once_with("test") mock.off.assert_called_once() @@ -289,7 +255,7 @@ async def lifespan(env: str): @pytest.mark.asyncio() async def test_test_app(mock: Mock) -> None: - app = FastStream() + app = FastStream(AsyncMock()) app.on_startup(mock.on) app.on_shutdown(mock.off) @@ -303,7 +269,7 @@ async def test_test_app(mock: Mock) -> None: @pytest.mark.asyncio() async def test_test_app_with_excp(mock: Mock) -> None: - app = FastStream() + app = FastStream(AsyncMock()) app.on_startup(mock.on) app.on_shutdown(mock.off) @@ -317,7 +283,7 @@ async def test_test_app_with_excp(mock: Mock) -> None: def test_sync_test_app(mock: Mock) -> None: - app = FastStream() + app = FastStream(AsyncMock()) app.on_startup(mock.on) app.on_shutdown(mock.off) @@ -330,7 +296,7 @@ def test_sync_test_app(mock: Mock) -> None: def test_sync_test_app_with_excp(mock: Mock) -> None: - app = FastStream() + app = FastStream(AsyncMock()) app.on_startup(mock.on) app.on_shutdown(mock.off) @@ -354,11 +320,6 @@ async def lifespan(env: str): with ( patch.object(app.broker, "start", async_mock.broker_run), - patch.object( - app.broker, - "connect", - async_mock.broker_connect, - ), patch.object(app.broker, "close", async_mock.broker_stopped), ): async with TestApp(app, {"env": "test"}): @@ -367,7 +328,6 @@ async def lifespan(env: str): async_mock.on.assert_awaited_once_with("test") async_mock.off.assert_awaited_once() async_mock.broker_run.assert_called_once() - async_mock.broker_connect.assert_called_once() async_mock.broker_stopped.assert_called_once() @@ -387,7 +347,6 @@ async def lifespan(env: str): "close", async_mock.broker_stopped, ), - patch.object(app.broker, "connect", async_mock.broker_connect), TestApp( app, {"env": "test"}, @@ -398,7 +357,6 @@ async def lifespan(env: str): async_mock.on.assert_awaited_once_with("test") async_mock.off.assert_awaited_once() async_mock.broker_run.assert_called_once() - async_mock.broker_connect.assert_called_once() async_mock.broker_stopped.assert_called_once() @@ -407,11 +365,6 @@ async def lifespan(env: str): async def test_stop_with_sigint(async_mock, app: FastStream) -> None: with ( patch.object(app.broker, "start", async_mock.broker_run_sigint), - patch.object( - app.broker, - "connect", - async_mock.broker_connect, - ), patch.object(app.broker, "close", async_mock.broker_stopped_sigint), ): async with anyio.create_task_group() as tg: @@ -419,7 +372,6 @@ async def test_stop_with_sigint(async_mock, app: FastStream) -> None: tg.start_soon(_kill, signal.SIGINT) async_mock.broker_run_sigint.assert_called_once() - async_mock.broker_connect.assert_called_once() async_mock.broker_stopped_sigint.assert_called_once() @@ -428,11 +380,6 @@ async def test_stop_with_sigint(async_mock, app: FastStream) -> None: async def test_stop_with_sigterm(async_mock, app: FastStream) -> None: with ( patch.object(app.broker, "start", async_mock.broker_run_sigterm), - patch.object( - app.broker, - "connect", - async_mock.broker_connect, - ), patch.object(app.broker, "close", async_mock.broker_stopped_sigterm), ): async with anyio.create_task_group() as tg: @@ -440,7 +387,6 @@ async def test_stop_with_sigterm(async_mock, app: FastStream) -> None: tg.start_soon(_kill, signal.SIGTERM) async_mock.broker_run_sigterm.assert_called_once() - async_mock.broker_connect.assert_called_once() async_mock.broker_stopped_sigterm.assert_called_once() @@ -460,11 +406,6 @@ async def test_run_asgi(async_mock: AsyncMock, app: FastStream) -> None: with ( patch.object(app.broker, "start", async_mock.broker_run), - patch.object( - app.broker, - "connect", - async_mock.broker_connect, - ), patch.object(app.broker, "close", async_mock.broker_stopped), ): async with anyio.create_task_group() as tg: @@ -472,7 +413,6 @@ async def test_run_asgi(async_mock: AsyncMock, app: FastStream) -> None: tg.start_soon(_kill, signal.SIGINT) async_mock.broker_run.assert_called_once() - async_mock.broker_connect.assert_called_once() async_mock.broker_stopped.assert_called_once() diff --git a/tests/cli/rabbit/test_logs.py b/tests/cli/rabbit/test_logs.py index 50a6187911..d9abbcd0cc 100644 --- a/tests/cli/rabbit/test_logs.py +++ b/tests/cli/rabbit/test_logs.py @@ -29,11 +29,6 @@ def test_set_level(level, app: FastStream) -> None: @pytest.mark.parametrize( ("level", "app"), ( - pytest.param( - logging.CRITICAL, - FastStream(), - id="empty app", - ), pytest.param( logging.CRITICAL, FastStream(RabbitBroker(), logger=None), @@ -56,8 +51,8 @@ def test_set_level_to_none(level, app: FastStream) -> None: set_log_level(get_log_level(level), app) -def test_set_default() -> None: - app = FastStream() +def test_set_default(broker) -> None: + app = FastStream(broker) level = "wrong_level" set_log_level(get_log_level(level), app) assert app.logger.level is logging.INFO diff --git a/tests/cli/test_asyncapi_docs.py b/tests/cli/test_asyncapi_docs.py index 42d321ceaa..9deb1877b9 100644 --- a/tests/cli/test_asyncapi_docs.py +++ b/tests/cli/test_asyncapi_docs.py @@ -75,7 +75,7 @@ def test_serve_asyncapi_docs( m.setattr(HTTPServer, "serve_forever", mock) r = runner.invoke(cli, SERVE_CMD + [kafka_ascynapi_project]) # noqa: RUF005 - assert r.exit_code == 0 + assert r.exit_code == 0, r.exc_info mock.assert_called_once() @@ -94,7 +94,7 @@ def test_serve_asyncapi_json_schema( m.setattr(HTTPServer, "serve_forever", mock) r = runner.invoke(cli, SERVE_CMD + [str(schema_path)]) # noqa: RUF005 - assert r.exit_code == 0 + assert r.exit_code == 0, r.exc_info mock.assert_called_once() schema_path.unlink() @@ -115,7 +115,7 @@ def test_serve_asyncapi_yaml_schema( m.setattr(HTTPServer, "serve_forever", mock) r = runner.invoke(cli, SERVE_CMD + [str(schema_path)]) # noqa: RUF005 - assert r.exit_code == 0 + assert r.exit_code == 0, r.exc_info mock.assert_called_once() schema_path.unlink() diff --git a/tests/cli/test_run_asgi.py b/tests/cli/test_run_asgi.py index fac7662c04..c644c04bb2 100644 --- a/tests/cli/test_run_asgi.py +++ b/tests/cli/test_run_asgi.py @@ -9,7 +9,7 @@ def test_run_as_asgi(runner: CliRunner) -> None: - app = AsgiFastStream() + app = AsgiFastStream(AsyncMock()) app.run = AsyncMock() with patch( @@ -43,7 +43,7 @@ def test_run_as_asgi(runner: CliRunner) -> None: ), ) def test_run_as_asgi_with_workers(runner: CliRunner, workers: int) -> None: - app = AsgiFastStream() + app = AsgiFastStream(AsyncMock()) app.run = AsyncMock() with patch( @@ -73,7 +73,7 @@ def test_run_as_asgi_with_workers(runner: CliRunner, workers: int) -> None: def test_run_as_asgi_callable(runner: CliRunner) -> None: - app = AsgiFastStream() + app = AsgiFastStream(AsyncMock()) app.run = AsyncMock() app_factory = Mock(return_value=app) diff --git a/tests/cli/test_run_regular.py b/tests/cli/test_run_regular.py index e696389070..7edf3152ce 100644 --- a/tests/cli/test_run_regular.py +++ b/tests/cli/test_run_regular.py @@ -9,7 +9,7 @@ def test_run(runner: CliRunner) -> None: - app = FastStream() + app = FastStream(MagicMock()) app.run = AsyncMock() with patch( @@ -35,7 +35,7 @@ def test_run(runner: CliRunner) -> None: def test_run_factory(runner: CliRunner) -> None: - app = FastStream() + app = FastStream(MagicMock()) app.run = AsyncMock() app_factory = MagicMock(return_value=app) @@ -58,7 +58,7 @@ def test_run_factory(runner: CliRunner) -> None: def test_run_workers(runner: CliRunner) -> None: - app = FastStream() + app = FastStream(MagicMock()) app.run = AsyncMock() with ( @@ -85,7 +85,7 @@ def test_run_workers(runner: CliRunner) -> None: def test_run_factory_with_workers(runner: CliRunner) -> None: - app = FastStream() + app = FastStream(MagicMock()) app.run = AsyncMock() app_factory = MagicMock(return_value=app) @@ -113,7 +113,7 @@ def test_run_factory_with_workers(runner: CliRunner) -> None: def test_run_reloader(runner: CliRunner) -> None: - app = FastStream() + app = FastStream(MagicMock()) app.run = AsyncMock() with ( @@ -150,7 +150,7 @@ def test_run_reloader(runner: CliRunner) -> None: def test_run_reloader_with_factory(runner: CliRunner) -> None: - app = FastStream() + app = FastStream(MagicMock()) app.run = AsyncMock() app_factory = MagicMock(return_value=app) diff --git a/tests/conftest.py b/tests/conftest.py index ce7cfbd8f0..02f1c26724 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,10 +6,7 @@ from typer.testing import CliRunner from faststream.__about__ import __version__ -from faststream._internal.context import ( - ContextRepo, - context as global_context, -) +from faststream._internal.context import ContextRepo @pytest.hookimpl(tryfirst=True) @@ -58,8 +55,7 @@ def version() -> str: @pytest.fixture() def context() -> ContextRepo: - yield global_context - global_context.clear() + return ContextRepo() @pytest.fixture() diff --git a/tests/utils/context/test_alias.py b/tests/utils/context/test_alias.py index e01fbde325..a84e475a03 100644 --- a/tests/utils/context/test_alias.py +++ b/tests/utils/context/test_alias.py @@ -11,7 +11,7 @@ async def test_base_context_alias(context: ContextRepo) -> None: key = 1000 context.set_global("key", key) - @apply_types + @apply_types(context__=context) async def func(k=Context("key")): return k is key @@ -23,7 +23,7 @@ async def test_context_cast(context: ContextRepo) -> None: key = 1000 context.set_global("key", key) - @apply_types + @apply_types(context__=context) async def func(k: float = Context("key", cast=True)): return isinstance(k, float) @@ -35,7 +35,7 @@ async def test_nested_context_alias(context: ContextRepo) -> None: model = SomeModel(field=SomeModel(field=1000)) context.set_global("model", model) - @apply_types + @apply_types(context__=context) async def func( m=Context("model.field.field"), m2=Context("model.not_existed", default=None), @@ -59,7 +59,7 @@ async def test_annotated_alias(context: ContextRepo) -> None: model = SomeModel(field=SomeModel(field=1000)) context.set_global("model", model) - @apply_types + @apply_types(context__=context) async def func(m: Annotated[int, Context("model.field.field")]): return m is model.field.field diff --git a/tests/utils/context/test_main.py b/tests/utils/context/test_main.py index 006a478061..c34317a879 100644 --- a/tests/utils/context/test_main.py +++ b/tests/utils/context/test_main.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError +from fast_depends.exceptions import ValidationError from faststream import Context, ContextRepo from faststream._internal.utils import apply_types @@ -18,7 +18,7 @@ async def test_context_apply(context: ContextRepo) -> None: a = 1000 context.set_global("key", a) - @apply_types + @apply_types(context__=context) async def use(key=Context()): return key is a @@ -30,7 +30,7 @@ async def test_context_ignore(context: ContextRepo) -> None: a = 3 context.set_global("key", a) - @apply_types + @apply_types(context__=context) async def use() -> None: return None @@ -45,19 +45,19 @@ async def test_context_apply_multi(context: ContextRepo) -> None: b = 1000 context.set_global("key_b", b) - @apply_types + @apply_types(context__=context) async def use1(key_a=Context()): return key_a is a assert await use1() - @apply_types + @apply_types(context__=context) async def use2(key_b=Context()): return key_b is b assert await use2() - @apply_types + @apply_types(context__=context) async def use3(key_a=Context(), key_b=Context()): return key_a is a and key_b is b @@ -72,7 +72,7 @@ async def test_context_overrides(context: ContextRepo) -> None: b = 1000 context.set_global("test", b) - @apply_types + @apply_types(context__=context) async def use(test=Context()): return test is b @@ -84,11 +84,11 @@ async def test_context_nested_apply(context: ContextRepo) -> None: a = 1000 context.set_global("key", a) - @apply_types + @apply_types(context__=context) def use_nested(key=Context()): return key - @apply_types + @apply_types(context__=context) async def use(key=Context()): return key is use_nested() is a @@ -101,7 +101,7 @@ async def test_reset_global(context: ContextRepo) -> None: context.set_global("key", a) context.reset_global("key") - @apply_types + @apply_types(context__=context) async def use(key=Context()) -> None: ... with pytest.raises(ValidationError): @@ -114,7 +114,7 @@ async def test_clear_context(context: ContextRepo) -> None: context.set_global("key", a) context.clear() - @apply_types + @apply_types(context__=context) async def use(key=Context(default=None)): return key is None @@ -122,7 +122,7 @@ async def use(key=Context(default=None)): def test_scope(context: ContextRepo) -> None: - @apply_types + @apply_types(context__=context) def use(key=Context(), key2=Context()) -> None: assert key == 1 assert key2 == 1 @@ -135,7 +135,7 @@ def use(key=Context(), key2=Context()) -> None: def test_default(context: ContextRepo) -> None: - @apply_types + @apply_types(context__=context) def use( key=Context(), key2=Context(), @@ -169,8 +169,8 @@ def test_local_default(context: ContextRepo) -> None: assert context.get_local(key, 1) == 1 -def test_initial() -> None: - @apply_types +def test_initial(context: ContextRepo) -> None: + @apply_types(context__=context) def use( a, key=Context(initial=list), @@ -201,7 +201,7 @@ def __ne__(self, other): user2 = User(user_id=2) user3 = User(user_id=3) - @apply_types + @apply_types(context__=context) async def use( key1=Context("user1"), key2=Context("user2", default=user2), From 40d007b318dbca8c47bce17b9a2352b69c81755a Mon Sep 17 00:00:00 2001 From: Roma Frolov <98967567+roma-frolov@users.noreply.github.com> Date: Fri, 1 Nov 2024 07:46:41 +0300 Subject: [PATCH 28/48] Fixed RPC for Prometheus metrics (#1887) * fixing metrics for rpc * == -> is --- faststream/prometheus/middleware.py | 6 +++-- tests/prometheus/basic.py | 31 ++++++++++++++++++++++++++ tests/prometheus/nats/test_nats.py | 4 ++-- tests/prometheus/rabbit/test_rabbit.py | 4 ++-- tests/prometheus/redis/test_redis.py | 4 ++-- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/faststream/prometheus/middleware.py b/faststream/prometheus/middleware.py index 54270ae6c9..5d35708cfe 100644 --- a/faststream/prometheus/middleware.py +++ b/faststream/prometheus/middleware.py @@ -4,6 +4,7 @@ from faststream import BaseMiddleware from faststream._internal.constants import EMPTY +from faststream.message import SourceType from faststream.prometheus.consts import ( PROCESSING_STATUS_BY_ACK_STATUS, PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP, @@ -12,6 +13,7 @@ from faststream.prometheus.manager import MetricsManager from faststream.prometheus.provider import MetricsSettingsProvider from faststream.prometheus.types import ProcessingStatus, PublishingStatus +from faststream.response import PublishType if TYPE_CHECKING: from prometheus_client import CollectorRegistry @@ -86,7 +88,7 @@ async def consume_scope( call_next: "AsyncFuncAny", msg: "StreamMessage[Any]", ) -> Any: - if self._settings_provider is None: + if self._settings_provider is None or msg._source_type is SourceType.Response: return await call_next(msg) messaging_system = self._settings_provider.messaging_system @@ -163,7 +165,7 @@ async def publish_scope( call_next: "AsyncFunc", cmd: "PublishCommand", ) -> Any: - if self._settings_provider is None: + if self._settings_provider is None or cmd.publish_type is PublishType.Reply: return await call_next(cmd) destination_name = ( diff --git a/tests/prometheus/basic.py b/tests/prometheus/basic.py index 74c55dc4c0..004afa8457 100644 --- a/tests/prometheus/basic.py +++ b/tests/prometheus/basic.py @@ -202,3 +202,34 @@ def assert_publish_metrics(self, metrics_manager: Any): status="success", ), ] + + +class LocalRPCPrometheusTestcase: + @pytest.mark.asyncio() + async def test_rpc_request( + self, + queue: str, + event: asyncio.Event, + ) -> None: + middleware = self.get_middleware(registry=CollectorRegistry()) + metrics_manager_mock = Mock() + middleware._metrics_manager = metrics_manager_mock + + broker = self.get_broker(apply_types=True, middlewares=(middleware,)) + + @broker.subscriber(queue) + async def handle(): + event.set() + return "" + + async with self.patch_broker(broker) as br: + await br.start() + + await asyncio.wait_for( + br.request("", queue), + timeout=3, + ) + + assert event.is_set() + metrics_manager_mock.add_received_message.assert_called_once() + metrics_manager_mock.add_published_message.assert_called_once() diff --git a/tests/prometheus/nats/test_nats.py b/tests/prometheus/nats/test_nats.py index 81e34bd5cb..4af2685a7d 100644 --- a/tests/prometheus/nats/test_nats.py +++ b/tests/prometheus/nats/test_nats.py @@ -9,7 +9,7 @@ from faststream.nats.prometheus.middleware import NatsPrometheusMiddleware from tests.brokers.nats.test_consume import TestConsume from tests.brokers.nats.test_publish import TestPublish -from tests.prometheus.basic import LocalPrometheusTestcase +from tests.prometheus.basic import LocalPrometheusTestcase, LocalRPCPrometheusTestcase @pytest.fixture() @@ -18,7 +18,7 @@ def stream(queue): @pytest.mark.nats() -class TestPrometheus(LocalPrometheusTestcase): +class TestPrometheus(LocalPrometheusTestcase, LocalRPCPrometheusTestcase): def get_broker(self, apply_types=False, **kwargs): return NatsBroker(apply_types=apply_types, **kwargs) diff --git a/tests/prometheus/rabbit/test_rabbit.py b/tests/prometheus/rabbit/test_rabbit.py index a5a7a67cbe..f64786fc4f 100644 --- a/tests/prometheus/rabbit/test_rabbit.py +++ b/tests/prometheus/rabbit/test_rabbit.py @@ -5,7 +5,7 @@ from faststream.rabbit.prometheus.middleware import RabbitPrometheusMiddleware from tests.brokers.rabbit.test_consume import TestConsume from tests.brokers.rabbit.test_publish import TestPublish -from tests.prometheus.basic import LocalPrometheusTestcase +from tests.prometheus.basic import LocalPrometheusTestcase, LocalRPCPrometheusTestcase @pytest.fixture() @@ -14,7 +14,7 @@ def exchange(queue): @pytest.mark.rabbit() -class TestPrometheus(LocalPrometheusTestcase): +class TestPrometheus(LocalPrometheusTestcase, LocalRPCPrometheusTestcase): def get_broker(self, apply_types=False, **kwargs): return RabbitBroker(apply_types=apply_types, **kwargs) diff --git a/tests/prometheus/redis/test_redis.py b/tests/prometheus/redis/test_redis.py index 3a4aa9cc98..ce7f81f49f 100644 --- a/tests/prometheus/redis/test_redis.py +++ b/tests/prometheus/redis/test_redis.py @@ -9,11 +9,11 @@ from faststream.redis.prometheus.middleware import RedisPrometheusMiddleware from tests.brokers.redis.test_consume import TestConsume from tests.brokers.redis.test_publish import TestPublish -from tests.prometheus.basic import LocalPrometheusTestcase +from tests.prometheus.basic import LocalPrometheusTestcase, LocalRPCPrometheusTestcase @pytest.mark.redis() -class TestPrometheus(LocalPrometheusTestcase): +class TestPrometheus(LocalPrometheusTestcase, LocalRPCPrometheusTestcase): def get_broker(self, apply_types=False, **kwargs): return RedisBroker(apply_types=apply_types, **kwargs) From aafa2eedba4ebed2170252ed5c73fe66b127a065 Mon Sep 17 00:00:00 2001 From: Vladislav Tumko <56307628+vectorvp@users.noreply.github.com> Date: Sat, 2 Nov 2024 01:24:29 +0400 Subject: [PATCH 29/48] feat: add class method to create a baggage instance from headers (#1885) * feat: add class method to create a baggage instance from headers * test: baggage from headers * refactor: update from headers test * chore: fix precommit --------- Co-authored-by: Nikita Pastukhov --- faststream/opentelemetry/baggage.py | 6 +++++ tests/opentelemetry/basic.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/faststream/opentelemetry/baggage.py b/faststream/opentelemetry/baggage.py index 420b0c9081..d225714988 100644 --- a/faststream/opentelemetry/baggage.py +++ b/faststream/opentelemetry/baggage.py @@ -73,5 +73,11 @@ def from_msg(cls, msg: "StreamMessage[Any]") -> Self: return cls(cumulative_baggage, batch_baggage) + @classmethod + def from_headers(cls, headers: "AnyDict") -> Self: + """Create a Baggage instance from headers.""" + payload = baggage.get_all(_BAGGAGE_PROPAGATOR.extract(headers)) + return cls(cast("AnyDict", payload)) + def __repr__(self) -> str: return self._baggage.__repr__() diff --git a/tests/opentelemetry/basic.py b/tests/opentelemetry/basic.py index 0c5be63a1c..d3a28ab0ce 100644 --- a/tests/opentelemetry/basic.py +++ b/tests/opentelemetry/basic.py @@ -4,6 +4,8 @@ import pytest from dirty_equals import IsFloat, IsUUID +from opentelemetry import baggage, context +from opentelemetry.baggage.propagation import W3CBaggagePropagator from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics._internal.point import Metric from opentelemetry.sdk.metrics.export import InMemoryMetricReader @@ -535,3 +537,43 @@ async def handler2(m, baggage: CurrentBaggage): assert event.is_set() mock.assert_called_once_with(msg) + + async def test_get_baggage_from_headers( + self, + event: asyncio.Event, + queue: str, + ): + mid = self.telemetry_middleware_class() + broker = self.broker_class(middlewares=(mid,)) + + args, kwargs = self.get_subscriber_params(queue) + + expected_baggage = {"foo": "bar", "bar": "baz"} + + ctx = context.Context() + for key, value in expected_baggage.items(): + ctx = baggage.set_baggage(key, value, context=ctx) + + propagator = W3CBaggagePropagator() + headers = {} + propagator.inject(headers, context=ctx) + + @broker.subscriber(*args, **kwargs) + async def handler(): + baggage_instance = Baggage.from_headers(headers) + extracted_baggage = baggage_instance.get_all() + assert extracted_baggage == expected_baggage + event.set() + + broker = self.patch_broker(broker) + msg = "start" + + async with broker: + await broker.start() + tasks = ( + asyncio.create_task(broker.publish(msg, queue, headers=headers)), + asyncio.create_task(event.wait()), + ) + await asyncio.wait(tasks, timeout=self.timeout) + + assert event.is_set() From c9e567942b9340ef8b0f3704747f206cc649f415 Mon Sep 17 00:00:00 2001 From: Vladislav Tumko <56307628+vectorvp@users.noreply.github.com> Date: Sun, 3 Nov 2024 19:57:55 +0400 Subject: [PATCH 30/48] ops: update docker compose commands to compose V2 in scripts (#1889) --- scripts/start_test_env.sh | 2 +- scripts/stop_test_env.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/start_test_env.sh b/scripts/start_test_env.sh index a0ae1627b8..906556db41 100755 --- a/scripts/start_test_env.sh +++ b/scripts/start_test_env.sh @@ -2,4 +2,4 @@ source ./scripts/set_variables.sh -docker-compose -p $DOCKER_COMPOSE_PROJECT -f docs/includes/docker-compose.yaml up -d --no-recreate +docker compose -p $DOCKER_COMPOSE_PROJECT -f docs/includes/docker-compose.yaml up -d --no-recreate diff --git a/scripts/stop_test_env.sh b/scripts/stop_test_env.sh index 5d77186357..76ab4a3ee0 100755 --- a/scripts/stop_test_env.sh +++ b/scripts/stop_test_env.sh @@ -2,4 +2,4 @@ source ./scripts/set_variables.sh -docker-compose -p $DOCKER_COMPOSE_PROJECT -f docs/includes/docker-compose.yaml down +docker compose -p $DOCKER_COMPOSE_PROJECT -f docs/includes/docker-compose.yaml down From 0fa5fc1fe2ab873902df55ab6354a6e1a9990760 Mon Sep 17 00:00:00 2001 From: Roma Frolov <98967567+roma-frolov@users.noreply.github.com> Date: Tue, 5 Nov 2024 15:56:12 +0300 Subject: [PATCH 31/48] tests on MetricsSettingsProvider for all brokers (#1890) * tests on MetricsSettingsProvider for all brokers * chore: fix tests * chore: remove loguru usage --------- Co-authored-by: Nikita Pastukhov --- pyproject.toml | 4 +- tests/brokers/base/fastapi.py | 3 - tests/prometheus/basic.py | 19 +++ tests/prometheus/confluent/test_provider.py | 106 +++++++++++++++++ tests/prometheus/kafka/test_provider.py | 101 ++++++++++++++++ tests/prometheus/nats/test_provider.py | 107 +++++++++++++++++ tests/prometheus/rabbit/test_provider.py | 65 ++++++++++ tests/prometheus/redis/test_provider.py | 125 ++++++++++++++++++++ 8 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 tests/prometheus/confluent/test_provider.py create mode 100644 tests/prometheus/kafka/test_provider.py create mode 100644 tests/prometheus/nats/test_provider.py create mode 100644 tests/prometheus/rabbit/test_provider.py create mode 100644 tests/prometheus/redis/test_provider.py diff --git a/pyproject.toml b/pyproject.toml index 4cfce9283a..f875f7a652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dynamic = ["version"] dependencies = [ "anyio>=3.7.1,<5", - "fast-depends[pydantic]>=3.0.0a2,<4.0.0", + "fast-depends[pydantic]>=3.0.0a3,<4.0.0", "typing-extensions>=4.8.0", ] @@ -171,8 +171,6 @@ files = ["faststream", "tests/mypy"] strict = true python_version = "3.9" ignore_missing_imports = true -install_types = true -non_interactive = true plugins = ["pydantic.mypy"] # from https://blog.wolt.com/engineering/2021/09/30/professional-grade-mypy-configuration/ diff --git a/tests/brokers/base/fastapi.py b/tests/brokers/base/fastapi.py index 53725c23cb..38c9475ae2 100644 --- a/tests/brokers/base/fastapi.py +++ b/tests/brokers/base/fastapi.py @@ -80,9 +80,6 @@ async def hello(msg, tasks: BackgroundTasks) -> None: async def test_context(self, mock: Mock, queue: str, event: asyncio.Event) -> None: router = self.router_class() context = router.context - from loguru import logger - - logger.debug(context) context_key = "message.headers" diff --git a/tests/prometheus/basic.py b/tests/prometheus/basic.py index 004afa8457..22c13b1540 100644 --- a/tests/prometheus/basic.py +++ b/tests/prometheus/basic.py @@ -8,6 +8,7 @@ from faststream import Context from faststream.exceptions import RejectMessage from faststream.message import AckStatus +from faststream.prometheus import MetricsSettingsProvider from faststream.prometheus.middleware import ( PROCESSING_STATUS_BY_ACK_STATUS, PROCESSING_STATUS_BY_HANDLER_EXCEPTION_MAP, @@ -233,3 +234,21 @@ async def handle(): assert event.is_set() metrics_manager_mock.add_received_message.assert_called_once() metrics_manager_mock.add_published_message.assert_called_once() + + +class LocalMetricsSettingsProviderTestcase: + messaging_system: str + + @staticmethod + def get_provider() -> MetricsSettingsProvider: + raise NotImplementedError + + def test_messaging_system(self) -> None: + provider = self.get_provider() + assert provider.messaging_system == self.messaging_system + + def test_get_consume_attrs_from_message(self, *args, **kwargs) -> None: + raise NotImplementedError + + def test_get_publish_destination_name_from_cmd(self, *args, **kwargs) -> None: + raise NotImplementedError diff --git a/tests/prometheus/confluent/test_provider.py b/tests/prometheus/confluent/test_provider.py new file mode 100644 index 0000000000..87d9f5659b --- /dev/null +++ b/tests/prometheus/confluent/test_provider.py @@ -0,0 +1,106 @@ +import random +from types import SimpleNamespace + +import pytest + +from faststream.confluent.prometheus.provider import ( + BatchConfluentMetricsSettingsProvider, + ConfluentMetricsSettingsProvider, + settings_provider_factory, +) +from faststream.prometheus import MetricsSettingsProvider +from tests.prometheus.basic import LocalMetricsSettingsProviderTestcase + + +class LocalBaseConfluentMetricsSettingsProviderTestcase( + LocalMetricsSettingsProviderTestcase +): + messaging_system = "kafka" + + def test_get_publish_destination_name_from_cmd(self, queue: str) -> None: + expected_destination_name = queue + provider = self.get_provider() + command = SimpleNamespace(destination=queue) + + destination_name = provider.get_publish_destination_name_from_cmd(command) + + assert destination_name == expected_destination_name + + +class TestKafkaMetricsSettingsProvider( + LocalBaseConfluentMetricsSettingsProviderTestcase +): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return ConfluentMetricsSettingsProvider() + + def test_get_consume_attrs_from_message(self, queue: str) -> None: + body = b"Hello" + expected_attrs = { + "destination_name": queue, + "message_size": len(body), + "messages_count": 1, + } + + message = SimpleNamespace( + body=body, raw_message=SimpleNamespace(topic=lambda: queue) + ) + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +class TestBatchConfluentMetricsSettingsProvider( + LocalBaseConfluentMetricsSettingsProviderTestcase +): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return BatchConfluentMetricsSettingsProvider() + + def test_get_consume_attrs_from_message(self, queue: str) -> None: + body = [b"Hi ", b"again, ", b"FastStream!"] + message = SimpleNamespace( + body=body, + raw_message=[ + SimpleNamespace(topic=lambda: queue) + for _ in range(random.randint(a=2, b=10)) + ], + ) + expected_attrs = { + "destination_name": message.raw_message[0].topic(), + "message_size": len(bytearray().join(body)), + "messages_count": len(message.raw_message), + } + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +@pytest.mark.parametrize( + ("msg", "expected_provider"), + ( + pytest.param( + (SimpleNamespace(), SimpleNamespace()), + BatchConfluentMetricsSettingsProvider(), + id="message is batch", + ), + pytest.param( + SimpleNamespace(), + ConfluentMetricsSettingsProvider(), + id="single message", + ), + pytest.param( + None, + ConfluentMetricsSettingsProvider(), + id="message is None", + ), + ), +) +def test_settings_provider_factory(msg, expected_provider) -> None: + provider = settings_provider_factory(msg) + + assert isinstance(provider, type(expected_provider)) diff --git a/tests/prometheus/kafka/test_provider.py b/tests/prometheus/kafka/test_provider.py new file mode 100644 index 0000000000..4127eed58e --- /dev/null +++ b/tests/prometheus/kafka/test_provider.py @@ -0,0 +1,101 @@ +import random +from types import SimpleNamespace + +import pytest + +from faststream.kafka.prometheus.provider import ( + BatchKafkaMetricsSettingsProvider, + KafkaMetricsSettingsProvider, + settings_provider_factory, +) +from faststream.prometheus import MetricsSettingsProvider +from tests.prometheus.basic import LocalMetricsSettingsProviderTestcase + + +class LocalBaseKafkaMetricsSettingsProviderTestcase( + LocalMetricsSettingsProviderTestcase +): + messaging_system = "kafka" + + def test_get_publish_destination_name_from_cmd(self, queue: str) -> None: + expected_destination_name = queue + provider = self.get_provider() + command = SimpleNamespace(destination=queue) + + destination_name = provider.get_publish_destination_name_from_cmd(command) + + assert destination_name == expected_destination_name + + +class TestKafkaMetricsSettingsProvider(LocalBaseKafkaMetricsSettingsProviderTestcase): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return KafkaMetricsSettingsProvider() + + def test_get_consume_attrs_from_message(self, queue: str) -> None: + body = b"Hello" + expected_attrs = { + "destination_name": queue, + "message_size": len(body), + "messages_count": 1, + } + + message = SimpleNamespace(body=body, raw_message=SimpleNamespace(topic=queue)) + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +class TestBatchKafkaMetricsSettingsProvider( + LocalBaseKafkaMetricsSettingsProviderTestcase +): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return BatchKafkaMetricsSettingsProvider() + + def test_get_consume_attrs_from_message(self, queue: str) -> None: + body = [b"Hi ", b"again, ", b"FastStream!"] + message = SimpleNamespace( + body=body, + raw_message=[ + SimpleNamespace(topic=queue) for _ in range(random.randint(a=2, b=10)) + ], + ) + expected_attrs = { + "destination_name": message.raw_message[0].topic, + "message_size": len(bytearray().join(body)), + "messages_count": len(message.raw_message), + } + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +@pytest.mark.parametrize( + ("msg", "expected_provider"), + ( + pytest.param( + (SimpleNamespace(), SimpleNamespace()), + BatchKafkaMetricsSettingsProvider(), + id="message is batch", + ), + pytest.param( + SimpleNamespace(), + KafkaMetricsSettingsProvider(), + id="single message", + ), + pytest.param( + None, + KafkaMetricsSettingsProvider(), + id="message is None", + ), + ), +) +def test_settings_provider_factory(msg, expected_provider) -> None: + provider = settings_provider_factory(msg) + + assert isinstance(provider, type(expected_provider)) diff --git a/tests/prometheus/nats/test_provider.py b/tests/prometheus/nats/test_provider.py new file mode 100644 index 0000000000..817a37142d --- /dev/null +++ b/tests/prometheus/nats/test_provider.py @@ -0,0 +1,107 @@ +import random +from types import SimpleNamespace + +import pytest +from nats.aio.msg import Msg + +from faststream.nats.prometheus.provider import ( + BatchNatsMetricsSettingsProvider, + NatsMetricsSettingsProvider, + settings_provider_factory, +) +from faststream.prometheus import MetricsSettingsProvider +from tests.prometheus.basic import LocalMetricsSettingsProviderTestcase + + +class LocalBaseNatsMetricsSettingsProviderTestcase( + LocalMetricsSettingsProviderTestcase +): + messaging_system = "nats" + + def test_get_publish_destination_name_from_cmd(self, queue: str) -> None: + expected_destination_name = queue + command = SimpleNamespace(destination=queue) + + provider = self.get_provider() + destination_name = provider.get_publish_destination_name_from_cmd(command) + + assert destination_name == expected_destination_name + + +class TestNatsMetricsSettingsProvider(LocalBaseNatsMetricsSettingsProviderTestcase): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return NatsMetricsSettingsProvider() + + def test_get_consume_attrs_from_message(self, queue: str) -> None: + body = b"Hello" + expected_attrs = { + "destination_name": queue, + "message_size": len(body), + "messages_count": 1, + } + message = SimpleNamespace(body=body, raw_message=SimpleNamespace(subject=queue)) + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +class TestBatchNatsMetricsSettingsProvider( + LocalBaseNatsMetricsSettingsProviderTestcase +): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return BatchNatsMetricsSettingsProvider() + + def test_get_consume_attrs_from_message(self, queue: str) -> None: + body = b"Hello" + raw_messages = [ + SimpleNamespace(subject=queue) for _ in range(random.randint(a=2, b=10)) + ] + + expected_attrs = { + "destination_name": raw_messages[0].subject, + "message_size": len(body), + "messages_count": len(raw_messages), + } + message = SimpleNamespace(body=body, raw_message=raw_messages) + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +@pytest.mark.parametrize( + ("msg", "expected_provider"), + ( + pytest.param( + (Msg(SimpleNamespace()), Msg(SimpleNamespace())), + BatchNatsMetricsSettingsProvider(), + id="message is sequence", + ), + pytest.param( + Msg( + SimpleNamespace(), + ), + NatsMetricsSettingsProvider(), + id="single message", + ), + pytest.param( + None, + NatsMetricsSettingsProvider(), + id="message is None", + ), + pytest.param( + SimpleNamespace(), + None, + id="message is not Msg instance", + ), + ), +) +def test_settings_provider_factory(msg, expected_provider) -> None: + provider = settings_provider_factory(msg) + + assert isinstance(provider, type(expected_provider)) diff --git a/tests/prometheus/rabbit/test_provider.py b/tests/prometheus/rabbit/test_provider.py new file mode 100644 index 0000000000..71d47a781b --- /dev/null +++ b/tests/prometheus/rabbit/test_provider.py @@ -0,0 +1,65 @@ +from types import SimpleNamespace +from typing import Union + +import pytest + +from faststream.prometheus import MetricsSettingsProvider +from faststream.rabbit.prometheus.provider import RabbitMetricsSettingsProvider +from tests.prometheus.basic import LocalMetricsSettingsProviderTestcase + + +class TestRabbitMetricsSettingsProvider(LocalMetricsSettingsProviderTestcase): + messaging_system = "rabbitmq" + + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return RabbitMetricsSettingsProvider() + + @pytest.mark.parametrize( + "exchange", + ( + pytest.param("my_exchange", id="with exchange"), + pytest.param(None, id="without exchange"), + ), + ) + def test_get_consume_attrs_from_message( + self, + exchange: Union[str, None], + queue: str, + ) -> None: + body = b"Hello" + expected_attrs = { + "destination_name": f"{exchange or 'default'}.{queue}", + "message_size": len(body), + "messages_count": 1, + } + message = SimpleNamespace( + body=body, raw_message=SimpleNamespace(exchange=exchange, routing_key=queue) + ) + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + @pytest.mark.parametrize( + "exchange", + ( + pytest.param("my_exchange", id="with exchange"), + pytest.param(None, id="without exchange"), + ), + ) + def test_get_publish_destination_name_from_cmd( + self, + exchange: Union[str, None], + queue: str, + ) -> None: + expected_destination_name = f"{exchange or 'default'}.{queue}" + command = SimpleNamespace( + exchange=SimpleNamespace(name=exchange), destination=queue + ) + + provider = self.get_provider() + destination_name = provider.get_publish_destination_name_from_cmd(command) + + assert destination_name == expected_destination_name diff --git a/tests/prometheus/redis/test_provider.py b/tests/prometheus/redis/test_provider.py new file mode 100644 index 0000000000..9bafd26402 --- /dev/null +++ b/tests/prometheus/redis/test_provider.py @@ -0,0 +1,125 @@ +from types import SimpleNamespace + +import pytest + +from faststream.prometheus import MetricsSettingsProvider +from faststream.redis.prometheus.provider import ( + BatchRedisMetricsSettingsProvider, + RedisMetricsSettingsProvider, + settings_provider_factory, +) +from tests.prometheus.basic import LocalMetricsSettingsProviderTestcase + + +class LocalBaseRedisMetricsSettingsProviderTestcase( + LocalMetricsSettingsProviderTestcase +): + messaging_system = "redis" + + def test_get_publish_destination_name_from_cmd(self, queue: str) -> None: + expected_destination_name = queue + provider = self.get_provider() + command = SimpleNamespace(destination=queue) + + destination_name = provider.get_publish_destination_name_from_cmd(command) + + assert destination_name == expected_destination_name + + +class TestRedisMetricsSettingsProvider(LocalBaseRedisMetricsSettingsProviderTestcase): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return RedisMetricsSettingsProvider() + + @pytest.mark.parametrize( + "destination", + ( + pytest.param("channel", id="destination is channel"), + pytest.param("list", id="destination is list"), + pytest.param("stream", id="destination is stream"), + pytest.param("", id="destination is blank"), + ), + ) + def test_get_consume_attrs_from_message(self, queue: str, destination: str) -> None: + body = b"Hello" + expected_attrs = { + "destination_name": queue if destination else "", + "message_size": len(body), + "messages_count": 1, + } + raw_message = {} + + if destination: + raw_message[destination] = queue + + message = SimpleNamespace(body=body, raw_message=raw_message) + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +class TestBatchRedisMetricsSettingsProvider( + LocalBaseRedisMetricsSettingsProviderTestcase +): + @staticmethod + def get_provider() -> MetricsSettingsProvider: + return BatchRedisMetricsSettingsProvider() + + @pytest.mark.parametrize( + "destination", + ( + pytest.param("channel", id="destination is channel"), + pytest.param("list", id="destination is list"), + pytest.param("stream", id="destination is stream"), + pytest.param("", id="destination is blank"), + ), + ) + def test_get_consume_attrs_from_message(self, queue: str, destination: str) -> None: + decoded_body = ["Hi ", "again, ", "FastStream!"] + body = str(decoded_body).encode() + expected_attrs = { + "destination_name": queue if destination else "", + "message_size": len(body), + "messages_count": len(decoded_body), + } + raw_message = {} + + if destination: + raw_message[destination] = queue + + message = SimpleNamespace( + body=body, _decoded_body=decoded_body, raw_message=raw_message + ) + + provider = self.get_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +@pytest.mark.parametrize( + ("msg", "expected_provider"), + ( + pytest.param( + {"type": "blist"}, + BatchRedisMetricsSettingsProvider(), + id="message is batch", + ), + pytest.param( + {"type": "not_blist"}, + RedisMetricsSettingsProvider(), + id="single message", + ), + pytest.param( + None, + RedisMetricsSettingsProvider(), + id="message is None", + ), + ), +) +def test_settings_provider_factory(msg, expected_provider) -> None: + provider = settings_provider_factory(msg) + + assert isinstance(provider, type(expected_provider)) From 0578c1bf1178142ec92284f9cb13b2978af494c2 Mon Sep 17 00:00:00 2001 From: Ruslan Alimov Date: Tue, 5 Nov 2024 16:48:57 +0300 Subject: [PATCH 32/48] feat/ack_middleware added middleware (#1869) * feat/ack_middleware added ack middleware * tests: fix FastAPI tests * feat/ack_middleware added ack middleware * ack_middleware fixed redis stream * tests: fix FastAPI tests * ack_middleware fixed redis stream * chore: remove conflicts * chore: refactor AckMiddleware * docs: generate API References --------- Co-authored-by: Nikita Pastukhov Co-authored-by: Pastukhov Nikita Co-authored-by: Lancetnik --- docs/docs/SUMMARY.md | 9 + docs/docs/en/api/faststream/AckPolicy.md | 11 + .../api/faststream/middlewares/AckPolicy.md | 11 + .../middlewares/AcknowledgementMiddleware.md | 11 + .../acknowledgement/conf/AckPolicy.md | 11 + .../middleware/AcknowledgementMiddleware.md | 11 + faststream/__init__.py | 3 +- faststream/_internal/broker/broker.py | 11 +- .../subscriber/acknowledgement_watcher.py | 220 ------------------ faststream/_internal/subscriber/usecase.py | 30 +-- faststream/_internal/subscriber/utils.py | 27 +-- faststream/confluent/broker/registrator.py | 52 ++--- faststream/confluent/fastapi/fastapi.py | 40 ++-- faststream/confluent/router.py | 16 +- faststream/confluent/subscriber/factory.py | 19 +- faststream/confluent/subscriber/usecase.py | 19 +- faststream/kafka/broker/registrator.py | 52 ++--- faststream/kafka/fastapi/fastapi.py | 52 ++--- faststream/kafka/router.py | 16 +- faststream/kafka/subscriber/factory.py | 19 +- faststream/kafka/subscriber/usecase.py | 19 +- faststream/middlewares/__init__.py | 9 +- .../middlewares/acknowledgement/__init__.py | 0 .../middlewares/acknowledgement/conf.py | 8 + .../middlewares/acknowledgement/middleware.py | 111 +++++++++ faststream/nats/broker/registrator.py | 16 +- faststream/nats/fastapi/fastapi.py | 16 +- faststream/nats/parser.py | 7 +- faststream/nats/publisher/producer.py | 5 +- faststream/nats/router.py | 16 +- faststream/nats/schemas/js_stream.py | 13 +- faststream/nats/subscriber/factory.py | 25 +- faststream/nats/subscriber/usecase.py | 88 +++---- faststream/nats/testing.py | 3 +- faststream/rabbit/broker/registrator.py | 16 +- faststream/rabbit/fastapi/fastapi.py | 16 +- faststream/rabbit/router.py | 16 +- faststream/rabbit/subscriber/factory.py | 9 +- faststream/rabbit/subscriber/usecase.py | 11 +- faststream/redis/broker/registrator.py | 16 +- faststream/redis/fastapi/fastapi.py | 16 +- faststream/redis/router.py | 16 +- faststream/redis/schemas/stream_sub.py | 5 +- faststream/redis/subscriber/factory.py | 19 +- faststream/redis/subscriber/usecase.py | 49 ++-- tests/brokers/base/fastapi.py | 32 ++- tests/brokers/base/router.py | 9 +- tests/brokers/confluent/test_consume.py | 5 +- tests/brokers/kafka/test_consume.py | 5 +- tests/brokers/nats/test_consume.py | 5 +- tests/brokers/rabbit/test_consume.py | 22 +- tests/brokers/rabbit/test_test_client.py | 6 +- tests/brokers/test_pushback.py | 124 ---------- 53 files changed, 528 insertions(+), 845 deletions(-) create mode 100644 docs/docs/en/api/faststream/AckPolicy.md create mode 100644 docs/docs/en/api/faststream/middlewares/AckPolicy.md create mode 100644 docs/docs/en/api/faststream/middlewares/AcknowledgementMiddleware.md create mode 100644 docs/docs/en/api/faststream/middlewares/acknowledgement/conf/AckPolicy.md create mode 100644 docs/docs/en/api/faststream/middlewares/acknowledgement/middleware/AcknowledgementMiddleware.md delete mode 100644 faststream/_internal/subscriber/acknowledgement_watcher.py create mode 100644 faststream/middlewares/acknowledgement/__init__.py create mode 100644 faststream/middlewares/acknowledgement/conf.py create mode 100644 faststream/middlewares/acknowledgement/middleware.py delete mode 100644 tests/brokers/test_pushback.py diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index bf061a321a..5c5b4db5b4 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -119,6 +119,7 @@ search: - [Reference - Code API](api/index.md) - Public API - faststream + - [AckPolicy](public_api/faststream/AckPolicy.md) - [BaseMiddleware](public_api/faststream/BaseMiddleware.md) - [Context](public_api/faststream/Context.md) - [Depends](public_api/faststream/Depends.md) @@ -205,6 +206,7 @@ search: - [TestRedisBroker](public_api/faststream/redis/TestRedisBroker.md) - All API - faststream + - [AckPolicy](api/faststream/AckPolicy.md) - [BaseMiddleware](api/faststream/BaseMiddleware.md) - [Context](api/faststream/Context.md) - [Depends](api/faststream/Depends.md) @@ -472,8 +474,15 @@ search: - [encode_message](api/faststream/message/utils/encode_message.md) - [gen_cor_id](api/faststream/message/utils/gen_cor_id.md) - middlewares + - [AckPolicy](api/faststream/middlewares/AckPolicy.md) + - [AcknowledgementMiddleware](api/faststream/middlewares/AcknowledgementMiddleware.md) - [BaseMiddleware](api/faststream/middlewares/BaseMiddleware.md) - [ExceptionMiddleware](api/faststream/middlewares/ExceptionMiddleware.md) + - acknowledgement + - conf + - [AckPolicy](api/faststream/middlewares/acknowledgement/conf/AckPolicy.md) + - middleware + - [AcknowledgementMiddleware](api/faststream/middlewares/acknowledgement/middleware/AcknowledgementMiddleware.md) - base - [BaseMiddleware](api/faststream/middlewares/base/BaseMiddleware.md) - exception diff --git a/docs/docs/en/api/faststream/AckPolicy.md b/docs/docs/en/api/faststream/AckPolicy.md new file mode 100644 index 0000000000..4d7218c81b --- /dev/null +++ b/docs/docs/en/api/faststream/AckPolicy.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.AckPolicy diff --git a/docs/docs/en/api/faststream/middlewares/AckPolicy.md b/docs/docs/en/api/faststream/middlewares/AckPolicy.md new file mode 100644 index 0000000000..82d0033dfb --- /dev/null +++ b/docs/docs/en/api/faststream/middlewares/AckPolicy.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.middlewares.AckPolicy diff --git a/docs/docs/en/api/faststream/middlewares/AcknowledgementMiddleware.md b/docs/docs/en/api/faststream/middlewares/AcknowledgementMiddleware.md new file mode 100644 index 0000000000..d3e7d6a763 --- /dev/null +++ b/docs/docs/en/api/faststream/middlewares/AcknowledgementMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.middlewares.AcknowledgementMiddleware diff --git a/docs/docs/en/api/faststream/middlewares/acknowledgement/conf/AckPolicy.md b/docs/docs/en/api/faststream/middlewares/acknowledgement/conf/AckPolicy.md new file mode 100644 index 0000000000..8a92ec0a54 --- /dev/null +++ b/docs/docs/en/api/faststream/middlewares/acknowledgement/conf/AckPolicy.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.middlewares.acknowledgement.conf.AckPolicy diff --git a/docs/docs/en/api/faststream/middlewares/acknowledgement/middleware/AcknowledgementMiddleware.md b/docs/docs/en/api/faststream/middlewares/acknowledgement/middleware/AcknowledgementMiddleware.md new file mode 100644 index 0000000000..79b2956eb4 --- /dev/null +++ b/docs/docs/en/api/faststream/middlewares/acknowledgement/middleware/AcknowledgementMiddleware.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.middlewares.acknowledgement.middleware.AcknowledgementMiddleware diff --git a/faststream/__init__.py b/faststream/__init__.py index 09514567a8..cad7e628bf 100644 --- a/faststream/__init__.py +++ b/faststream/__init__.py @@ -4,7 +4,7 @@ from faststream._internal.utils import apply_types from faststream.annotations import ContextRepo, Logger from faststream.app import FastStream -from faststream.middlewares import BaseMiddleware, ExceptionMiddleware +from faststream.middlewares import AckPolicy, BaseMiddleware, ExceptionMiddleware from faststream.params import ( Context, Depends, @@ -16,6 +16,7 @@ __all__ = ( # middlewares + "AckPolicy", "BaseMiddleware", # params "Context", diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index 162ea5fbc6..f4229be5c6 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -16,7 +16,6 @@ from fast_depends.pydantic import PydanticSerializer from typing_extensions import Doc, Self -from faststream._internal._compat import is_test_env from faststream._internal.constants import EMPTY from faststream._internal.context.repository import ContextRepo from faststream._internal.setup import ( @@ -164,12 +163,10 @@ def __init__( self._connection = None self._producer = None - # TODO: remove useless middleware filter - if not is_test_env(): - self._middlewares = ( - CriticalLogMiddleware(logger_state), - *self._middlewares, - ) + self._middlewares = ( + CriticalLogMiddleware(logger_state), + *self._middlewares, + ) self._state = EmptyState( depends_params=FastDependsData( diff --git a/faststream/_internal/subscriber/acknowledgement_watcher.py b/faststream/_internal/subscriber/acknowledgement_watcher.py deleted file mode 100644 index c86e59baf6..0000000000 --- a/faststream/_internal/subscriber/acknowledgement_watcher.py +++ /dev/null @@ -1,220 +0,0 @@ -import logging -from abc import ABC, abstractmethod -from collections import ( - Counter, - Counter as CounterType, -) -from typing import TYPE_CHECKING, Any, Optional, Union - -from faststream.exceptions import ( - AckMessage, - HandlerException, - NackMessage, - RejectMessage, - SkipMessage, -) - -if TYPE_CHECKING: - from types import TracebackType - - from faststream._internal.basic_types import LoggerProto - from faststream._internal.types import MsgType - from faststream.message import StreamMessage - - -class BaseWatcher(ABC): - """A base class for a watcher.""" - - max_tries: int - - def __init__( - self, - max_tries: int = 0, - logger: Optional["LoggerProto"] = None, - ) -> None: - self.logger = logger - self.max_tries = max_tries - - @abstractmethod - def add(self, message_id: str) -> None: - """Add a message.""" - raise NotImplementedError - - @abstractmethod - def is_max(self, message_id: str) -> bool: - """Check if the given message ID is the maximum attempt.""" - raise NotImplementedError - - @abstractmethod - def remove(self, message_id: str) -> None: - """Remove a message.""" - raise NotImplementedError - - -class EndlessWatcher(BaseWatcher): - """A class to watch and track messages.""" - - def add(self, message_id: str) -> None: - """Add a message to the list.""" - - def is_max(self, message_id: str) -> bool: - """Check if the given message ID is the maximum attempt.""" - return False - - def remove(self, message_id: str) -> None: - """Remove a message.""" - - -class OneTryWatcher(BaseWatcher): - """A class to watch and track messages.""" - - def add(self, message_id: str) -> None: - """Add a message.""" - - def is_max(self, message_id: str) -> bool: - """Check if the given message ID is the maximum attempt.""" - return True - - def remove(self, message_id: str) -> None: - """Remove a message.""" - - -class CounterWatcher(BaseWatcher): - """A class to watch and track the count of messages.""" - - memory: CounterType[str] - - def __init__( - self, - max_tries: int = 3, - logger: Optional["LoggerProto"] = None, - ) -> None: - super().__init__(logger=logger, max_tries=max_tries) - self.memory = Counter() - - def add(self, message_id: str) -> None: - """Check if the given message ID is the maximum attempt.""" - self.memory[message_id] += 1 - - def is_max(self, message_id: str) -> bool: - """Check if the number of tries for a message has exceeded the maximum allowed tries.""" - is_max = self.memory[message_id] > self.max_tries - if self.logger is not None: - if is_max: - self.logger.log( - logging.ERROR, - f"Already retried {self.max_tries} times. Skipped.", - ) - else: - self.logger.log( - logging.ERROR, - "Error is occurred. Pushing back to queue.", - ) - return is_max - - def remove(self, message_id: str) -> None: - """Remove a message from memory.""" - self.memory[message_id] = 0 - self.memory += Counter() - - -class WatcherContext: - """A class representing a context for a watcher.""" - - def __init__( - self, - message: "StreamMessage[MsgType]", - watcher: BaseWatcher, - logger: Optional["LoggerProto"] = None, - **extra_options: Any, - ) -> None: - self.watcher = watcher - self.message = message - self.extra_options = extra_options - self.logger = logger - - async def __aenter__(self) -> None: - self.watcher.add(self.message.message_id) - - async def __aexit__( - self, - exc_type: Optional[type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional["TracebackType"], - ) -> bool: - """Exit the asynchronous context manager.""" - if not exc_type: - await self.__ack() - - elif isinstance(exc_val, HandlerException): - if isinstance(exc_val, SkipMessage): - self.watcher.remove(self.message.message_id) - - elif isinstance(exc_val, AckMessage): - await self.__ack(**exc_val.extra_options) - - elif isinstance(exc_val, NackMessage): - await self.__nack(**exc_val.extra_options) - - elif isinstance(exc_val, RejectMessage): # pragma: no branch - await self.__reject(**exc_val.extra_options) - - # Exception was processed and suppressed - return True - - elif self.watcher.is_max(self.message.message_id): - await self.__reject() - - else: - await self.__nack() - - # Exception was not processed - return False - - async def __ack(self, **exc_extra_options: Any) -> None: - try: - await self.message.ack(**self.extra_options, **exc_extra_options) - except Exception as er: - if self.logger is not None: - self.logger.log(logging.ERROR, er, exc_info=er) - else: - self.watcher.remove(self.message.message_id) - - async def __nack(self, **exc_extra_options: Any) -> None: - try: - await self.message.nack(**self.extra_options, **exc_extra_options) - except Exception as er: - if self.logger is not None: - self.logger.log(logging.ERROR, er, exc_info=er) - - async def __reject(self, **exc_extra_options: Any) -> None: - try: - await self.message.reject(**self.extra_options, **exc_extra_options) - except Exception as er: - if self.logger is not None: - self.logger.log(logging.ERROR, er, exc_info=er) - else: - self.watcher.remove(self.message.message_id) - - -def get_watcher( - logger: Optional["LoggerProto"], - try_number: Union[bool, int], -) -> BaseWatcher: - """Get a watcher object based on the provided parameters. - - Args: - logger: Optional logger object for logging messages. - try_number: Optional parameter to specify the type of watcher. - - If set to True, an EndlessWatcher object will be returned. - - If set to False, a OneTryWatcher object will be returned. - - If set to an integer, a CounterWatcher object with the specified maximum number of tries will be returned. - """ - watcher: Optional[BaseWatcher] - if try_number is True: - watcher = EndlessWatcher() - elif try_number is False: - watcher = OneTryWatcher() - else: - watcher = CounterWatcher(logger=logger, max_tries=try_number) - return watcher diff --git a/faststream/_internal/subscriber/usecase.py b/faststream/_internal/subscriber/usecase.py index 0133be3493..58ed1a8e2a 100644 --- a/faststream/_internal/subscriber/usecase.py +++ b/faststream/_internal/subscriber/usecase.py @@ -7,7 +7,6 @@ Any, Callable, Optional, - Union, overload, ) @@ -19,7 +18,6 @@ from faststream._internal.subscriber.utils import ( MultiLock, default_filter, - get_watcher_context, resolve_custom_func, ) from faststream._internal.types import ( @@ -29,6 +27,7 @@ ) from faststream._internal.utils.functions import sync_fake_context, to_async from faststream.exceptions import SetupError, StopConsume, SubscriberNotFound +from faststream.middlewares import AckPolicy, AcknowledgementMiddleware from faststream.response import ensure_response from faststream.specification.asyncapi.message import parse_handler_params from faststream.specification.asyncapi.utils import to_camelcase @@ -92,13 +91,12 @@ class SubscriberUsecase(SubscriberProto[MsgType]): def __init__( self, *, - no_ack: bool, no_reply: bool, - retry: Union[bool, int], broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], default_parser: "AsyncCallable", default_decoder: "AsyncCallable", + ack_policy: AckPolicy, # AsyncAPI information title_: Optional[str], description_: Optional[str], @@ -110,9 +108,7 @@ def __init__( self._parser = default_parser self._decoder = default_decoder self._no_reply = no_reply - # Watcher args - self._no_ack = no_ack - self._retry = retry + self.ack_policy = ack_policy self._call_options = None self._call_decorators = () @@ -134,6 +130,15 @@ def __init__( self.description_ = description_ self.include_in_schema = include_in_schema + if self.ack_policy is not AckPolicy.DO_NOTHING: + self._broker_middlewares = ( + AcknowledgementMiddleware( + self.ack_policy, + self.extra_watcher_options, + ), + *self._broker_middlewares, + ) + def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: self._broker_middlewares = (*self._broker_middlewares, middleware) @@ -157,8 +162,6 @@ def _setup( # type: ignore[override] self.graceful_timeout = graceful_timeout self.extra_context = extra_context - self.watcher = get_watcher_context(logger, self._no_ack, self._retry) - for call in self.calls: if parser := call.item_parser or broker_parser: async_parser = resolve_custom_func(to_async(parser), self._parser) @@ -345,15 +348,6 @@ async def process_message(self, msg: MsgType) -> "Response": break if message is not None: - # Acknowledgement scope - # TODO: move it to scope enter at `retry` option deprecation - await stack.enter_async_context( - self.watcher( - message, - **self.extra_watcher_options, - ), - ) - stack.enter_context( context.scope("log_context", self.get_log_context(message)), ) diff --git a/faststream/_internal/subscriber/utils.py b/faststream/_internal/subscriber/utils.py index 4dc615a9c0..fb9002c3a3 100644 --- a/faststream/_internal/subscriber/utils.py +++ b/faststream/_internal/subscriber/utils.py @@ -1,7 +1,7 @@ import asyncio import inspect from collections.abc import Awaitable, Iterable -from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress +from contextlib import AsyncExitStack, suppress from functools import partial from typing import ( TYPE_CHECKING, @@ -15,18 +15,13 @@ import anyio from typing_extensions import Literal, Self, overload -from faststream._internal.subscriber.acknowledgement_watcher import ( - WatcherContext, - get_watcher, -) from faststream._internal.types import MsgType -from faststream._internal.utils.functions import fake_context, return_input, to_async +from faststream._internal.utils.functions import return_input, to_async from faststream.message.source_type import SourceType if TYPE_CHECKING: from types import TracebackType - from faststream._internal.basic_types import LoggerProto from faststream._internal.types import ( AsyncCallable, CustomCallable, @@ -90,24 +85,6 @@ async def default_filter(msg: "StreamMessage[Any]") -> bool: return not msg.processed -def get_watcher_context( - logger: Optional["LoggerProto"], - no_ack: bool, - retry: Union[bool, int], - **extra_options: Any, -) -> Callable[..., "AbstractAsyncContextManager[None]"]: - """Create Acknowledgement scope.""" - if no_ack: - return fake_context - - return partial( - WatcherContext, - watcher=get_watcher(logger, retry), - logger=logger, - **extra_options, - ) - - class MultiLock: """A class representing a multi lock.""" diff --git a/faststream/confluent/broker/registrator.py b/faststream/confluent/broker/registrator.py index 8eb5c58326..7187c56a03 100644 --- a/faststream/confluent/broker/registrator.py +++ b/faststream/confluent/broker/registrator.py @@ -16,6 +16,7 @@ from faststream.confluent.publisher.factory import create_publisher from faststream.confluent.subscriber.factory import create_subscriber from faststream.exceptions import SetupError +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from confluent_kafka import Message @@ -294,14 +295,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -565,14 +562,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -836,14 +829,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1110,14 +1099,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1174,9 +1159,8 @@ def subscriber( }, is_manual=not auto_commit, # subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=self._middlewares, broker_dependencies=self._dependencies, # Specification diff --git a/faststream/confluent/fastapi/fastapi.py b/faststream/confluent/fastapi/fastapi.py index 73f6972a40..ac24669769 100644 --- a/faststream/confluent/fastapi/fastapi.py +++ b/faststream/confluent/fastapi/fastapi.py @@ -25,6 +25,7 @@ from faststream._internal.constants import EMPTY from faststream._internal.fastapi.router import StreamRouter from faststream.confluent.broker.broker import KafkaBroker as KB +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from enum import Enum @@ -833,14 +834,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1607,14 +1604,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -2004,14 +1997,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -2187,8 +2176,7 @@ def subscriber( parser=parser, decoder=decoder, middlewares=middlewares, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/confluent/router.py b/faststream/confluent/router.py index cd1fc9509c..c4d36fd7e0 100644 --- a/faststream/confluent/router.py +++ b/faststream/confluent/router.py @@ -17,6 +17,7 @@ SubscriberRoute, ) from faststream.confluent.broker.registrator import KafkaRegistrator +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from confluent_kafka import Message @@ -380,14 +381,10 @@ def __init__( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -445,8 +442,7 @@ def __init__( description=description, include_in_schema=include_in_schema, # FastDepends args - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, ) diff --git a/faststream/confluent/subscriber/factory.py b/faststream/confluent/subscriber/factory.py index 24107e56be..744a47f744 100644 --- a/faststream/confluent/subscriber/factory.py +++ b/faststream/confluent/subscriber/factory.py @@ -19,6 +19,7 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware from faststream.confluent.schemas import TopicPartition + from faststream.middlewares import AckPolicy @overload @@ -33,9 +34,8 @@ def create_subscriber( connection_data: "AnyDict", is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[tuple[ConfluentMsg, ...]]"], # Specification args @@ -57,9 +57,8 @@ def create_subscriber( connection_data: "AnyDict", is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[ConfluentMsg]"], # Specification args @@ -81,9 +80,8 @@ def create_subscriber( connection_data: "AnyDict", is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConfluentMsg, tuple[ConfluentMsg, ...]]]" @@ -109,9 +107,8 @@ def create_subscriber( connection_data: "AnyDict", is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConfluentMsg, tuple[ConfluentMsg, ...]]]" @@ -133,9 +130,8 @@ def create_subscriber( group_id=group_id, connection_data=connection_data, is_manual=is_manual, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, title_=title_, @@ -149,9 +145,8 @@ def create_subscriber( group_id=group_id, connection_data=connection_data, is_manual=is_manual, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, title_=title_, diff --git a/faststream/confluent/subscriber/usecase.py b/faststream/confluent/subscriber/usecase.py index 470fb57840..e14e2903d2 100644 --- a/faststream/confluent/subscriber/usecase.py +++ b/faststream/confluent/subscriber/usecase.py @@ -32,6 +32,7 @@ ) from faststream.confluent.client import AsyncConfluentConsumer from faststream.message import StreamMessage + from faststream.middlewares import AckPolicy class LogicSubscriber(ABC, SubscriberUsecase[MsgType]): @@ -58,9 +59,8 @@ def __init__( # Subscriber args default_parser: "AsyncCallable", default_decoder: "AsyncCallable", - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args @@ -72,9 +72,8 @@ def __init__( default_parser=default_parser, default_decoder=default_decoder, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -263,9 +262,8 @@ def __init__( connection_data: "AnyDict", is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Message]"], # AsyncAPI args @@ -285,9 +283,8 @@ def __init__( default_parser=self.parser.parse_message, default_decoder=self.parser.decode_message, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -328,9 +325,8 @@ def __init__( connection_data: "AnyDict", is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[tuple[Message, ...]]"], # AsyncAPI args @@ -352,9 +348,8 @@ def __init__( default_parser=self.parser.parse_message_batch, default_decoder=self.parser.decode_message_batch, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args diff --git a/faststream/kafka/broker/registrator.py b/faststream/kafka/broker/registrator.py index b5b7c4b0d9..76cb4ec77b 100644 --- a/faststream/kafka/broker/registrator.py +++ b/faststream/kafka/broker/registrator.py @@ -18,6 +18,7 @@ from faststream._internal.broker.abc_broker import ABCBroker from faststream.kafka.publisher.factory import create_publisher from faststream.kafka.subscriber.factory import create_subscriber +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from aiokafka import TopicPartition @@ -396,14 +397,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -766,14 +763,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1136,14 +1129,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1509,14 +1498,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1576,9 +1561,8 @@ def subscriber( partitions=partitions, is_manual=not auto_commit, # subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=self._middlewares, broker_dependencies=self._dependencies, # Specification diff --git a/faststream/kafka/fastapi/fastapi.py b/faststream/kafka/fastapi/fastapi.py index 1b92a1029c..3cd73426db 100644 --- a/faststream/kafka/fastapi/fastapi.py +++ b/faststream/kafka/fastapi/fastapi.py @@ -28,6 +28,7 @@ from faststream._internal.constants import EMPTY from faststream._internal.fastapi.router import StreamRouter from faststream.kafka.broker.broker import KafkaBroker as KB +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from asyncio import AbstractEventLoop @@ -944,14 +945,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1434,14 +1431,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -1924,14 +1917,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -2417,14 +2406,10 @@ def subscriber( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -2607,8 +2592,7 @@ def subscriber( parser=parser, decoder=decoder, middlewares=middlewares, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/kafka/router.py b/faststream/kafka/router.py index 1652e52901..416d23df20 100644 --- a/faststream/kafka/router.py +++ b/faststream/kafka/router.py @@ -18,6 +18,7 @@ SubscriberRoute, ) from faststream.kafka.broker.registrator import KafkaRegistrator +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from aiokafka import ConsumerRecord, TopicPartition @@ -483,14 +484,10 @@ def __init__( Iterable["SubscriberMiddleware[KafkaMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -555,8 +552,7 @@ def __init__( description=description, include_in_schema=include_in_schema, # FastDepends args - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, ) diff --git a/faststream/kafka/subscriber/factory.py b/faststream/kafka/subscriber/factory.py index e53654283e..7542a11cd6 100644 --- a/faststream/kafka/subscriber/factory.py +++ b/faststream/kafka/subscriber/factory.py @@ -20,6 +20,7 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware + from faststream.middlewares import AckPolicy @overload @@ -36,9 +37,8 @@ def create_subscriber( partitions: Iterable["TopicPartition"], is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[tuple[ConsumerRecord, ...]]"], # Specification args @@ -62,9 +62,8 @@ def create_subscriber( partitions: Iterable["TopicPartition"], is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[ConsumerRecord]"], # Specification args @@ -88,9 +87,8 @@ def create_subscriber( partitions: Iterable["TopicPartition"], is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConsumerRecord, tuple[ConsumerRecord, ...]]]" @@ -118,9 +116,8 @@ def create_subscriber( partitions: Iterable["TopicPartition"], is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Union[ConsumerRecord, tuple[ConsumerRecord, ...]]]" @@ -163,9 +160,8 @@ def create_subscriber( connection_args=connection_args, partitions=partitions, is_manual=is_manual, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, title_=title_, @@ -181,9 +177,8 @@ def create_subscriber( connection_args=connection_args, partitions=partitions, is_manual=is_manual, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, title_=title_, diff --git a/faststream/kafka/subscriber/usecase.py b/faststream/kafka/subscriber/usecase.py index 03dae21687..f3cf9c2122 100644 --- a/faststream/kafka/subscriber/usecase.py +++ b/faststream/kafka/subscriber/usecase.py @@ -36,6 +36,7 @@ from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto from faststream._internal.setup import SetupState from faststream.message import StreamMessage + from faststream.middlewares import AckPolicy class LogicSubscriber(SubscriberUsecase[MsgType]): @@ -64,9 +65,8 @@ def __init__( # Subscriber args default_parser: "AsyncCallable", default_decoder: "AsyncCallable", - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args @@ -78,9 +78,8 @@ def __init__( default_parser=default_parser, default_decoder=default_decoder, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -292,9 +291,8 @@ def __init__( partitions: Iterable["TopicPartition"], is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[ConsumerRecord]"], # AsyncAPI args @@ -328,9 +326,8 @@ def __init__( default_parser=self.parser.parse_message, default_decoder=self.parser.decode_message, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -373,9 +370,8 @@ def __init__( partitions: Iterable["TopicPartition"], is_manual: bool, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable[ "BrokerMiddleware[Sequence[tuple[ConsumerRecord, ...]]]" @@ -414,9 +410,8 @@ def __init__( default_parser=self.parser.parse_message, default_decoder=self.parser.decode_message, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args diff --git a/faststream/middlewares/__init__.py b/faststream/middlewares/__init__.py index 0615c88194..f8d57bdf50 100644 --- a/faststream/middlewares/__init__.py +++ b/faststream/middlewares/__init__.py @@ -1,4 +1,11 @@ +from faststream.middlewares.acknowledgement.conf import AckPolicy +from faststream.middlewares.acknowledgement.middleware import AcknowledgementMiddleware from faststream.middlewares.base import BaseMiddleware from faststream.middlewares.exception import ExceptionMiddleware -__all__ = ("BaseMiddleware", "ExceptionMiddleware") +__all__ = ( + "AckPolicy", + "AcknowledgementMiddleware", + "BaseMiddleware", + "ExceptionMiddleware", +) diff --git a/faststream/middlewares/acknowledgement/__init__.py b/faststream/middlewares/acknowledgement/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/faststream/middlewares/acknowledgement/conf.py b/faststream/middlewares/acknowledgement/conf.py new file mode 100644 index 0000000000..60b910264d --- /dev/null +++ b/faststream/middlewares/acknowledgement/conf.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class AckPolicy(str, Enum): + ACK = "ack" + REJECT_ON_ERROR = "reject_on_error" + NACK_ON_ERROR = "nack_on_error" + DO_NOTHING = "do_nothing" diff --git a/faststream/middlewares/acknowledgement/middleware.py b/faststream/middlewares/acknowledgement/middleware.py new file mode 100644 index 0000000000..2513768649 --- /dev/null +++ b/faststream/middlewares/acknowledgement/middleware.py @@ -0,0 +1,111 @@ +import logging +from typing import TYPE_CHECKING, Any, Optional + +from faststream.exceptions import ( + AckMessage, + HandlerException, + NackMessage, + RejectMessage, +) +from faststream.middlewares.acknowledgement.conf import AckPolicy +from faststream.middlewares.base import BaseMiddleware + +if TYPE_CHECKING: + from types import TracebackType + + from faststream._internal.basic_types import AnyDict, AsyncFuncAny + from faststream._internal.context.repository import ContextRepo + from faststream.message import StreamMessage + + +class AcknowledgementMiddleware: + def __init__(self, ack_policy: AckPolicy, extra_options: "AnyDict") -> None: + self.ack_policy = ack_policy + self.extra_options = extra_options + + def __call__(self, msg: Optional[Any], context: "ContextRepo") -> "_AcknowledgementMiddleware": + return _AcknowledgementMiddleware( + msg, + ack_policy=self.ack_policy, + extra_options=self.extra_options, + context=context, + ) + + +class _AcknowledgementMiddleware(BaseMiddleware): + def __init__( + self, + msg: Optional[Any], + /, + *, + context: "ContextRepo", + ack_policy: AckPolicy, + extra_options: "AnyDict", + ) -> None: + super().__init__(msg, context=context) + self.ack_policy = ack_policy + self.extra_options = extra_options + self.logger = context.get_local("logger") + + async def consume_scope( + self, + call_next: "AsyncFuncAny", + msg: "StreamMessage[Any]", + ) -> Any: + self.message = msg + return await call_next(msg) + + async def __aexit__( + self, + exc_type: Optional[type[BaseException]] = None, + exc_val: Optional[BaseException] = None, + exc_tb: Optional["TracebackType"] = None, + ) -> Optional[bool]: + if self.ack_policy is AckPolicy.DO_NOTHING: + return False + + if not exc_type: + await self.__ack() + + elif isinstance(exc_val, HandlerException): + if isinstance(exc_val, AckMessage): + await self.__ack(**exc_val.extra_options) + + elif isinstance(exc_val, NackMessage): + await self.__nack(**exc_val.extra_options) + + elif isinstance(exc_val, RejectMessage): # pragma: no branch + await self.__reject(**exc_val.extra_options) + + # Exception was processed and suppressed + return True + + elif self.ack_policy is AckPolicy.REJECT_ON_ERROR: + await self.__reject() + + elif self.ack_policy is AckPolicy.NACK_ON_ERROR: + await self.__nack() + + # Exception was not processed + return False + + async def __ack(self, **exc_extra_options: Any) -> None: + try: + await self.message.ack(**exc_extra_options, **self.extra_options) + except Exception as er: + if self.logger is not None: + self.logger.log(logging.ERROR, er, exc_info=er) + + async def __nack(self, **exc_extra_options: Any) -> None: + try: + await self.message.nack(**exc_extra_options, **self.extra_options) + except Exception as er: + if self.logger is not None: + self.logger.log(logging.ERROR, er, exc_info=er) + + async def __reject(self, **exc_extra_options: Any) -> None: + try: + await self.message.reject(**exc_extra_options, **self.extra_options) + except Exception as er: + if self.logger is not None: + self.logger.log(logging.ERROR, er, exc_info=er) diff --git a/faststream/nats/broker/registrator.py b/faststream/nats/broker/registrator.py index 4ff8f0fbda..f8feacb294 100644 --- a/faststream/nats/broker/registrator.py +++ b/faststream/nats/broker/registrator.py @@ -5,6 +5,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream.middlewares import AckPolicy from faststream.nats.helpers import StreamBuilder from faststream.nats.publisher.factory import create_publisher from faststream.nats.publisher.specified import SpecificationPublisher @@ -160,14 +161,10 @@ def subscriber( # type: ignore[override] int, Doc("Number of workers to process messages concurrently."), ] = 1, - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -222,9 +219,8 @@ def subscriber( # type: ignore[override] inbox_prefix=inbox_prefix, ack_first=ack_first, # subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=self._middlewares, broker_dependencies=self._dependencies, # AsyncAPI diff --git a/faststream/nats/fastapi/fastapi.py b/faststream/nats/fastapi/fastapi.py index e2c5ae3d98..aa449acde7 100644 --- a/faststream/nats/fastapi/fastapi.py +++ b/faststream/nats/fastapi/fastapi.py @@ -32,6 +32,7 @@ from faststream.__about__ import SERVICE_NAME from faststream._internal.constants import EMPTY from faststream._internal.fastapi.router import StreamRouter +from faststream.middlewares import AckPolicy from faststream.nats.broker import NatsBroker from faststream.nats.subscriber.specified import SpecificationSubscriber @@ -692,14 +693,10 @@ def subscriber( # type: ignore[override] int, Doc("Number of workers to process messages concurrently."), ] = 1, - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -871,8 +868,7 @@ def subscriber( # type: ignore[override] decoder=decoder, middlewares=middlewares, max_workers=max_workers, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/nats/parser.py b/faststream/nats/parser.py index 0f3b2f4c8c..3cb2c695a8 100644 --- a/faststream/nats/parser.py +++ b/faststream/nats/parser.py @@ -4,6 +4,7 @@ StreamMessage, decode_message, ) +from faststream.middlewares import AckPolicy from faststream.nats.message import ( NatsBatchMessage, NatsKvMessage, @@ -54,9 +55,9 @@ async def decode_message( class NatsParser(NatsBaseParser): """A class to parse NATS core messages.""" - def __init__(self, *, pattern: str, no_ack: bool) -> None: + def __init__(self, *, pattern: str, ack_policy: AckPolicy) -> None: super().__init__(pattern=pattern) - self.no_ack = no_ack + self.ack_policy = ack_policy async def parse_message( self, @@ -69,7 +70,7 @@ async def parse_message( headers = message.header or {} - if not self.no_ack: + if self.ack_policy is not AckPolicy.DO_NOTHING: message._ackd = True # prevent message from acking return NatsMessage( diff --git a/faststream/nats/publisher/producer.py b/faststream/nats/publisher/producer.py index f2ed4715e0..62245c2c7c 100644 --- a/faststream/nats/publisher/producer.py +++ b/faststream/nats/publisher/producer.py @@ -5,6 +5,7 @@ import nats from typing_extensions import override +from faststream import AckPolicy from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func from faststream.exceptions import FeatureNotSupportedException @@ -40,7 +41,7 @@ def __init__( parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - default = NatsParser(pattern="", no_ack=False) + default = NatsParser(pattern="", ack_policy=AckPolicy.REJECT_ON_ERROR) self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) @@ -111,7 +112,7 @@ def __init__( parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - default = NatsParser(pattern="", no_ack=False) + default = NatsParser(pattern="", ack_policy=AckPolicy.REJECT_ON_ERROR) self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) diff --git a/faststream/nats/router.py b/faststream/nats/router.py index 3b98073050..5d4f87b03b 100644 --- a/faststream/nats/router.py +++ b/faststream/nats/router.py @@ -16,6 +16,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream.middlewares import AckPolicy from faststream.nats.broker.registrator import NatsRegistrator if TYPE_CHECKING: @@ -250,14 +251,10 @@ def __init__( int, Doc("Number of workers to process messages concurrently."), ] = 1, - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -307,8 +304,7 @@ def __init__( parser=parser, decoder=decoder, middlewares=middlewares, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/nats/schemas/js_stream.py b/faststream/nats/schemas/js_stream.py index 0d9b23ac4f..3ad4fc2e4f 100644 --- a/faststream/nats/schemas/js_stream.py +++ b/faststream/nats/schemas/js_stream.py @@ -6,6 +6,7 @@ from faststream._internal.proto import NameRequired from faststream._internal.utils.path import compile_path +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from re import Pattern @@ -120,13 +121,10 @@ def __init__( "cluster may be available but for reads only.", ), ] = None, - no_ack: Annotated[ - bool, - Doc( - "Should stream acknowledge writes or not. Without acks publisher can't determine, does message " - "received by stream or not.", - ), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, template_owner: Optional[str] = None, duplicate_window: Annotated[ float, @@ -191,6 +189,7 @@ def __init__( super().__init__(name) subjects = subjects or [] + no_ack = ack_policy is AckPolicy.DO_NOTHING self.subjects = subjects self.declare = declare diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index 613a76a535..586f0db870 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -30,6 +30,7 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware + from faststream.middlewares import AckPolicy from faststream.nats.schemas import JStream, KvWatch, ObjWatch, PullSub @@ -59,9 +60,8 @@ def create_subscriber( max_workers: int, stream: Optional["JStream"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Any]"], # Specification information @@ -159,9 +159,8 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # Specification @@ -177,9 +176,8 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # Specification @@ -199,9 +197,8 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # Specification @@ -219,9 +216,8 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # Specification @@ -240,9 +236,8 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # Specification @@ -259,9 +254,8 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # Specification @@ -278,9 +272,8 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # Specification information diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index 9f15f00456..2c1e35cd56 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -8,12 +8,10 @@ Callable, Generic, Optional, - Union, cast, ) import anyio -from fast_depends.dependencies import Dependant from nats.errors import ConnectionClosedError, TimeoutError from nats.js.api import ConsumerConfig, ObjectInfo from typing_extensions import Doc, override @@ -22,6 +20,7 @@ from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import MsgType +from faststream.middlewares import AckPolicy from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer from faststream.nats.message import NatsMessage from faststream.nats.parser import ( @@ -41,6 +40,7 @@ from .state import ConnectedSubscriberState, EmptySubscriberState, SubscriberState if TYPE_CHECKING: + from fast_depends.dependencies import Dependant from nats.aio.msg import Msg from nats.aio.subscription import Subscription from nats.js import JetStreamContext @@ -82,10 +82,9 @@ def __init__( # Subscriber args default_parser: "AsyncCallable", default_decoder: "AsyncCallable", - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args title_: Optional[str], @@ -101,9 +100,8 @@ def __init__( default_parser=default_parser, default_decoder=default_decoder, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -235,10 +233,9 @@ def __init__( # Subscriber args default_parser: "AsyncCallable", default_decoder: "AsyncCallable", - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], # AsyncAPI args title_: Optional[str], @@ -253,9 +250,8 @@ def __init__( default_parser=default_parser, default_decoder=default_decoder, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -306,17 +302,16 @@ def __init__( queue: str, extra_options: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], description_: Optional[str], include_in_schema: bool, ) -> None: - parser_ = NatsParser(pattern=subject, no_ack=no_ack) + parser_ = NatsParser(pattern=subject, ack_policy=ack_policy) self.queue = queue @@ -328,9 +323,8 @@ def __init__( default_parser=parser_.parse_message, default_decoder=parser_.decode_message, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -416,10 +410,9 @@ def __init__( queue: str, extra_options: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -434,9 +427,8 @@ def __init__( queue=queue, extra_options=extra_options, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -474,10 +466,9 @@ def __init__( queue: str, extra_options: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -497,9 +488,8 @@ def __init__( default_parser=parser_.parse_message, default_decoder=parser_.decode_message, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -606,10 +596,9 @@ def __init__( queue: str, extra_options: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -625,9 +614,8 @@ def __init__( queue=queue, extra_options=extra_options, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -669,10 +657,9 @@ def __init__( config: "ConsumerConfig", extra_options: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -689,9 +676,8 @@ def __init__( extra_options=extra_options, queue="", # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -749,10 +735,9 @@ def __init__( config: "ConsumerConfig", extra_options: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], # AsyncAPI args title_: Optional[str], @@ -768,9 +753,8 @@ def __init__( config=config, extra_options=extra_options, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -814,10 +798,9 @@ def __init__( pull_sub: "PullSub", extra_options: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[list[Msg]]"], # AsyncAPI args title_: Optional[str], @@ -837,9 +820,8 @@ def __init__( default_parser=parser.parse_batch, default_decoder=parser.decode_batch, # Propagated args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI args @@ -931,7 +913,7 @@ def __init__( subject: str, config: "ConsumerConfig", kv_watch: "KvWatch", - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[KeyValue.Entry]"], # AsyncAPI args title_: Optional[str], @@ -945,9 +927,8 @@ def __init__( subject=subject, config=config, extra_options=None, - no_ack=True, + ack_policy=AckPolicy.DO_NOTHING, no_reply=True, - retry=False, default_parser=parser.parse_message, default_decoder=parser.decode_message, broker_middlewares=broker_middlewares, @@ -1085,7 +1066,7 @@ def __init__( subject: str, config: "ConsumerConfig", obj_watch: "ObjWatch", - broker_dependencies: Iterable[Dependant], + broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[list[Msg]]"], # AsyncAPI args title_: Optional[str], @@ -1101,9 +1082,8 @@ def __init__( subject=subject, config=config, extra_options=None, - no_ack=True, + ack_policy=AckPolicy.DO_NOTHING, no_reply=True, - retry=False, default_parser=parser.parse_message, default_decoder=parser.decode_message, broker_middlewares=broker_middlewares, diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index 4ee0d1602c..d709554993 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -11,6 +11,7 @@ from nats.aio.msg import Msg from typing_extensions import override +from faststream import AckPolicy from faststream._internal.subscriber.utils import resolve_custom_func from faststream._internal.testing.broker import TestBroker from faststream.exceptions import SubscriberNotFound @@ -70,7 +71,7 @@ class FakeProducer(NatsFastProducer): def __init__(self, broker: NatsBroker) -> None: self.broker = broker - default = NatsParser(pattern="", no_ack=False) + default = NatsParser(pattern="", ack_policy=AckPolicy.REJECT_ON_ERROR) self._parser = resolve_custom_func(broker._parser, default.parse_message) self._decoder = resolve_custom_func(broker._decoder, default.decode_message) diff --git a/faststream/rabbit/broker/registrator.py b/faststream/rabbit/broker/registrator.py index 20893edbd0..117aafc1de 100644 --- a/faststream/rabbit/broker/registrator.py +++ b/faststream/rabbit/broker/registrator.py @@ -4,6 +4,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream.middlewares import AckPolicy from faststream.rabbit.publisher.factory import create_publisher from faststream.rabbit.publisher.specified import SpecificationPublisher from faststream.rabbit.publisher.usecase import PublishKwargs @@ -57,6 +58,10 @@ def subscriber( # type: ignore[override] Optional["AnyDict"], Doc("Extra consumer arguments to use in `queue.consume(...)` method."), ] = None, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, # broker arguments dependencies: Annotated[ Iterable["Dependant"], @@ -74,14 +79,6 @@ def subscriber( # type: ignore[override] Iterable["SubscriberMiddleware[RabbitMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - Union[bool, int], - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, no_reply: Annotated[ bool, Doc( @@ -113,9 +110,8 @@ def subscriber( # type: ignore[override] exchange=RabbitExchange.validate(exchange), consume_args=consume_args, # subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=self._middlewares, broker_dependencies=self._dependencies, # AsyncAPI diff --git a/faststream/rabbit/fastapi/fastapi.py b/faststream/rabbit/fastapi/fastapi.py index 9240380079..6411dbc949 100644 --- a/faststream/rabbit/fastapi/fastapi.py +++ b/faststream/rabbit/fastapi/fastapi.py @@ -20,6 +20,7 @@ from faststream.__about__ import SERVICE_NAME from faststream._internal.constants import EMPTY from faststream._internal.fastapi.router import StreamRouter +from faststream.middlewares import AckPolicy from faststream.rabbit.broker.broker import RabbitBroker as RB from faststream.rabbit.schemas import ( RabbitExchange, @@ -511,14 +512,10 @@ def subscriber( # type: ignore[override] Iterable["SubscriberMiddleware[RabbitMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - Union[bool, int], - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -675,8 +672,7 @@ def subscriber( # type: ignore[override] parser=parser, decoder=decoder, middlewares=middlewares, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/rabbit/router.py b/faststream/rabbit/router.py index a11b5ddbb6..3edbf447b7 100644 --- a/faststream/rabbit/router.py +++ b/faststream/rabbit/router.py @@ -8,6 +8,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream.middlewares import AckPolicy from faststream.rabbit.broker.registrator import RabbitRegistrator if TYPE_CHECKING: @@ -229,14 +230,10 @@ def __init__( Iterable["SubscriberMiddleware[RabbitMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - Union[bool, int], - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -270,8 +267,7 @@ def __init__( parser=parser, decoder=decoder, middlewares=middlewares, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/rabbit/subscriber/factory.py b/faststream/rabbit/subscriber/factory.py index b82210bd3d..4554f9c9c5 100644 --- a/faststream/rabbit/subscriber/factory.py +++ b/faststream/rabbit/subscriber/factory.py @@ -1,5 +1,5 @@ from collections.abc import Iterable -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Optional from faststream.rabbit.subscriber.specified import SpecificationSubscriber @@ -9,6 +9,7 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware + from faststream.middlewares import AckPolicy from faststream.rabbit.schemas import RabbitExchange, RabbitQueue @@ -18,11 +19,10 @@ def create_subscriber( exchange: "RabbitExchange", consume_args: Optional["AnyDict"], # Subscriber args - no_ack: bool, no_reply: bool, - retry: Union[bool, int], broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[IncomingMessage]"], + ack_policy: "AckPolicy", # AsyncAPI args title_: Optional[str], description_: Optional[str], @@ -32,9 +32,8 @@ def create_subscriber( queue=queue, exchange=exchange, consume_args=consume_args, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, title_=title_, diff --git a/faststream/rabbit/subscriber/usecase.py b/faststream/rabbit/subscriber/usecase.py index aeae29d970..119938afb5 100644 --- a/faststream/rabbit/subscriber/usecase.py +++ b/faststream/rabbit/subscriber/usecase.py @@ -3,7 +3,6 @@ TYPE_CHECKING, Any, Optional, - Union, ) import anyio @@ -12,6 +11,7 @@ from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream.exceptions import SetupError +from faststream.middlewares import AckPolicy from faststream.rabbit.parser import AioPikaParser from faststream.rabbit.publisher.fake import RabbitFakePublisher from faststream.rabbit.schemas import BaseRMQInformation @@ -54,9 +54,8 @@ def __init__( exchange: "RabbitExchange", consume_args: Optional["AnyDict"], # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: Union[bool, int], broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[IncomingMessage]"], # AsyncAPI args @@ -70,9 +69,8 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -175,7 +173,7 @@ async def get_one( self, *, timeout: float = 5.0, - no_ack: bool = True, + ack_policy: AckPolicy = AckPolicy.REJECT_ON_ERROR, ) -> "Optional[RabbitMessage]": assert self._queue_obj, "You should start subscriber at first." # nosec B101 assert ( # nosec B101 @@ -185,6 +183,7 @@ async def get_one( sleep_interval = timeout / 10 raw_message: Optional[IncomingMessage] = None + no_ack = self.ack_policy is AckPolicy.DO_NOTHING with anyio.move_on_after(timeout): while ( # noqa: ASYNC110 raw_message := await self._queue_obj.get( diff --git a/faststream/redis/broker/registrator.py b/faststream/redis/broker/registrator.py index 9b10aae10e..1599a10d46 100644 --- a/faststream/redis/broker/registrator.py +++ b/faststream/redis/broker/registrator.py @@ -4,6 +4,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream.middlewares import AckPolicy from faststream.redis.message import UnifyRedisDict from faststream.redis.publisher.factory import create_publisher from faststream.redis.publisher.specified import SpecificationPublisher @@ -65,14 +66,10 @@ def subscriber( # type: ignore[override] Iterable["SubscriberMiddleware[UnifyRedisMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -104,9 +101,8 @@ def subscriber( # type: ignore[override] list=list, stream=stream, # subscriber args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=self._middlewares, broker_dependencies=self._dependencies, # AsyncAPI diff --git a/faststream/redis/fastapi/fastapi.py b/faststream/redis/fastapi/fastapi.py index ed97300b87..01a5432f5e 100644 --- a/faststream/redis/fastapi/fastapi.py +++ b/faststream/redis/fastapi/fastapi.py @@ -25,6 +25,7 @@ from faststream.__about__ import SERVICE_NAME from faststream._internal.constants import EMPTY from faststream._internal.fastapi.router import StreamRouter +from faststream.middlewares import AckPolicy from faststream.redis.broker.broker import RedisBroker as RB from faststream.redis.message import UnifyRedisDict from faststream.redis.schemas import ListSub, PubSub, StreamSub @@ -461,14 +462,10 @@ def subscriber( # type: ignore[override] Iterable["SubscriberMiddleware[UnifyRedisMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -625,8 +622,7 @@ def subscriber( # type: ignore[override] parser=parser, decoder=decoder, middlewares=middlewares, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/redis/router.py b/faststream/redis/router.py index f7e60051fe..32d937dcce 100644 --- a/faststream/redis/router.py +++ b/faststream/redis/router.py @@ -8,6 +8,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream.middlewares import AckPolicy from faststream.redis.broker.registrator import RedisRegistrator from faststream.redis.message import BaseMessage @@ -147,14 +148,10 @@ def __init__( Iterable["SubscriberMiddleware[UnifyRedisMessage]"], Doc("Subscriber middlewares to wrap incoming message processing."), ] = (), - retry: Annotated[ - bool, - Doc("Whether to `nack` message at processing exception."), - ] = False, - no_ack: Annotated[ - bool, - Doc("Whether to disable **FastStream** autoacknowledgement logic or not."), - ] = False, + ack_policy: Annotated[ + AckPolicy, + Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), + ] = AckPolicy.REJECT_ON_ERROR, no_reply: Annotated[ bool, Doc( @@ -188,8 +185,7 @@ def __init__( parser=parser, decoder=decoder, middlewares=middlewares, - retry=retry, - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, title=title, description=description, diff --git a/faststream/redis/schemas/stream_sub.py b/faststream/redis/schemas/stream_sub.py index 50a0b6d606..07488d5f86 100644 --- a/faststream/redis/schemas/stream_sub.py +++ b/faststream/redis/schemas/stream_sub.py @@ -3,6 +3,7 @@ from faststream._internal.proto import NameRequired from faststream.exceptions import SetupError +from faststream.middlewares import AckPolicy class StreamSub(NameRequired): @@ -27,11 +28,13 @@ def __init__( group: Optional[str] = None, consumer: Optional[str] = None, batch: bool = False, - no_ack: bool = False, + ack_policy: AckPolicy = AckPolicy.REJECT_ON_ERROR, last_id: Optional[str] = None, maxlen: Optional[int] = None, max_records: Optional[int] = None, ) -> None: + no_ack = ack_policy is AckPolicy.DO_NOTHING + if (group and not consumer) or (not group and consumer): msg = "You should specify `group` and `consumer` both" raise SetupError(msg) diff --git a/faststream/redis/subscriber/factory.py b/faststream/redis/subscriber/factory.py index 9238628332..e46d3d6646 100644 --- a/faststream/redis/subscriber/factory.py +++ b/faststream/redis/subscriber/factory.py @@ -18,6 +18,7 @@ from fast_depends.dependencies import Dependant from faststream._internal.types import BrokerMiddleware + from faststream.middlewares import AckPolicy from faststream.redis.message import UnifyRedisDict SubsciberType: TypeAlias = Union[ @@ -35,9 +36,8 @@ def create_subscriber( list: Union["ListSub", str, None], stream: Union["StreamSub", str, None], # Subscriber args - no_ack: bool = False, + ack_policy: "AckPolicy", no_reply: bool = False, - retry: bool = False, broker_dependencies: Iterable["Dependant"] = (), broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"] = (), # AsyncAPI args @@ -51,9 +51,8 @@ def create_subscriber( return AsyncAPIChannelSubscriber( channel=channel_sub, # basic args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # AsyncAPI args @@ -67,9 +66,8 @@ def create_subscriber( return AsyncAPIStreamBatchSubscriber( stream=stream_sub, # basic args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # AsyncAPI args @@ -80,9 +78,8 @@ def create_subscriber( return AsyncAPIStreamSubscriber( stream=stream_sub, # basic args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # AsyncAPI args @@ -96,9 +93,8 @@ def create_subscriber( return AsyncAPIListBatchSubscriber( list=list_sub, # basic args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # AsyncAPI args @@ -109,9 +105,8 @@ def create_subscriber( return AsyncAPIListSubscriber( list=list_sub, # basic args - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, # AsyncAPI args diff --git a/faststream/redis/subscriber/usecase.py b/faststream/redis/subscriber/usecase.py index 70ccaf73e9..995fc70454 100644 --- a/faststream/redis/subscriber/usecase.py +++ b/faststream/redis/subscriber/usecase.py @@ -54,6 +54,7 @@ CustomCallable, ) from faststream.message import StreamMessage as BrokerStreamMessage + from faststream.middlewares import AckPolicy TopicName: TypeAlias = bytes @@ -71,9 +72,8 @@ def __init__( default_parser: "AsyncCallable", default_decoder: "AsyncCallable", # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -85,9 +85,8 @@ def __init__( default_parser=default_parser, default_decoder=default_decoder, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -215,9 +214,8 @@ def __init__( *, channel: "PubSub", # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -230,9 +228,8 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -341,9 +338,8 @@ def __init__( default_parser: "AsyncCallable", default_decoder: "AsyncCallable", # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -355,9 +351,8 @@ def __init__( default_parser=default_parser, default_decoder=default_decoder, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -448,9 +443,8 @@ def __init__( *, list: ListSub, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -464,9 +458,8 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -497,9 +490,8 @@ def __init__( *, list: ListSub, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -513,9 +505,8 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -551,9 +542,8 @@ def __init__( default_parser: "AsyncCallable", default_decoder: "AsyncCallable", # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -565,9 +555,8 @@ def __init__( default_parser=default_parser, default_decoder=default_decoder, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -742,9 +731,8 @@ def __init__( *, stream: StreamSub, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -758,9 +746,8 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI @@ -811,9 +798,8 @@ def __init__( *, stream: StreamSub, # Subscriber args - no_ack: bool, + ack_policy: "AckPolicy", no_reply: bool, - retry: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], # AsyncAPI args @@ -827,9 +813,8 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - no_ack=no_ack, + ack_policy=ack_policy, no_reply=no_reply, - retry=retry, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, # AsyncAPI diff --git a/tests/brokers/base/fastapi.py b/tests/brokers/base/fastapi.py index 38c9475ae2..e1e9c15954 100644 --- a/tests/brokers/base/fastapi.py +++ b/tests/brokers/base/fastapi.py @@ -534,7 +534,6 @@ async def hello_router2() -> str: async def test_dependency_overrides(self, mock: Mock, queue: str) -> None: router = self.router_class() - router2 = self.router_class() def dep1() -> None: mock.not_call() @@ -547,10 +546,39 @@ def dep2() -> None: args, kwargs = self.get_subscriber_params(queue) - @router2.subscriber(*args, **kwargs) + @router.subscriber(*args, **kwargs) async def hello_router2(dep: None = Depends(dep1)) -> str: return "hi" + app.include_router(router) + + async with self.patch_broker(router.broker) as br: + with TestClient(app) as client: + assert client.app_state["broker"] is br + + r = await br.request( + "hi", + queue, + timeout=0.5, + ) + assert await r.decode() == "hi", r + + mock.assert_called_once() + assert not mock.not_call.called + + @pytest.mark.xfail(reason="https://github.com/airtai/faststream/issues/1742") + async def test_nested_router(self, mock: Mock, queue: str) -> None: + router = self.router_class() + router2 = self.router_class() + + app = FastAPI() + + args, kwargs = self.get_subscriber_params(queue) + + @router2.subscriber(*args, **kwargs) + async def hello_router2() -> str: + return "hi" + router.include_router(router2) app.include_router(router) diff --git a/tests/brokers/base/router.py b/tests/brokers/base/router.py index 68c8c8552c..1f50340fb9 100644 --- a/tests/brokers/base/router.py +++ b/tests/brokers/base/router.py @@ -447,9 +447,8 @@ def subscriber() -> None: ... sub = next(iter(pub_broker._subscribers)) publisher = next(iter(pub_broker._publishers)) - - assert len((*sub._broker_middlewares, *sub.calls[0].item_middlewares)) == 3 - assert len((*publisher._broker_middlewares, *publisher._middlewares)) == 3 + assert len((*sub._broker_middlewares, *sub.calls[0].item_middlewares)) == 5 + assert len((*publisher._broker_middlewares, *publisher._middlewares)) == 4 async def test_router_include_with_middlewares( self, @@ -473,8 +472,8 @@ def subscriber() -> None: ... publisher = next(iter(pub_broker._publishers)) sub_middlewares = (*sub._broker_middlewares, *sub.calls[0].item_middlewares) - assert len(sub_middlewares) == 3, sub_middlewares - assert len((*publisher._broker_middlewares, *publisher._middlewares)) == 3 + assert len(sub_middlewares) == 5, sub_middlewares + assert len((*publisher._broker_middlewares, *publisher._middlewares)) == 4 async def test_router_parser( self, diff --git a/tests/brokers/confluent/test_consume.py b/tests/brokers/confluent/test_consume.py index 6f61f01d5f..9bb954ee2f 100644 --- a/tests/brokers/confluent/test_consume.py +++ b/tests/brokers/confluent/test_consume.py @@ -4,6 +4,7 @@ import pytest +from faststream import AckPolicy from faststream.confluent import KafkaBroker from faststream.confluent.annotations import KafkaMessage from faststream.confluent.client import AsyncConfluentConsumer @@ -251,7 +252,9 @@ async def test_consume_no_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - args, kwargs = self.get_subscriber_params(queue, group_id="test", no_ack=True) + args, kwargs = self.get_subscriber_params( + queue, group_id="test", ack_policy=AckPolicy.DO_NOTHING + ) @consume_broker.subscriber(*args, **kwargs) async def handler(msg: KafkaMessage) -> None: diff --git a/tests/brokers/kafka/test_consume.py b/tests/brokers/kafka/test_consume.py index f5e5421fbd..cb0f32db43 100644 --- a/tests/brokers/kafka/test_consume.py +++ b/tests/brokers/kafka/test_consume.py @@ -5,6 +5,7 @@ import pytest from aiokafka import AIOKafkaConsumer +from faststream import AckPolicy from faststream.exceptions import AckMessage from faststream.kafka import KafkaBroker, TopicPartition from faststream.kafka.annotations import KafkaMessage @@ -296,7 +297,9 @@ async def test_consume_no_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue, group_id="test", no_ack=True) + @consume_broker.subscriber( + queue, group_id="test", ack_policy=AckPolicy.DO_NOTHING + ) async def handler(msg: KafkaMessage) -> None: event.set() diff --git a/tests/brokers/nats/test_consume.py b/tests/brokers/nats/test_consume.py index d7200e627f..d253bcb716 100644 --- a/tests/brokers/nats/test_consume.py +++ b/tests/brokers/nats/test_consume.py @@ -5,6 +5,7 @@ import pytest from nats.aio.msg import Msg +from faststream import AckPolicy from faststream.exceptions import AckMessage from faststream.nats import ConsumerConfig, JStream, NatsBroker, PullSub from faststream.nats.annotations import NatsMessage @@ -169,7 +170,7 @@ async def test_core_consume_no_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue, no_ack=True) + @consume_broker.subscriber(queue, ack_policy=AckPolicy.DO_NOTHING) async def handler(msg: NatsMessage) -> None: if not msg.raw_message._ackd: event.set() @@ -278,7 +279,7 @@ async def test_consume_no_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue, no_ack=True) + @consume_broker.subscriber(queue, ack_policy=AckPolicy.DO_NOTHING) async def handler(msg: NatsMessage) -> None: event.set() diff --git a/tests/brokers/rabbit/test_consume.py b/tests/brokers/rabbit/test_consume.py index 742daac225..4f51bb4899 100644 --- a/tests/brokers/rabbit/test_consume.py +++ b/tests/brokers/rabbit/test_consume.py @@ -5,6 +5,7 @@ import pytest from aio_pika import IncomingMessage, Message +from faststream import AckPolicy from faststream.exceptions import AckMessage, NackMessage, RejectMessage, SkipMessage from faststream.rabbit import RabbitBroker, RabbitExchange, RabbitQueue from faststream.rabbit.annotations import RabbitMessage @@ -26,7 +27,7 @@ async def test_consume_from_exchange( ) -> None: consume_broker = self.get_broker() - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) def h(m) -> None: event.set() @@ -56,7 +57,6 @@ async def test_consume_with_get_old( @consume_broker.subscriber( queue=RabbitQueue(name=queue, passive=True), exchange=RabbitExchange(name=exchange.name, passive=True), - retry=True, ) def h(m) -> None: event.set() @@ -92,7 +92,7 @@ async def test_consume_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage) -> None: event.set() @@ -126,7 +126,7 @@ async def test_consume_manual_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage) -> None: await msg.ack() event.set() @@ -160,7 +160,7 @@ async def test_consume_exception_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage) -> None: try: raise AckMessage @@ -196,7 +196,7 @@ async def test_consume_manual_nack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage): await msg.nack() event.set() @@ -231,7 +231,7 @@ async def test_consume_exception_nack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage) -> None: try: raise NackMessage @@ -267,7 +267,7 @@ async def test_consume_manual_reject( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage): await msg.reject() event.set() @@ -302,7 +302,7 @@ async def test_consume_exception_reject( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue=queue, exchange=exchange, retry=1) + @consume_broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage) -> None: try: raise RejectMessage @@ -386,7 +386,9 @@ async def test_consume_no_ack( ) -> None: consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue, exchange=exchange, retry=1, no_ack=True) + @consume_broker.subscriber( + queue, exchange=exchange, ack_policy=AckPolicy.DO_NOTHING + ) async def handler(msg: RabbitMessage) -> None: event.set() diff --git a/tests/brokers/rabbit/test_test_client.py b/tests/brokers/rabbit/test_test_client.py index 856ed80668..cf76669716 100644 --- a/tests/brokers/rabbit/test_test_client.py +++ b/tests/brokers/rabbit/test_test_client.py @@ -207,18 +207,18 @@ async def test_consume_manual_ack( consume2 = asyncio.Event() consume3 = asyncio.Event() - @broker.subscriber(queue=queue, exchange=exchange, retry=1) + @broker.subscriber(queue=queue, exchange=exchange) async def handler(msg: RabbitMessage) -> None: await msg.raw_message.ack() consume.set() - @broker.subscriber(queue=queue + "1", exchange=exchange, retry=1) + @broker.subscriber(queue=queue + "1", exchange=exchange) async def handler2(msg: RabbitMessage): await msg.raw_message.nack() consume2.set() raise ValueError - @broker.subscriber(queue=queue + "2", exchange=exchange, retry=1) + @broker.subscriber(queue=queue + "2", exchange=exchange) async def handler3(msg: RabbitMessage): await msg.raw_message.reject() consume3.set() diff --git a/tests/brokers/test_pushback.py b/tests/brokers/test_pushback.py deleted file mode 100644 index afb064ff69..0000000000 --- a/tests/brokers/test_pushback.py +++ /dev/null @@ -1,124 +0,0 @@ -from unittest.mock import AsyncMock - -import pytest - -from faststream._internal.subscriber.acknowledgement_watcher import ( - CounterWatcher, - EndlessWatcher, - WatcherContext, -) -from faststream.exceptions import NackMessage, SkipMessage - - -@pytest.fixture() -def message(): - return AsyncMock(message_id=1, committed=None) - - -@pytest.mark.asyncio() -async def test_push_back_correct(async_mock: AsyncMock, message) -> None: - watcher = CounterWatcher(3) - - context = WatcherContext( - message=message, - watcher=watcher, - ) - - async with context: - await async_mock() - - async_mock.assert_awaited_once() - message.ack.assert_awaited_once() - assert not watcher.memory.get(message.message_id) - - -@pytest.mark.asyncio() -async def test_push_back_endless_correct(async_mock: AsyncMock, message) -> None: - watcher = EndlessWatcher() - - context = WatcherContext( - message=message, - watcher=watcher, - ) - - async with context: - await async_mock() - - async_mock.assert_awaited_once() - message.ack.assert_awaited_once() - - -@pytest.mark.asyncio() -async def test_push_back_watcher(async_mock: AsyncMock, message) -> None: - watcher = CounterWatcher(3) - - context = WatcherContext( - message=message, - watcher=watcher, - ) - - async_mock.side_effect = ValueError("Ooops!") - - while not message.reject.called: - with pytest.raises(ValueError): # noqa: PT011 - async with context: - await async_mock() - - assert not message.ack.await_count - assert message.nack.await_count == 3 - message.reject.assert_awaited_once() - - -@pytest.mark.asyncio() -async def test_push_endless_back_watcher(async_mock: AsyncMock, message) -> None: - watcher = EndlessWatcher() - - context = WatcherContext( - message=message, - watcher=watcher, - ) - - async_mock.side_effect = ValueError("Ooops!") - - while message.nack.await_count < 10: - with pytest.raises(ValueError): # noqa: PT011 - async with context: - await async_mock() - - assert not message.ack.called - assert not message.reject.called - assert message.nack.await_count == 10 - - -@pytest.mark.asyncio() -async def test_ignore_skip(async_mock: AsyncMock, message) -> None: - watcher = CounterWatcher(3) - - context = WatcherContext( - message=message, - watcher=watcher, - ) - - async with context: - raise SkipMessage - - assert not message.nack.called - assert not message.reject.called - assert not message.ack.called - - -@pytest.mark.asyncio() -async def test_additional_params_with_handler_exception( - async_mock: AsyncMock, message -) -> None: - watcher = EndlessWatcher() - - context = WatcherContext( - message=message, - watcher=watcher, - ) - - async with context: - raise NackMessage(delay=5) - - message.nack.assert_called_with(delay=5) From f54e5ee9f978545be13d21f9bfadd0950a203ea0 Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Tue, 5 Nov 2024 18:21:08 +0300 Subject: [PATCH 33/48] refactor: use caps names for public enums --- faststream/_internal/broker/broker.py | 2 +- faststream/_internal/constants.py | 4 ++-- faststream/_internal/publisher/usecase.py | 2 +- faststream/_internal/subscriber/utils.py | 6 +++--- faststream/message/message.py | 14 +++++++------- faststream/message/source_type.py | 4 ++-- faststream/message/utils.py | 8 ++++---- .../middlewares/acknowledgement/middleware.py | 4 +++- faststream/middlewares/logging.py | 6 +++--- faststream/prometheus/consts.py | 6 +++--- faststream/prometheus/middleware.py | 2 +- faststream/redis/parser.py | 4 ++-- .../specification/asyncapi/v2_6_0/generate.py | 2 +- .../specification/asyncapi/v3_0_0/generate.py | 2 +- tests/prometheus/basic.py | 16 ++++++++-------- 15 files changed, 42 insertions(+), 40 deletions(-) diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index f4229be5c6..cf5959a908 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -388,7 +388,7 @@ async def _basic_request( ), parser=producer._parser, decoder=producer._decoder, - source_type=SourceType.Response, + source_type=SourceType.RESPONSE, ) return response_msg diff --git a/faststream/_internal/constants.py b/faststream/_internal/constants.py index 108e318f67..c81916ed95 100644 --- a/faststream/_internal/constants.py +++ b/faststream/_internal/constants.py @@ -7,8 +7,8 @@ class ContentTypes(str, Enum): """A class to represent content types.""" - text = "text/plain" - json = "application/json" + TEXT = "text/plain" + JSON = "application/json" class EmptyPlaceholder: diff --git a/faststream/_internal/publisher/usecase.py b/faststream/_internal/publisher/usecase.py index 049ed1e5a5..39a0da7cb9 100644 --- a/faststream/_internal/publisher/usecase.py +++ b/faststream/_internal/publisher/usecase.py @@ -187,7 +187,7 @@ async def _basic_request( ), parser=self._producer._parser, decoder=self._producer._decoder, - source_type=SourceType.Response, + source_type=SourceType.RESPONSE, ) return response_msg diff --git a/faststream/_internal/subscriber/utils.py b/faststream/_internal/subscriber/utils.py index fb9002c3a3..5f3d6719c2 100644 --- a/faststream/_internal/subscriber/utils.py +++ b/faststream/_internal/subscriber/utils.py @@ -37,7 +37,7 @@ async def process_msg( middlewares: Iterable["BaseMiddleware"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], - source_type: SourceType = SourceType.Consume, + source_type: SourceType = SourceType.CONSUME, ) -> None: ... @@ -47,7 +47,7 @@ async def process_msg( middlewares: Iterable["BaseMiddleware"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], - source_type: SourceType = SourceType.Consume, + source_type: SourceType = SourceType.CONSUME, ) -> "StreamMessage[MsgType]": ... @@ -56,7 +56,7 @@ async def process_msg( middlewares: Iterable["BaseMiddleware"], parser: Callable[[MsgType], Awaitable["StreamMessage[MsgType]"]], decoder: Callable[["StreamMessage[MsgType]"], "Any"], - source_type: SourceType = SourceType.Consume, + source_type: SourceType = SourceType.CONSUME, ) -> Optional["StreamMessage[MsgType]"]: if msg is None: return None diff --git a/faststream/message/message.py b/faststream/message/message.py index 7e19bf9c3c..cdba65b5bb 100644 --- a/faststream/message/message.py +++ b/faststream/message/message.py @@ -19,9 +19,9 @@ class AckStatus(str, Enum): - acked = "acked" - nacked = "nacked" - rejected = "rejected" + ACKED = "ACKED" + NACKED = "NACKED" + REJECTED = "REJECTED" class StreamMessage(Generic[MsgType]): @@ -39,7 +39,7 @@ def __init__( content_type: Optional[str] = None, correlation_id: Optional[str] = None, message_id: Optional[str] = None, - source_type: SourceType = SourceType.Consume, + source_type: SourceType = SourceType.CONSUME, ) -> None: self.raw_message = raw_message self.body = body @@ -85,12 +85,12 @@ async def decode(self) -> Optional["DecodedMessage"]: async def ack(self) -> None: if self.committed is None: - self.committed = AckStatus.acked + self.committed = AckStatus.ACKED async def nack(self) -> None: if self.committed is None: - self.committed = AckStatus.nacked + self.committed = AckStatus.NACKED async def reject(self) -> None: if self.committed is None: - self.committed = AckStatus.rejected + self.committed = AckStatus.REJECTED diff --git a/faststream/message/source_type.py b/faststream/message/source_type.py index 1741be23d6..b6e4f95fd9 100644 --- a/faststream/message/source_type.py +++ b/faststream/message/source_type.py @@ -2,8 +2,8 @@ class SourceType(str, Enum): - Consume = "Consume" + CONSUME = "CONSUME" """Message consumed by basic subscriber flow.""" - Response = "Response" + RESPONSE = "RESPONSE" """RPC response consumed.""" diff --git a/faststream/message/utils.py b/faststream/message/utils.py index 5483c27bb5..715c336bd1 100644 --- a/faststream/message/utils.py +++ b/faststream/message/utils.py @@ -32,10 +32,10 @@ def decode_message(message: "StreamMessage[Any]") -> "DecodedMessage": if content_type := getattr(message, "content_type", False): content_type = ContentTypes(cast(str, content_type)) - if content_type is ContentTypes.text: + if content_type is ContentTypes.TEXT: m = body.decode() - elif content_type is ContentTypes.json: + elif content_type is ContentTypes.JSON: m = json_loads(body) else: @@ -65,10 +65,10 @@ def encode_message( if isinstance(msg, str): return ( msg.encode(), - ContentTypes.text.value, + ContentTypes.TEXT.value, ) return ( dump_json(msg), - ContentTypes.json.value, + ContentTypes.JSON.value, ) diff --git a/faststream/middlewares/acknowledgement/middleware.py b/faststream/middlewares/acknowledgement/middleware.py index 2513768649..409bc28262 100644 --- a/faststream/middlewares/acknowledgement/middleware.py +++ b/faststream/middlewares/acknowledgement/middleware.py @@ -23,7 +23,9 @@ def __init__(self, ack_policy: AckPolicy, extra_options: "AnyDict") -> None: self.ack_policy = ack_policy self.extra_options = extra_options - def __call__(self, msg: Optional[Any], context: "ContextRepo") -> "_AcknowledgementMiddleware": + def __call__( + self, msg: Optional[Any], context: "ContextRepo" + ) -> "_AcknowledgementMiddleware": return _AcknowledgementMiddleware( msg, ack_policy=self.ack_policy, diff --git a/faststream/middlewares/logging.py b/faststream/middlewares/logging.py index 2c82299532..f0eeffbe4d 100644 --- a/faststream/middlewares/logging.py +++ b/faststream/middlewares/logging.py @@ -46,7 +46,7 @@ def __init__( ) -> None: super().__init__(msg, context=context) self.logger = logger - self._source_type = SourceType.Consume + self._source_type = SourceType.CONSUME async def consume_scope( self, @@ -55,7 +55,7 @@ async def consume_scope( ) -> "StreamMessage[Any]": source_type = self._source_type = msg._source_type - if source_type is not SourceType.Response: + if source_type is not SourceType.RESPONSE: self.logger.log( "Received", extra=self.context.get_local("log_context", {}), @@ -70,7 +70,7 @@ async def __aexit__( exc_tb: Optional["TracebackType"] = None, ) -> bool: """Asynchronously called after processing.""" - if self._source_type is not SourceType.Response: + if self._source_type is not SourceType.RESPONSE: c = self.context.get_local("log_context", {}) if exc_type: diff --git a/faststream/prometheus/consts.py b/faststream/prometheus/consts.py index 75cb8ba89a..8e592d14ae 100644 --- a/faststream/prometheus/consts.py +++ b/faststream/prometheus/consts.py @@ -11,7 +11,7 @@ PROCESSING_STATUS_BY_ACK_STATUS = { - AckStatus.acked: ProcessingStatus.acked, - AckStatus.nacked: ProcessingStatus.nacked, - AckStatus.rejected: ProcessingStatus.rejected, + AckStatus.ACKED: ProcessingStatus.acked, + AckStatus.NACKED: ProcessingStatus.nacked, + AckStatus.REJECTED: ProcessingStatus.rejected, } diff --git a/faststream/prometheus/middleware.py b/faststream/prometheus/middleware.py index 5d35708cfe..ffdea49d4f 100644 --- a/faststream/prometheus/middleware.py +++ b/faststream/prometheus/middleware.py @@ -88,7 +88,7 @@ async def consume_scope( call_next: "AsyncFuncAny", msg: "StreamMessage[Any]", ) -> Any: - if self._settings_provider is None or msg._source_type is SourceType.Response: + if self._settings_provider is None or msg._source_type is SourceType.RESPONSE: return await call_next(msg) messaging_system = self._settings_provider.messaging_system diff --git a/faststream/redis/parser.py b/faststream/redis/parser.py index 1d33c0e9f3..382d7be15b 100644 --- a/faststream/redis/parser.py +++ b/faststream/redis/parser.py @@ -200,7 +200,7 @@ def _parse_data( dump_json(body), { **first_msg_headers, - "content-type": ContentTypes.json.value, + "content-type": ContentTypes.JSON.value, }, batch_headers, ) @@ -239,7 +239,7 @@ def _parse_data( dump_json(body), { **first_msg_headers, - "content-type": ContentTypes.json.value, + "content-type": ContentTypes.JSON.value, }, batch_headers, ) diff --git a/faststream/specification/asyncapi/v2_6_0/generate.py b/faststream/specification/asyncapi/v2_6_0/generate.py index 7e6f4f5b67..2c6a3b3900 100644 --- a/faststream/specification/asyncapi/v2_6_0/generate.py +++ b/faststream/specification/asyncapi/v2_6_0/generate.py @@ -72,7 +72,7 @@ def get_app_schema( license=license_from_spec(license) if license else None, ), asyncapi=schema_version, - defaultContentType=ContentTypes.json.value, + defaultContentType=ContentTypes.JSON.value, id=identifier, tags=[tag_from_spec(tag) for tag in tags] if tags else None, externalDocs=docs_from_spec(external_docs) if external_docs else None, diff --git a/faststream/specification/asyncapi/v3_0_0/generate.py b/faststream/specification/asyncapi/v3_0_0/generate.py index 72b5e7966e..b14dca2772 100644 --- a/faststream/specification/asyncapi/v3_0_0/generate.py +++ b/faststream/specification/asyncapi/v3_0_0/generate.py @@ -98,7 +98,7 @@ def get_app_schema( externalDocs=docs_from_spec(external_docs) if external_docs else None, ), asyncapi=schema_version, - defaultContentType=ContentTypes.json.value, + defaultContentType=ContentTypes.JSON.value, id=identifier, servers=servers, channels=channels, diff --git a/tests/prometheus/basic.py b/tests/prometheus/basic.py index 22c13b1540..2c4d8a2028 100644 --- a/tests/prometheus/basic.py +++ b/tests/prometheus/basic.py @@ -42,17 +42,17 @@ def settings_provider_factory(self): ), ( pytest.param( - AckStatus.acked, + AckStatus.ACKED, RejectMessage, id="acked status with reject message exception", ), pytest.param( - AckStatus.acked, Exception, id="acked status with not handler exception" + AckStatus.ACKED, Exception, id="acked status with not handler exception" ), - pytest.param(AckStatus.acked, None, id="acked status without exception"), - pytest.param(AckStatus.nacked, None, id="nacked status without exception"), + pytest.param(AckStatus.ACKED, None, id="acked status without exception"), + pytest.param(AckStatus.NACKED, None, id="nacked status without exception"), pytest.param( - AckStatus.rejected, None, id="rejected status without exception" + AckStatus.REJECTED, None, id="rejected status without exception" ), ), ) @@ -83,11 +83,11 @@ async def handler(m=Context("message")): if exception_class: raise exception_class - if status == AckStatus.acked: + if status == AckStatus.ACKED: await message.ack() - elif status == AckStatus.nacked: + elif status == AckStatus.NACKED: await message.nack() - elif status == AckStatus.rejected: + elif status == AckStatus.REJECTED: await message.reject() async with broker: From 2a4c51d7219660d39b346016bd2216cdc44ad2fb Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Tue, 5 Nov 2024 23:00:25 +0300 Subject: [PATCH 34/48] refactor: little type changes --- faststream/_internal/fastapi/router.py | 1 + faststream/confluent/broker/broker.py | 2 +- faststream/kafka/broker/broker.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/faststream/_internal/fastapi/router.py b/faststream/_internal/fastapi/router.py index 910c8a0387..4828893324 100644 --- a/faststream/_internal/fastapi/router.py +++ b/faststream/_internal/fastapi/router.py @@ -327,6 +327,7 @@ async def start_broker_lifespan( else: warnings.warn( "Specifying 'lifespan_context' manually is no longer necessary with FastAPI >= 0.112.2.", + category=RuntimeWarning, stacklevel=2, ) diff --git a/faststream/confluent/broker/broker.py b/faststream/confluent/broker/broker.py index 8eb7aefd87..b7853ad014 100644 --- a/faststream/confluent/broker/broker.py +++ b/faststream/confluent/broker/broker.py @@ -524,7 +524,7 @@ async def publish( # type: ignore[override] correlation_id=correlation_id or gen_cor_id(), _publish_type=PublishType.Publish, ) - return await super()._basic_publish(cmd, producer=self._producer) + await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] diff --git a/faststream/kafka/broker/broker.py b/faststream/kafka/broker/broker.py index 3564d52c9c..ad50958ace 100644 --- a/faststream/kafka/broker/broker.py +++ b/faststream/kafka/broker/broker.py @@ -744,7 +744,7 @@ async def publish( # type: ignore[override] correlation_id=correlation_id or gen_cor_id(), _publish_type=PublishType.Publish, ) - return await super()._basic_publish(cmd, producer=self._producer) + await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] From b9bcb80bb1f2e2e7c368a1f9c3431099154e6861 Mon Sep 17 00:00:00 2001 From: Maxim Martynov Date: Wed, 6 Nov 2024 19:13:32 +0300 Subject: [PATCH 35/48] Allow using aiokafka 0.12 (#1896) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8962231b58..152163acd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ # public distributions rabbit = ["aio-pika>=9,<10"] -kafka = ["aiokafka>=0.9,<0.12"] +kafka = ["aiokafka>=0.9,<0.13"] confluent = [ "confluent-kafka>=2,<3; python_version < '3.13'", From 65ba4a443ebca89bbf5163f49ff927e90ba86ab9 Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Wed, 6 Nov 2024 20:45:15 +0300 Subject: [PATCH 36/48] refactor: new setup logic --- faststream/_internal/application.py | 4 +- faststream/_internal/broker/abc_broker.py | 8 +- faststream/_internal/broker/broker.py | 188 +++++++----------- faststream/_internal/broker/pub_base.py | 97 +++++++++ faststream/_internal/broker/router.py | 4 + faststream/_internal/cli/utils/logs.py | 10 +- faststream/_internal/fastapi/route.py | 6 +- faststream/_internal/log/logging.py | 3 +- faststream/_internal/proto.py | 4 +- faststream/_internal/publisher/proto.py | 10 +- faststream/_internal/publisher/usecase.py | 38 ++-- faststream/_internal/setup/logger.py | 163 --------------- faststream/_internal/setup/state.py | 99 --------- .../_internal/{setup => state}/__init__.py | 14 +- faststream/_internal/state/broker.py | 80 ++++++++ .../{setup => state}/fast_depends.py | 20 +- faststream/_internal/state/logger/__init__.py | 9 + .../_internal/state/logger/logger_proxy.py | 102 ++++++++++ .../_internal/state/logger/params_storage.py | 72 +++++++ faststream/_internal/state/logger/state.py | 72 +++++++ faststream/_internal/state/pointer.py | 19 ++ faststream/_internal/state/producer.py | 29 +++ .../_internal/{setup => state}/proto.py | 0 faststream/_internal/subscriber/call_item.py | 6 +- .../_internal/subscriber/call_wrapper/call.py | 4 +- faststream/_internal/subscriber/proto.py | 9 +- faststream/_internal/subscriber/usecase.py | 28 ++- faststream/_internal/testing/broker.py | 30 ++- faststream/confluent/broker/broker.py | 29 ++- faststream/confluent/broker/logging.py | 11 +- faststream/confluent/broker/registrator.py | 4 +- faststream/confluent/client.py | 2 +- faststream/confluent/publisher/producer.py | 23 ++- faststream/confluent/publisher/state.py | 50 +++++ faststream/confluent/publisher/usecase.py | 6 +- faststream/confluent/response.py | 2 +- faststream/confluent/subscriber/usecase.py | 23 +-- faststream/confluent/testing.py | 20 +- faststream/kafka/broker/broker.py | 28 ++- faststream/kafka/broker/logging.py | 11 +- faststream/kafka/broker/registrator.py | 4 +- faststream/kafka/publisher/producer.py | 26 ++- faststream/kafka/publisher/state.py | 49 +++++ faststream/kafka/publisher/usecase.py | 6 +- faststream/kafka/response.py | 2 +- faststream/kafka/subscriber/usecase.py | 21 +- faststream/kafka/testing.py | 18 +- faststream/middlewares/logging.py | 2 +- faststream/nats/broker/broker.py | 23 +-- faststream/nats/broker/logging.py | 11 +- faststream/nats/broker/registrator.py | 4 +- faststream/nats/publisher/producer.py | 1 - faststream/nats/publisher/usecase.py | 6 +- faststream/nats/response.py | 2 +- faststream/nats/subscriber/usecase.py | 28 +-- faststream/nats/testing.py | 17 +- faststream/prometheus/middleware.py | 2 +- faststream/rabbit/broker/broker.py | 46 ++--- faststream/rabbit/broker/logging.py | 11 +- faststream/rabbit/broker/registrator.py | 4 +- faststream/rabbit/helpers/declarer.py | 28 ++- faststream/rabbit/helpers/state.py | 35 ++++ faststream/rabbit/publisher/producer.py | 37 +++- faststream/rabbit/publisher/usecase.py | 8 +- faststream/rabbit/response.py | 2 +- faststream/rabbit/schemas/exchange.py | 8 + faststream/rabbit/schemas/queue.py | 8 + faststream/rabbit/subscriber/usecase.py | 20 +- faststream/rabbit/testing.py | 11 +- faststream/redis/broker/broker.py | 23 ++- faststream/redis/broker/logging.py | 11 +- faststream/redis/broker/registrator.py | 4 +- faststream/redis/helpers/__init__.py | 0 faststream/redis/helpers/state.py | 27 +++ faststream/redis/publisher/producer.py | 25 ++- faststream/redis/publisher/usecase.py | 14 +- faststream/redis/response.py | 2 +- faststream/redis/subscriber/usecase.py | 25 +-- faststream/redis/testing.py | 11 +- faststream/response/publish_type.py | 6 +- faststream/response/response.py | 2 +- tests/asgi/testcase.py | 2 +- tests/brokers/base/router.py | 4 +- tests/brokers/base/testclient.py | 6 +- tests/brokers/rabbit/specific/test_declare.py | 12 +- tests/cli/rabbit/test_logs.py | 2 +- tests/cli/test_publish.py | 4 +- tests/opentelemetry/basic.py | 4 +- tests/prometheus/confluent/test_provider.py | 4 +- tests/prometheus/kafka/test_provider.py | 4 +- tests/prometheus/redis/test_provider.py | 4 +- 91 files changed, 1225 insertions(+), 748 deletions(-) create mode 100644 faststream/_internal/broker/pub_base.py delete mode 100644 faststream/_internal/setup/logger.py delete mode 100644 faststream/_internal/setup/state.py rename faststream/_internal/{setup => state}/__init__.py (53%) create mode 100644 faststream/_internal/state/broker.py rename faststream/_internal/{setup => state}/fast_depends.py (55%) create mode 100644 faststream/_internal/state/logger/__init__.py create mode 100644 faststream/_internal/state/logger/logger_proxy.py create mode 100644 faststream/_internal/state/logger/params_storage.py create mode 100644 faststream/_internal/state/logger/state.py create mode 100644 faststream/_internal/state/pointer.py create mode 100644 faststream/_internal/state/producer.py rename faststream/_internal/{setup => state}/proto.py (100%) create mode 100644 faststream/confluent/publisher/state.py create mode 100644 faststream/kafka/publisher/state.py create mode 100644 faststream/rabbit/helpers/state.py create mode 100644 faststream/redis/helpers/__init__.py create mode 100644 faststream/redis/helpers/state.py diff --git a/faststream/_internal/application.py b/faststream/_internal/application.py index 056b11930c..d74f1c4a76 100644 --- a/faststream/_internal/application.py +++ b/faststream/_internal/application.py @@ -16,7 +16,7 @@ from faststream._internal.constants import EMPTY from faststream._internal.context import ContextRepo from faststream._internal.log import logger -from faststream._internal.setup.state import FastDependsData +from faststream._internal.state import DIState from faststream._internal.utils import apply_types from faststream._internal.utils.functions import ( drop_response_type, @@ -98,7 +98,7 @@ def _init_setupable_( # noqa: PLW3201 serializer = PydanticSerializer() - self._state = FastDependsData( + self._state = DIState( use_fastdepends=True, get_dependent=None, call_decorators=(), diff --git a/faststream/_internal/broker/abc_broker.py b/faststream/_internal/broker/abc_broker.py index 09be9f317f..b50b0195c5 100644 --- a/faststream/_internal/broker/abc_broker.py +++ b/faststream/_internal/broker/abc_broker.py @@ -37,7 +37,7 @@ def __init__( self._publishers = [] self._dependencies = dependencies - self._middlewares = middlewares + self.middlewares = middlewares self._parser = parser self._decoder = decoder @@ -46,7 +46,7 @@ def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: Current middleware will be used as a most inner of already existed ones. """ - self._middlewares = (*self._middlewares, middleware) + self.middlewares = (*self.middlewares, middleware) for sub in self._subscribers: sub.add_middleware(middleware) @@ -91,7 +91,7 @@ def include_router( h.include_in_schema = include_in_schema h._broker_middlewares = ( - *self._middlewares, + *self.middlewares, *middlewares, *h._broker_middlewares, ) @@ -111,7 +111,7 @@ def include_router( p.include_in_schema = include_in_schema p._broker_middlewares = ( - *self._middlewares, + *self.middlewares, *middlewares, *p._broker_middlewares, ) diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index cf5959a908..73f7aea2c5 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -1,6 +1,5 @@ from abc import abstractmethod from collections.abc import Iterable, Sequence -from functools import partial from typing import ( TYPE_CHECKING, Annotated, @@ -13,21 +12,21 @@ ) from fast_depends import Provider -from fast_depends.pydantic import PydanticSerializer from typing_extensions import Doc, Self from faststream._internal.constants import EMPTY from faststream._internal.context.repository import ContextRepo -from faststream._internal.setup import ( - EmptyState, - FastDependsData, +from faststream._internal.state import ( + DIState, LoggerState, SetupAble, - SetupState, ) -from faststream._internal.setup.state import BaseState +from faststream._internal.state.broker import ( + BrokerState, + InitialBrokerState, +) +from faststream._internal.state.producer import ProducerUnset from faststream._internal.subscriber.proto import SubscriberProto -from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import ( AsyncCustomCallable, BrokerMiddleware, @@ -36,11 +35,10 @@ MsgType, ) from faststream._internal.utils.functions import to_async -from faststream.exceptions import NOT_CONNECTED_YET -from faststream.message.source_type import SourceType from faststream.middlewares.logging import CriticalLogMiddleware from .abc_broker import ABCBroker +from .pub_base import BrokerPublishMixin if TYPE_CHECKING: from types import TracebackType @@ -53,7 +51,6 @@ ProducerProto, PublisherProto, ) - from faststream.response.response import PublishCommand from faststream.security import BaseSecurity from faststream.specification.schema.tag import Tag, TagDict @@ -61,14 +58,14 @@ class BrokerUsecase( ABCBroker[MsgType], SetupAble, + BrokerPublishMixin[MsgType], Generic[MsgType, ConnectionType], ): """A class representing a broker async use case.""" url: Union[str, Sequence[str]] _connection: Optional[ConnectionType] - _producer: Optional["ProducerProto"] - _state: BaseState + _producer: "ProducerProto" def __init__( self, @@ -157,27 +154,27 @@ def __init__( ) self.running = False - self.graceful_timeout = graceful_timeout self._connection_kwargs = connection_kwargs self._connection = None - self._producer = None - self._middlewares = ( + self.middlewares = ( CriticalLogMiddleware(logger_state), - *self._middlewares, + *self.middlewares, ) - self._state = EmptyState( - depends_params=FastDependsData( + self._state: BrokerState = InitialBrokerState( + di_state=DIState( use_fastdepends=apply_types, get_dependent=_get_dependant, call_decorators=_call_decorators, - serializer=PydanticSerializer() if serializer is EMPTY else serializer, + serializer=serializer, provider=Provider(), context=ContextRepo(), ), logger_state=logger_state, + graceful_timeout=graceful_timeout, + producer=ProducerUnset(), ) # AsyncAPI information @@ -189,12 +186,16 @@ def __init__( self.security = security @property - def context(self) -> ContextRepo: - return self._state.depends_params.context + def _producer(self) -> "ProducerProto": + return self._state.producer + + @property + def context(self) -> "ContextRepo": + return self._state.di_state.context @property def provider(self) -> Provider: - return self._state.depends_params.provider + return self._state.di_state.provider async def __aenter__(self) -> "Self": await self.connect() @@ -212,12 +213,19 @@ async def __aexit__( async def start(self) -> None: """Start the broker async use case.""" # TODO: filter by already running handlers after TestClient refactor - for handler in self._subscribers: + for subscriber in self._subscribers: + log_context = subscriber.get_log_context(None) + log_context.pop("message_id", None) + self._state.logger_state.params_storage.setup_log_contest(log_context) + + self._state._setup_logger_state() + + for subscriber in self._subscribers: self._state.logger_state.log( - f"`{handler.call_name}` waiting for messages", - extra=handler.get_log_context(None), + f"`{subscriber.call_name}` waiting for messages", + extra=subscriber.get_log_context(None), ) - await handler.start() + await subscriber.start() async def connect(self, **kwargs: Any) -> ConnectionType: """Connect to a remote server.""" @@ -233,42 +241,42 @@ async def _connect(self) -> ConnectionType: """Connect to a resource.""" raise NotImplementedError - def _setup(self, di_state: Optional[FastDependsData] = None) -> None: - """Prepare all Broker entities to startup.""" - if not self._state: - if di_state is not None: - new_state = SetupState( - logger_state=self._state.logger_state, - depends_params=FastDependsData( - use_fastdepends=self._state.depends_params.use_fastdepends, - call_decorators=self._state.depends_params.call_decorators, - get_dependent=self._state.depends_params.get_dependent, - # from parent - serializer=di_state.serializer, - provider=di_state.provider, - context=di_state.context, - ), - ) - - else: - # Fallback to default state if there no - # parent container like FastStream object - new_state = self._state.copy_to_state(SetupState) - - self._state = new_state + def _setup(self, di_state: Optional[DIState] = None) -> None: + """Prepare all Broker entities to startup. - if not self.running: - self.running = True + Method should be idempotent due could be called twice + """ + broker_serializer = self._state.di_state.serializer + + if di_state is not None: + if broker_serializer is EMPTY: + broker_serializer = di_state.serializer + + self._state.di_state.update( + serializer=broker_serializer, + provider=di_state.provider, + context=di_state.context, + ) - for h in self._subscribers: - log_context = h.get_log_context(None) - log_context.pop("message_id", None) - self._state.logger_state.params_storage.setup_log_contest(log_context) + else: + # Fallback to default state if there no + # parent container like FastStream object + if broker_serializer is EMPTY: + from fast_depends.pydantic import PydanticSerializer - self._state._setup() + broker_serializer = PydanticSerializer() - # TODO: why we can't move it to running? - # TODO: can we setup subscriber in running broker automatically? + self._state.di_state.update( + serializer=broker_serializer, + ) + + self._state._setup() + + # TODO: move to start + if not self.running: + self.running = True + + # TODO: move setup to object creation for h in self._subscribers: self.setup_subscriber(h) @@ -298,12 +306,8 @@ def setup_publisher( @property def _subscriber_setup_extra(self) -> "AnyDict": return { - "logger": self._state.logger_state.logger.logger, - "producer": self._producer, - "graceful_timeout": self.graceful_timeout, "extra_context": { "broker": self, - "logger": self._state.logger_state.logger.logger, }, # broker options "broker_parser": self._parser, @@ -334,64 +338,6 @@ async def close( self.running = False - async def _basic_publish( - self, - cmd: "PublishCommand", - *, - producer: Optional["ProducerProto"], - ) -> Optional[Any]: - """Publish message directly.""" - assert producer, NOT_CONNECTED_YET # nosec B101 - - publish = producer.publish - - for m in self._middlewares: - publish = partial(m(None, context=self.context).publish_scope, publish) - - return await publish(cmd) - - async def _basic_publish_batch( - self, - cmd: "PublishCommand", - *, - producer: Optional["ProducerProto"], - ) -> None: - """Publish a messages batch directly.""" - assert producer, NOT_CONNECTED_YET # nosec B101 - - publish = producer.publish_batch - - for m in self._middlewares: - publish = partial(m(None, context=self.context).publish_scope, publish) - - await publish(cmd) - - async def _basic_request( - self, - cmd: "PublishCommand", - *, - producer: Optional["ProducerProto"], - ) -> Any: - """Publish message directly.""" - assert producer, NOT_CONNECTED_YET # nosec B101 - - request = producer.request - for m in self._middlewares: - request = partial(m(None, context=self.context).publish_scope, request) - - published_msg = await request(cmd) - - response_msg: Any = await process_msg( - msg=published_msg, - middlewares=( - m(published_msg, context=self.context) for m in self._middlewares - ), - parser=producer._parser, - decoder=producer._decoder, - source_type=SourceType.RESPONSE, - ) - return response_msg - @abstractmethod async def ping(self, timeout: Optional[float]) -> bool: """Check connection alive.""" diff --git a/faststream/_internal/broker/pub_base.py b/faststream/_internal/broker/pub_base.py new file mode 100644 index 0000000000..000f007d52 --- /dev/null +++ b/faststream/_internal/broker/pub_base.py @@ -0,0 +1,97 @@ +from abc import abstractmethod +from collections.abc import Iterable +from functools import partial +from typing import TYPE_CHECKING, Any, Generic, Optional + +from faststream._internal.subscriber.utils import process_msg +from faststream._internal.types import MsgType +from faststream.message.source_type import SourceType + +if TYPE_CHECKING: + from faststream._internal.basic_types import SendableMessage + from faststream._internal.context import ContextRepo + from faststream._internal.publisher.proto import ProducerProto + from faststream._internal.types import BrokerMiddleware + from faststream.response import PublishCommand + + +class BrokerPublishMixin(Generic[MsgType]): + middlewares: Iterable["BrokerMiddleware[MsgType]"] + context: "ContextRepo" + + @abstractmethod + async def publish( + self, + message: "SendableMessage", + queue: str, + /, + ) -> None: + raise NotImplementedError + + async def _basic_publish( + self, + cmd: "PublishCommand", + *, + producer: "ProducerProto", + ) -> Optional[Any]: + publish = producer.publish + + for m in self.middlewares: + publish = partial(m(None, context=self.context).publish_scope, publish) + + return await publish(cmd) + + @abstractmethod + async def publish_batch( + self, + *messages: "SendableMessage", + queue: str, + ) -> None: + raise NotImplementedError + + async def _basic_publish_batch( + self, + cmd: "PublishCommand", + *, + producer: "ProducerProto", + ) -> None: + publish = producer.publish_batch + + for m in self.middlewares: + publish = partial(m(None, context=self.context).publish_scope, publish) + + await publish(cmd) + + @abstractmethod + async def request( + self, + message: "SendableMessage", + queue: str, + /, + timeout: float = 0.5, + ) -> Any: + raise NotImplementedError + + async def _basic_request( + self, + cmd: "PublishCommand", + *, + producer: "ProducerProto", + ) -> Any: + request = producer.request + + for m in self.middlewares: + request = partial(m(None, context=self.context).publish_scope, request) + + published_msg = await request(cmd) + + response_msg: Any = await process_msg( + msg=published_msg, + middlewares=( + m(published_msg, context=self.context) for m in self.middlewares + ), + parser=producer._parser, + decoder=producer._decoder, + source_type=SourceType.RESPONSE, + ) + return response_msg diff --git a/faststream/_internal/broker/router.py b/faststream/_internal/broker/router.py index a6a49fae3a..6daa2f245a 100644 --- a/faststream/_internal/broker/router.py +++ b/faststream/_internal/broker/router.py @@ -23,6 +23,8 @@ class ArgsContainer: """Class to store any arguments.""" + __slots__ = ("args", "kwargs") + args: Iterable[Any] kwargs: "AnyDict" @@ -38,6 +40,8 @@ def __init__( class SubscriberRoute(ArgsContainer): """A generic class to represent a broker route.""" + __slots__ = ("args", "call", "kwargs", "publishers") + call: Callable[..., Any] publishers: Iterable[Any] diff --git a/faststream/_internal/cli/utils/logs.py b/faststream/_internal/cli/utils/logs.py index f1656686a4..cd2aa47ea5 100644 --- a/faststream/_internal/cli/utils/logs.py +++ b/faststream/_internal/cli/utils/logs.py @@ -1,10 +1,9 @@ import logging from collections import defaultdict from enum import Enum -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Union if TYPE_CHECKING: - from faststream._internal.basic_types import LoggerProto from faststream.app import FastStream @@ -69,9 +68,4 @@ def set_log_level(level: int, app: "FastStream") -> None: if app.logger and getattr(app.logger, "setLevel", None): app.logger.setLevel(level) # type: ignore[attr-defined] - if app.broker: - broker_logger: Optional[LoggerProto] = ( - app.broker._state.logger_state.logger.logger - ) - if broker_logger is not None and getattr(broker_logger, "setLevel", None): - broker_logger.setLevel(level) # type: ignore[attr-defined] + app.broker._state.logger_state.set_level(level) diff --git a/faststream/_internal/fastapi/route.py b/faststream/_internal/fastapi/route.py index f45b64194e..a132daf3f9 100644 --- a/faststream/_internal/fastapi/route.py +++ b/faststream/_internal/fastapi/route.py @@ -35,7 +35,7 @@ from fastapi.types import IncEx from faststream._internal.basic_types import AnyDict - from faststream._internal.setup import FastDependsData + from faststream._internal.state import DIState from faststream.message import StreamMessage as NativeMessage @@ -76,7 +76,7 @@ def wrap_callable_to_fastapi_compatible( response_model_exclude_unset: bool, response_model_exclude_defaults: bool, response_model_exclude_none: bool, - state: "FastDependsData", + state: "DIState", ) -> Callable[["NativeMessage[Any]"], Awaitable[Any]]: __magic_attr = "__faststream_consumer__" @@ -120,7 +120,7 @@ def build_faststream_to_fastapi_parser( response_model_exclude_unset: bool, response_model_exclude_defaults: bool, response_model_exclude_none: bool, - state: "FastDependsData", + state: "DIState", ) -> Callable[["NativeMessage[Any]"], Awaitable[Any]]: """Creates a session for handling requests.""" assert dependent.call # nosec B101 diff --git a/faststream/_internal/log/logging.py b/faststream/_internal/log/logging.py index 7ffc83ded3..2fa346ca8a 100644 --- a/faststream/_internal/log/logging.py +++ b/faststream/_internal/log/logging.py @@ -58,9 +58,10 @@ def get_broker_logger( message_id_ln: int, fmt: str, context: "ContextRepo", + log_level: int, ) -> logging.Logger: logger = logging.getLogger(f"faststream.access.{name}") - logger.setLevel(logging.INFO) + logger.setLevel(log_level) logger.propagate = False logger.addFilter(ExtendedFilter(default_context, message_id_ln, context=context)) handler = logging.StreamHandler(stream=sys.stdout) diff --git a/faststream/_internal/proto.py b/faststream/_internal/proto.py index b75266d087..615dec872b 100644 --- a/faststream/_internal/proto.py +++ b/faststream/_internal/proto.py @@ -1,10 +1,8 @@ from abc import abstractmethod from typing import Any, Optional, Protocol, TypeVar, Union, overload -from .setup import SetupAble - -class Endpoint(SetupAble, Protocol): +class Endpoint(Protocol): @abstractmethod def add_prefix(self, prefix: str) -> None: ... diff --git a/faststream/_internal/publisher/proto.py b/faststream/_internal/publisher/proto.py index fef4cb1b68..49e7b3151c 100644 --- a/faststream/_internal/publisher/proto.py +++ b/faststream/_internal/publisher/proto.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage - from faststream._internal.setup import SetupState + from faststream._internal.state import BrokerState from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -42,6 +42,12 @@ async def publish_batch(self, cmd: "PublishCommand") -> None: ... +class ProducerFactory(Protocol): + def __call__( + self, parser: "AsyncCallable", decoder: "AsyncCallable" + ) -> ProducerProto: ... + + class BasePublisherProto(Protocol): @abstractmethod async def publish( @@ -103,7 +109,7 @@ def _setup( # type: ignore[override] self, *, producer: Optional["ProducerProto"], - state: "SetupState", + state: "BrokerState", ) -> None: ... @abstractmethod diff --git a/faststream/_internal/publisher/usecase.py b/faststream/_internal/publisher/usecase.py index 39a0da7cb9..d487e54859 100644 --- a/faststream/_internal/publisher/usecase.py +++ b/faststream/_internal/publisher/usecase.py @@ -16,6 +16,7 @@ from typing_extensions import Doc, override from faststream._internal.publisher.proto import PublisherProto +from faststream._internal.state.producer import ProducerUnset from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import ( @@ -23,7 +24,6 @@ P_HandlerParams, T_HandlerReturn, ) -from faststream.exceptions import NOT_CONNECTED_YET from faststream.message.source_type import SourceType from faststream.specification.asyncapi.message import ( get_model_schema, @@ -33,7 +33,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict from faststream._internal.publisher.proto import ProducerProto - from faststream._internal.setup import SetupState + from faststream._internal.state import BrokerState from faststream._internal.types import ( BrokerMiddleware, PublisherMiddleware, @@ -80,9 +80,10 @@ def __init__( ], ) -> None: self.calls = [] - self._middlewares = middlewares + self.middlewares = middlewares self._broker_middlewares = broker_middlewares - self._producer = None + + self._producer: ProducerProto = ProducerUnset() self._fake_handler = False self.mock = None @@ -98,10 +99,13 @@ def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: @override def _setup( # type: ignore[override] - self, *, producer: Optional["ProducerProto"], state: Optional["SetupState"] + self, + *, + producer: "ProducerProto", + state: Optional["BrokerState"], ) -> None: - self._producer = producer self._state = state + self._producer = producer def set_test( self, @@ -144,19 +148,17 @@ async def _basic_publish( *, _extra_middlewares: Iterable["PublisherMiddleware"], ) -> Any: - assert self._producer, NOT_CONNECTED_YET # nosec B101 - pub: Callable[..., Awaitable[Any]] = self._producer.publish for pub_m in chain( ( _extra_middlewares or ( - m(None, context=self._state.depends_params.context).publish_scope + m(None, context=self._state.di_state.context).publish_scope for m in self._broker_middlewares ) ), - self._middlewares, + self.middlewares, ): pub = partial(pub_m, pub) @@ -166,15 +168,13 @@ async def _basic_request( self, cmd: "PublishCommand", ) -> Optional[Any]: - assert self._producer, NOT_CONNECTED_YET # nosec B101 - - context = self._state.depends_params.context + context = self._state.di_state.context request = self._producer.request for pub_m in chain( (m(None, context=context).publish_scope for m in self._broker_middlewares), - self._middlewares, + self.middlewares, ): request = partial(pub_m, request) @@ -197,19 +197,17 @@ async def _basic_publish_batch( *, _extra_middlewares: Iterable["PublisherMiddleware"], ) -> Optional[Any]: - assert self._producer, NOT_CONNECTED_YET # nosec B101 - pub = self._producer.publish_batch for pub_m in chain( ( _extra_middlewares or ( - m(None, context=self._state.depends_params.context).publish_scope + m(None, context=self._state.di_state.context).publish_scope for m in self._broker_middlewares ) ), - self._middlewares, + self.middlewares, ): pub = partial(pub_m, pub) @@ -235,8 +233,8 @@ def get_payloads(self) -> list[tuple["AnyDict", str]]: for call in self.calls: call_model = build_call_model( call, - dependency_provider=self._state.depends_params.provider, - serializer_cls=self._state.depends_params.serializer, + dependency_provider=self._state.di_state.provider, + serializer_cls=self._state.di_state.serializer, ) response_type = next( diff --git a/faststream/_internal/setup/logger.py b/faststream/_internal/setup/logger.py deleted file mode 100644 index fb7da8f493..0000000000 --- a/faststream/_internal/setup/logger.py +++ /dev/null @@ -1,163 +0,0 @@ -import warnings -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Optional, Protocol - -from faststream._internal.basic_types import AnyDict, LoggerProto -from faststream._internal.constants import EMPTY -from faststream.exceptions import IncorrectState - -from .proto import SetupAble - -if TYPE_CHECKING: - from faststream._internal.context import ContextRepo - -__all__ = ( - "DefaultLoggerStorage", - "LoggerParamsStorage", - "LoggerState", - "make_logger_state", -) - - -def make_logger_state( - logger: Optional["LoggerProto"], - log_level: int, - log_fmt: Optional[str], - default_storag_cls: type["DefaultLoggerStorage"], -) -> "LoggerState": - if logger is not EMPTY and log_fmt: - warnings.warn( - message="You can't set custom `logger` with `log_fmt` both.", - category=RuntimeWarning, - stacklevel=1, - ) - - if logger is EMPTY: - storage = default_storag_cls(log_fmt) - elif logger is None: - storage = _EmptyLoggerStorage() - else: - storage = _ManualLoggerStorage(logger) - - return LoggerState( - log_level=log_level, - params_storage=storage, - ) - - -class _LoggerObject(Protocol): - logger: Optional["LoggerProto"] - - def log( - self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, - ) -> None: ... - - -class _NotSetLoggerObject(_LoggerObject): - def __init__(self) -> None: - self.logger = None - - def log( - self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, - ) -> None: - msg = "Logger object was not set up." - raise IncorrectState(msg) - - -class _EmptyLoggerObject(_LoggerObject): - def __init__(self) -> None: - self.logger = None - - def log( - self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, - ) -> None: - pass - - -class _RealLoggerObject(_LoggerObject): - def __init__(self, logger: "LoggerProto") -> None: - self.logger = logger - - def log( - self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, - ) -> None: - self.logger.log( - log_level, - message, - extra=extra, - exc_info=exc_info, - ) - - -class LoggerParamsStorage(Protocol): - def setup_log_contest(self, params: "AnyDict") -> None: ... - - def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: ... - - -class _EmptyLoggerStorage(LoggerParamsStorage): - def setup_log_contest(self, params: AnyDict) -> None: - pass - - def get_logger(self, *, context: "ContextRepo") -> None: - return None - - -class _ManualLoggerStorage(LoggerParamsStorage): - def __init__(self, logger: "LoggerProto") -> None: - self.__logger = logger - - def setup_log_contest(self, params: AnyDict) -> None: - pass - - def get_logger(self, *, context: "ContextRepo") -> LoggerProto: - return self.__logger - - -class DefaultLoggerStorage(LoggerParamsStorage): - def __init__(self, log_fmt: Optional[str]) -> None: - self._log_fmt = log_fmt - - -@dataclass -class LoggerState(SetupAble): - log_level: int - params_storage: LoggerParamsStorage - - logger: _LoggerObject = field(default=_NotSetLoggerObject(), init=False) - - def log( - self, - message: str, - log_level: Optional[int] = None, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, - ) -> None: - self.logger.log( - log_level=(log_level or self.log_level), - message=message, - extra=extra, - exc_info=exc_info, - ) - - def _setup(self, *, context: "ContextRepo") -> None: - if logger := self.params_storage.get_logger(context=context): - self.logger = _RealLoggerObject(logger) - else: - self.logger = _EmptyLoggerObject() diff --git a/faststream/_internal/setup/state.py b/faststream/_internal/setup/state.py deleted file mode 100644 index da34c29c9e..0000000000 --- a/faststream/_internal/setup/state.py +++ /dev/null @@ -1,99 +0,0 @@ -from abc import abstractmethod, abstractproperty -from typing import Optional - -from faststream.exceptions import IncorrectState - -from .fast_depends import FastDependsData -from .logger import LoggerState -from .proto import SetupAble - - -class BaseState(SetupAble): - _depends_params: FastDependsData - _logger_params: LoggerState - - @abstractproperty - def depends_params(self) -> FastDependsData: - raise NotImplementedError - - @abstractproperty - def logger_state(self) -> LoggerState: - raise NotImplementedError - - @abstractmethod - def __bool__(self) -> bool: - raise NotImplementedError - - def _setup(self) -> None: - self.logger_state._setup(context=self.depends_params.context) - - def copy_with_params( - self, - *, - depends_params: Optional[FastDependsData] = None, - logger_state: Optional[LoggerState] = None, - ) -> "SetupState": - return self.__class__( - logger_state=logger_state or self._logger_params, - depends_params=depends_params or self._depends_params, - ) - - def copy_to_state(self, state_cls: type["SetupState"]) -> "SetupState": - return state_cls( - depends_params=self._depends_params, - logger_state=self._logger_params, - ) - - -class SetupState(BaseState): - """State after broker._setup() called.""" - - def __init__( - self, - *, - logger_state: LoggerState, - depends_params: FastDependsData, - ) -> None: - self._depends_params = depends_params - self._logger_params = logger_state - - @property - def depends_params(self) -> FastDependsData: - return self._depends_params - - @property - def logger_state(self) -> LoggerState: - return self._logger_params - - def __bool__(self) -> bool: - return True - - -class EmptyState(BaseState): - """Initial state for App, broker, etc.""" - - def __init__( - self, - *, - logger_state: Optional[LoggerState] = None, - depends_params: Optional[FastDependsData] = None, - ) -> None: - self._depends_params = depends_params - self._logger_params = logger_state - - @property - def depends_params(self) -> FastDependsData: - if not self._depends_params: - raise IncorrectState - - return self._depends_params - - @property - def logger_state(self) -> LoggerState: - if not self._logger_params: - raise IncorrectState - - return self._logger_params - - def __bool__(self) -> bool: - return False diff --git a/faststream/_internal/setup/__init__.py b/faststream/_internal/state/__init__.py similarity index 53% rename from faststream/_internal/setup/__init__.py rename to faststream/_internal/state/__init__.py index bd5d749560..f65fc1cb63 100644 --- a/faststream/_internal/setup/__init__.py +++ b/faststream/_internal/state/__init__.py @@ -1,17 +1,19 @@ -from .fast_depends import FastDependsData +from .broker import BrokerState, EmptyBrokerState +from .fast_depends import DIState from .logger import LoggerParamsStorage, LoggerState +from .pointer import Pointer from .proto import SetupAble -from .state import EmptyState, SetupState __all__ = ( - "EmptyState", + # state + "BrokerState", # FastDepend - "FastDependsData", + "DIState", + "EmptyBrokerState", "LoggerParamsStorage", # logging "LoggerState", + "Pointer", # proto "SetupAble", - # state - "SetupState", ) diff --git a/faststream/_internal/state/broker.py b/faststream/_internal/state/broker.py new file mode 100644 index 0000000000..920daeedec --- /dev/null +++ b/faststream/_internal/state/broker.py @@ -0,0 +1,80 @@ +from typing import TYPE_CHECKING, Optional, Protocol + +from faststream.exceptions import IncorrectState + +from .producer import ProducerUnset + +if TYPE_CHECKING: + from faststream._internal.publisher.proto import ProducerProto + + from .fast_depends import DIState + from .logger import LoggerState + + +class BrokerState(Protocol): + di_state: "DIState" + logger_state: "LoggerState" + producer: "ProducerProto" + + # Persistent variables + graceful_timeout: Optional[float] + + def _setup(self) -> None: ... + + def _setup_logger_state(self) -> None: ... + + def __bool__(self) -> bool: ... + + +class EmptyBrokerState(BrokerState): + def __init__(self, error_msg: str) -> None: + self.error_msg = error_msg + self.producer = ProducerUnset() + + @property + def di_state(self) -> "DIState": + raise IncorrectState(self.error_msg) + + @property + def logger_state(self) -> "DIState": + raise IncorrectState(self.error_msg) + + @property + def graceful_timeout(self) -> Optional[float]: + raise IncorrectState(self.error_msg) + + def _setup(self) -> None: + pass + + def _setup_logger_state(self) -> None: + pass + + def __bool__(self) -> bool: + return False + + +class InitialBrokerState(BrokerState): + def __init__( + self, + *, + di_state: "DIState", + logger_state: "LoggerState", + graceful_timeout: Optional[float], + producer: "ProducerProto", + ) -> None: + self.di_state = di_state + self.logger_state = logger_state + + self.graceful_timeout = graceful_timeout + self.producer = producer + + self.setupped = False + + def _setup(self) -> None: + self.setupped = True + + def _setup_logger_state(self) -> None: + self.logger_state._setup(context=self.di_state.context) + + def __bool__(self) -> bool: + return self.setupped diff --git a/faststream/_internal/setup/fast_depends.py b/faststream/_internal/state/fast_depends.py similarity index 55% rename from faststream/_internal/setup/fast_depends.py rename to faststream/_internal/state/fast_depends.py index ad5be38da2..a5e7a098ad 100644 --- a/faststream/_internal/setup/fast_depends.py +++ b/faststream/_internal/state/fast_depends.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, Optional +from faststream._internal.constants import EMPTY + if TYPE_CHECKING: from fast_depends import Provider from fast_depends.library.serializer import SerializerProto @@ -11,10 +13,26 @@ @dataclass -class FastDependsData: +class DIState: use_fastdepends: bool get_dependent: Optional[Callable[..., Any]] call_decorators: Sequence["Decorator"] provider: "Provider" serializer: Optional["SerializerProto"] context: "ContextRepo" + + def update( + self, + *, + provider: "Provider" = EMPTY, + serializer: Optional["SerializerProto"] = EMPTY, + context: "ContextRepo" = EMPTY, + ) -> None: + if provider is not EMPTY: + self.provider = provider + + if serializer is not EMPTY: + self.serializer = serializer + + if context is not EMPTY: + self.context = context diff --git a/faststream/_internal/state/logger/__init__.py b/faststream/_internal/state/logger/__init__.py new file mode 100644 index 0000000000..466e24c689 --- /dev/null +++ b/faststream/_internal/state/logger/__init__.py @@ -0,0 +1,9 @@ +from .params_storage import DefaultLoggerStorage, LoggerParamsStorage +from .state import LoggerState, make_logger_state + +__all__ = ( + "DefaultLoggerStorage", + "LoggerParamsStorage", + "LoggerState", + "make_logger_state", +) diff --git a/faststream/_internal/state/logger/logger_proxy.py b/faststream/_internal/state/logger/logger_proxy.py new file mode 100644 index 0000000000..a7ca07b920 --- /dev/null +++ b/faststream/_internal/state/logger/logger_proxy.py @@ -0,0 +1,102 @@ +from typing import TYPE_CHECKING, Optional, Protocol + +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from faststream._internal.basic_types import AnyDict, LoggerProto + + +class LoggerObject(Protocol): + logger: Optional["LoggerProto"] + + def __bool__(self) -> bool: ... + + def log( + self, + message: str, + log_level: int, + extra: Optional["AnyDict"] = None, + exc_info: Optional[Exception] = None, + ) -> None: ... + + +class NotSetLoggerObject(LoggerObject): + """Default logger proxy for state. + + Raises an error if user tries to log smth before state setup. + """ + + def __init__(self) -> None: + self.logger = None + + def __bool__(self) -> bool: + return False + + def __repr__(self) -> str: + return f"{self.__class__.__name__}()" + + def log( + self, + message: str, + log_level: int, + extra: Optional["AnyDict"] = None, + exc_info: Optional[Exception] = None, + ) -> None: + msg = "Logger object not set. Please, call `_setup_logger_state` of parent broker state." + raise IncorrectState(msg) + + +class EmptyLoggerObject(LoggerObject): + """Empty logger proxy for state. + + Will be used if user setup `logger=None`. + """ + + def __init__(self) -> None: + self.logger = None + + def __bool__(self) -> bool: + return True + + def __repr__(self) -> str: + return f"{self.__class__.__name__}()" + + def log( + self, + message: str, + log_level: int, + extra: Optional["AnyDict"] = None, + exc_info: Optional[Exception] = None, + ) -> None: + pass + + +class RealLoggerObject(LoggerObject): + """Empty logger proxy for state. + + Will be used if user setup custom `logger` (.params_storage.ManualLoggerStorage) + or in default logger case (.params_storage.DefaultLoggerStorage). + """ + + def __init__(self, logger: "LoggerProto") -> None: + self.logger = logger + + def __bool__(self) -> bool: + return True + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(logger={self.logger})" + + def log( + self, + message: str, + log_level: int, + extra: Optional["AnyDict"] = None, + exc_info: Optional[Exception] = None, + ) -> None: + self.logger.log( + log_level, + message, + extra=extra, + exc_info=exc_info, + ) diff --git a/faststream/_internal/state/logger/params_storage.py b/faststream/_internal/state/logger/params_storage.py new file mode 100644 index 0000000000..6a37ec141e --- /dev/null +++ b/faststream/_internal/state/logger/params_storage.py @@ -0,0 +1,72 @@ +import warnings +from abc import abstractmethod +from typing import TYPE_CHECKING, Optional, Protocol + +from faststream._internal.constants import EMPTY + +if TYPE_CHECKING: + from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.context import ContextRepo + + +def make_logger_storage( + logger: Optional["LoggerProto"], + log_fmt: Optional[str], + default_storage_cls: type["DefaultLoggerStorage"], +) -> "LoggerParamsStorage": + if logger is EMPTY: + return default_storage_cls(log_fmt) + + if log_fmt: + warnings.warn( + message="You can't set custom `logger` with `log_fmt` both.", + category=RuntimeWarning, + stacklevel=4, + ) + + return EmptyLoggerStorage() if logger is None else ManualLoggerStorage(logger) + + +class LoggerParamsStorage(Protocol): + def setup_log_contest(self, params: "AnyDict") -> None: ... + + def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: ... + + def set_level(self, level: int) -> None: ... + + +class EmptyLoggerStorage(LoggerParamsStorage): + def setup_log_contest(self, params: "AnyDict") -> None: + pass + + def get_logger(self, *, context: "ContextRepo") -> None: + return None + + def set_level(self, level: int) -> None: + pass + + +class ManualLoggerStorage(LoggerParamsStorage): + def __init__(self, logger: "LoggerProto") -> None: + self.__logger = logger + + def setup_log_contest(self, params: "AnyDict") -> None: + pass + + def get_logger(self, *, context: "ContextRepo") -> "LoggerProto": + return self.__logger + + def set_level(self, level: int) -> None: + """We shouldn't set custom logger level by CLI.""" + + +class DefaultLoggerStorage(LoggerParamsStorage): + def __init__(self, log_fmt: Optional[str]) -> None: + self._log_fmt = log_fmt + + @abstractmethod + def get_logger(self, *, context: "ContextRepo") -> "LoggerProto": + raise NotImplementedError + + def set_level(self, level: int) -> None: + raise NotImplementedError diff --git a/faststream/_internal/state/logger/state.py b/faststream/_internal/state/logger/state.py new file mode 100644 index 0000000000..132cedf7c5 --- /dev/null +++ b/faststream/_internal/state/logger/state.py @@ -0,0 +1,72 @@ +from typing import TYPE_CHECKING, Optional + +from faststream._internal.state.proto import SetupAble + +from .logger_proxy import ( + EmptyLoggerObject, + LoggerObject, + NotSetLoggerObject, + RealLoggerObject, +) +from .params_storage import LoggerParamsStorage, make_logger_storage + +if TYPE_CHECKING: + from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.context import ContextRepo + + +def make_logger_state( + logger: Optional["LoggerProto"], + log_level: int, + log_fmt: Optional[str], + default_storage_cls: type["LoggerParamsStorage"], +) -> "LoggerState": + storage = make_logger_storage( + logger=logger, + log_fmt=log_fmt, + default_storage_cls=default_storage_cls, + ) + + return LoggerState( + log_level=log_level, + storage=storage, + ) + + +class LoggerState(SetupAble): + def __init__( + self, + log_level: int, + storage: LoggerParamsStorage, + ) -> None: + self.log_level = log_level + self.params_storage = storage + + self.logger: LoggerObject = NotSetLoggerObject() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(log_level={self.log_level}, logger={self.logger})" + + def set_level(self, level: int) -> None: + self.params_storage.set_level(level) + + def log( + self, + message: str, + log_level: Optional[int] = None, + extra: Optional["AnyDict"] = None, + exc_info: Optional[Exception] = None, + ) -> None: + self.logger.log( + log_level=(log_level or self.log_level), + message=message, + extra=extra, + exc_info=exc_info, + ) + + def _setup(self, *, context: "ContextRepo") -> None: + if not self.logger: + if logger := self.params_storage.get_logger(context=context): + self.logger = RealLoggerObject(logger) + else: + self.logger = EmptyLoggerObject() diff --git a/faststream/_internal/state/pointer.py b/faststream/_internal/state/pointer.py new file mode 100644 index 0000000000..68a41de65b --- /dev/null +++ b/faststream/_internal/state/pointer.py @@ -0,0 +1,19 @@ +from typing import Generic, TypeVar + +from typing_extensions import Self + +T = TypeVar("T") + + +class Pointer(Generic[T]): + __slots__ = ("__value",) + + def __init__(self, value: T) -> None: + self.__value = value + + def change(self, new_value: T) -> "Self": + self.__value = new_value + return self + + def get(self) -> T: + return self.__value diff --git a/faststream/_internal/state/producer.py b/faststream/_internal/state/producer.py new file mode 100644 index 0000000000..d65cd4f205 --- /dev/null +++ b/faststream/_internal/state/producer.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING, Any, Optional + +from faststream._internal.publisher.proto import ProducerProto +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from faststream._internal.types import AsyncCallable + from faststream.response import PublishCommand + + +class ProducerUnset(ProducerProto): + msg = "Producer is unset yet. You should set producer in broker initial method." + + @property + def _decoder(self) -> "AsyncCallable": + raise IncorrectState(self.msg) + + @property + def _parser(self) -> "AsyncCallable": + raise IncorrectState(self.msg) + + async def publish(self, cmd: "PublishCommand") -> Optional[Any]: + raise IncorrectState(self.msg) + + async def request(self, cmd: "PublishCommand") -> Any: + raise IncorrectState(self.msg) + + async def publish_batch(self, cmd: "PublishCommand") -> None: + raise IncorrectState(self.msg) diff --git a/faststream/_internal/setup/proto.py b/faststream/_internal/state/proto.py similarity index 100% rename from faststream/_internal/setup/proto.py rename to faststream/_internal/state/proto.py diff --git a/faststream/_internal/subscriber/call_item.py b/faststream/_internal/subscriber/call_item.py index e0d9d94255..3a172dd432 100644 --- a/faststream/_internal/subscriber/call_item.py +++ b/faststream/_internal/subscriber/call_item.py @@ -12,7 +12,7 @@ from typing_extensions import override -from faststream._internal.setup import SetupAble +from faststream._internal.state import SetupAble from faststream._internal.types import MsgType from faststream.exceptions import IgnoredException, SetupError @@ -20,7 +20,7 @@ from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AsyncFuncAny, Decorator - from faststream._internal.setup import FastDependsData + from faststream._internal.state import DIState from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper from faststream._internal.types import ( AsyncCallable, @@ -76,7 +76,7 @@ def _setup( # type: ignore[override] parser: "AsyncCallable", decoder: "AsyncCallable", broker_dependencies: Iterable["Dependant"], - fast_depends_state: "FastDependsData", + fast_depends_state: "DIState", _call_decorators: Iterable["Decorator"], ) -> None: if self.dependant is None: diff --git a/faststream/_internal/subscriber/call_wrapper/call.py b/faststream/_internal/subscriber/call_wrapper/call.py index ed2c3b839b..dcb93f8a42 100644 --- a/faststream/_internal/subscriber/call_wrapper/call.py +++ b/faststream/_internal/subscriber/call_wrapper/call.py @@ -28,7 +28,7 @@ from faststream._internal.basic_types import Decorator from faststream._internal.publisher.proto import PublisherProto - from faststream._internal.setup.fast_depends import FastDependsData + from faststream._internal.state.fast_depends import DIState from faststream.message import StreamMessage @@ -148,7 +148,7 @@ def set_wrapped( *, dependencies: Iterable["Dependant"], _call_decorators: Iterable["Decorator"], - state: "FastDependsData", + state: "DIState", ) -> Optional["CallModel"]: call = self._original_call for decor in _call_decorators: diff --git a/faststream/_internal/subscriber/proto.py b/faststream/_internal/subscriber/proto.py index 1b42548e7a..14e9e51f4c 100644 --- a/faststream/_internal/subscriber/proto.py +++ b/faststream/_internal/subscriber/proto.py @@ -12,12 +12,12 @@ if TYPE_CHECKING: from fast_depends.dependencies import Dependant - from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.basic_types import AnyDict from faststream._internal.publisher.proto import ( BasePublisherProto, ProducerProto, ) - from faststream._internal.setup import SetupState + from faststream._internal.state import BrokerState from faststream._internal.subscriber.call_item import HandlerItem from faststream._internal.types import ( BrokerMiddleware, @@ -56,15 +56,12 @@ def get_log_context( def _setup( # type: ignore[override] self, *, - logger: Optional["LoggerProto"], - producer: Optional["ProducerProto"], - graceful_timeout: Optional[float], extra_context: "AnyDict", # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "SetupState", + state: "BrokerState", ) -> None: ... @abstractmethod diff --git a/faststream/_internal/subscriber/usecase.py b/faststream/_internal/subscriber/usecase.py index 58ed1a8e2a..60ac9f1b83 100644 --- a/faststream/_internal/subscriber/usecase.py +++ b/faststream/_internal/subscriber/usecase.py @@ -35,13 +35,12 @@ if TYPE_CHECKING: from fast_depends.dependencies import Dependant - from faststream._internal.basic_types import AnyDict, Decorator, LoggerProto + from faststream._internal.basic_types import AnyDict, Decorator from faststream._internal.context.repository import ContextRepo from faststream._internal.publisher.proto import ( BasePublisherProto, - ProducerProto, ) - from faststream._internal.setup import SetupState + from faststream._internal.state import BrokerState from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -120,8 +119,6 @@ def __init__( self._broker_middlewares = broker_middlewares # register in setup later - self._producer = None - self.graceful_timeout = None self.extra_context = {} self.extra_watcher_options = {} @@ -146,20 +143,15 @@ def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: def _setup( # type: ignore[override] self, *, - logger: Optional["LoggerProto"], - producer: Optional["ProducerProto"], - graceful_timeout: Optional[float], extra_context: "AnyDict", # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "SetupState", + state: "BrokerState", ) -> None: self._state = state - self._producer = producer - self.graceful_timeout = graceful_timeout self.extra_context = extra_context for call in self.calls: @@ -179,10 +171,10 @@ def _setup( # type: ignore[override] call._setup( parser=async_parser, decoder=async_decoder, - fast_depends_state=state.depends_params, + fast_depends_state=state.di_state, _call_decorators=( *self._call_decorators, - *state.depends_params.call_decorators, + *state.di_state.call_decorators, ), broker_dependencies=self._broker_dependencies, ) @@ -204,7 +196,7 @@ async def close(self) -> None: """ self.running = False if isinstance(self.lock, MultiLock): - await self.lock.wait_release(self.graceful_timeout) + await self.lock.wait_release(self._state.graceful_timeout) def add_call( self, @@ -311,7 +303,7 @@ async def consume(self, msg: MsgType) -> Any: # Stop handler at `exit()` call await self.close() - if app := self._state.depends_params.context.get("app"): + if app := self._state.di_state.context.get("app"): app.exit() except Exception: # nosec B110 @@ -320,12 +312,15 @@ async def consume(self, msg: MsgType) -> Any: async def process_message(self, msg: MsgType) -> "Response": """Execute all message processing stages.""" - context: ContextRepo = self._state.depends_params.context + context: ContextRepo = self._state.di_state.context async with AsyncExitStack() as stack: stack.enter_context(self.lock) # Enter context before middlewares + stack.enter_context( + context.scope("logger", self._state.logger_state.logger.logger) + ) for k, v in self.extra_context.items(): stack.enter_context(context.scope(k, v)) @@ -390,6 +385,7 @@ async def process_message(self, msg: MsgType) -> "Response": msg = f"There is no suitable handler for {msg=}" raise SubscriberNotFound(msg) + # An error was raised and processed by some middleware return ensure_response(None) diff --git a/faststream/_internal/testing/broker.py b/faststream/_internal/testing/broker.py index f074b55bbc..00468fa947 100644 --- a/faststream/_internal/testing/broker.py +++ b/faststream/_internal/testing/broker.py @@ -1,6 +1,6 @@ import warnings from abc import abstractmethod -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, Generator, Iterator from contextlib import asynccontextmanager, contextmanager from functools import partial from typing import ( @@ -14,6 +14,7 @@ from unittest.mock import MagicMock from faststream._internal.broker.broker import BrokerUsecase +from faststream._internal.state.logger.logger_proxy import RealLoggerObject from faststream._internal.subscriber.utils import MultiLock from faststream._internal.testing.app import TestApp from faststream._internal.testing.ast import is_contains_context_name @@ -68,8 +69,13 @@ async def __aenter__(self) -> Broker: self._ctx = self._create_ctx() return await self._ctx.__aenter__() - async def __aexit__(self, *args: object) -> None: - await self._ctx.__aexit__(*args) + async def __aexit__( + self, + exc_type: Optional[type[BaseException]] = None, + exc_val: Optional[BaseException] = None, + exc_tb: Optional["TracebackType"] = None, + ) -> None: + await self._ctx.__aexit__(exc_type, exc_val, exc_tb) @asynccontextmanager async def _create_ctx(self) -> AsyncGenerator[Broker, None]: @@ -88,6 +94,17 @@ async def _create_ctx(self) -> AsyncGenerator[Broker, None]: finally: self._fake_close(self.broker) + @contextmanager + def _patch_producer(self, broker: Broker) -> Iterator[None]: + raise NotImplementedError + + @contextmanager + def _patch_logger(self, broker: Broker) -> Iterator[None]: + old_logger = broker._state.logger_state.logger + broker._state.logger_state.logger = RealLoggerObject(MagicMock()) + yield + broker._state.logger_state.logger = old_logger + @contextmanager def _patch_broker(self, broker: Broker) -> Generator[None, None, None]: with ( @@ -110,11 +127,8 @@ def _patch_broker(self, broker: Broker) -> Generator[None, None, None]: "_connection", new=None, ), - mock.patch.object( - broker, - "_producer", - new=None, - ), + self._patch_producer(broker), + self._patch_logger(broker), mock.patch.object( broker, "ping", diff --git a/faststream/confluent/broker/broker.py b/faststream/confluent/broker/broker.py index b7853ad014..9f57f8217e 100644 --- a/faststream/confluent/broker/broker.py +++ b/faststream/confluent/broker/broker.py @@ -68,7 +68,7 @@ class KafkaBroker( ], ): url: list[str] - _producer: Optional[AsyncConfluentFastProducer] + _producer: AsyncConfluentFastProducer def __init__( self, @@ -398,9 +398,14 @@ def __init__( serializer=serializer, ) self.client_id = client_id - self._producer = None + self.config = ConfluentFastConfig(config) + self._state.producer = AsyncConfluentFastProducer( + parser=self._parser, + decoder=self._decoder, + ) + async def close( self, exc_type: Optional[type[BaseException]] = None, @@ -409,9 +414,7 @@ async def close( ) -> None: await super().close(exc_type, exc_val, exc_tb) - if self._producer is not None: # pragma: no branch - await self._producer.stop() - self._producer = None + await self._producer.disconnect() self._connection = None @@ -444,11 +447,7 @@ async def _connect( # type: ignore[override] config=self.config, ) - self._producer = AsyncConfluentFastProducer( - producer=native_producer, - parser=self._parser, - decoder=self._decoder, - ) + self._producer.connect(native_producer) return partial( AsyncConfluentConsumer, @@ -522,7 +521,7 @@ async def publish( # type: ignore[override] reply_to=reply_to, no_confirm=no_confirm, correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await super()._basic_publish(cmd, producer=self._producer) @@ -548,7 +547,7 @@ async def request( # type: ignore[override] headers=headers, timeout=timeout, correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) msg: KafkaMessage = await super()._basic_request(cmd, producer=self._producer) @@ -574,7 +573,7 @@ async def publish_batch( reply_to=reply_to, no_confirm=no_confirm, correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish_batch(cmd, producer=self._producer) @@ -584,14 +583,14 @@ async def ping(self, timeout: Optional[float]) -> bool: sleep_time = (timeout or 10) / 10 with anyio.move_on_after(timeout) as cancel_scope: - if self._producer is None: + if not self._producer: return False while True: if cancel_scope.cancel_called: return False - if await self._producer._producer.ping(timeout=timeout): + if await self._producer.ping(timeout=timeout): return True await anyio.sleep(sleep_time) diff --git a/faststream/confluent/broker/logging.py b/faststream/confluent/broker/logging.py index 9e64efca0e..b4523d2b40 100644 --- a/faststream/confluent/broker/logging.py +++ b/faststream/confluent/broker/logging.py @@ -1,8 +1,9 @@ +import logging from functools import partial from typing import TYPE_CHECKING, Optional from faststream._internal.log.logging import get_broker_logger -from faststream._internal.setup.logger import ( +from faststream._internal.state.logger import ( DefaultLoggerStorage, make_logger_state, ) @@ -22,6 +23,11 @@ def __init__( self._max_topic_len = 4 self._max_group_len = 0 + self.logger_log_level = logging.INFO + + def set_level(self, level: int) -> None: + self.logger_log_level = level + def setup_log_contest(self, params: "AnyDict") -> None: self._max_topic_len = max( ( @@ -60,10 +66,11 @@ def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: "- %(message)s", )), context=context, + log_level=self.logger_log_level, ) make_kafka_logger_state = partial( make_logger_state, - default_storag_cls=KafkaParamsStorage, + default_storage_cls=KafkaParamsStorage, ) diff --git a/faststream/confluent/broker/registrator.py b/faststream/confluent/broker/registrator.py index 7187c56a03..bf49f8b68a 100644 --- a/faststream/confluent/broker/registrator.py +++ b/faststream/confluent/broker/registrator.py @@ -1161,7 +1161,7 @@ def subscriber( # subscriber args ack_policy=ack_policy, no_reply=no_reply, - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, broker_dependencies=self._dependencies, # Specification title_=title, @@ -1503,7 +1503,7 @@ def publisher( headers=headers, reply_to=reply_to, # publisher-specific - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, middlewares=middlewares, # Specification title_=title, diff --git a/faststream/confluent/client.py b/faststream/confluent/client.py index 74a3bed571..32afc81c36 100644 --- a/faststream/confluent/client.py +++ b/faststream/confluent/client.py @@ -26,7 +26,7 @@ from typing_extensions import NotRequired, TypedDict from faststream._internal.basic_types import AnyDict, LoggerProto - from faststream._internal.setup.logger import LoggerState + from faststream._internal.state.logger import LoggerState class _SendKwargs(TypedDict): value: Optional[Union[str, bytes]] diff --git a/faststream/confluent/publisher/producer.py b/faststream/confluent/publisher/producer.py index fcb4328638..0aa22e9eba 100644 --- a/faststream/confluent/publisher/producer.py +++ b/faststream/confluent/publisher/producer.py @@ -8,6 +8,8 @@ from faststream.exceptions import FeatureNotSupportedException from faststream.message import encode_message +from .state import EmptyProducerState, ProducerState, RealProducer + if TYPE_CHECKING: from faststream._internal.types import CustomCallable from faststream.confluent.client import AsyncConfluentProducer @@ -19,19 +21,28 @@ class AsyncConfluentFastProducer(ProducerProto): def __init__( self, - producer: "AsyncConfluentProducer", parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - self._producer = producer + self._producer: ProducerState = EmptyProducerState() # NOTE: register default parser to be compatible with request default = AsyncConfluentParser() self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) - async def stop(self) -> None: + def connect(self, producer: "AsyncConfluentProducer") -> None: + self._producer = RealProducer(producer) + + async def disconnect(self) -> None: await self._producer.stop() + self._producer = EmptyProducerState() + + def __bool__(self) -> bool: + return bool(self._producer) + + async def ping(self, timeout: float) -> None: + return await self._producer.ping(timeout=timeout) @override async def publish( # type: ignore[override] @@ -46,7 +57,7 @@ async def publish( # type: ignore[override] **cmd.headers_to_publish(), } - await self._producer.send( + await self._producer.producer.send( topic=cmd.destination, value=message, key=cmd.key, @@ -61,7 +72,7 @@ async def publish_batch( cmd: "KafkaPublishCommand", ) -> None: """Publish a batch of messages to a topic.""" - batch = self._producer.create_batch() + batch = self._producer.producer.create_batch() headers_to_send = cmd.headers_to_publish() @@ -83,7 +94,7 @@ async def publish_batch( headers=[(i, j.encode()) for i, j in final_headers.items()], ) - await self._producer.send_batch( + await self._producer.producer.send_batch( batch, cmd.destination, partition=cmd.partition, diff --git a/faststream/confluent/publisher/state.py b/faststream/confluent/publisher/state.py new file mode 100644 index 0000000000..13f658903a --- /dev/null +++ b/faststream/confluent/publisher/state.py @@ -0,0 +1,50 @@ +from typing import TYPE_CHECKING, Protocol + +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from faststream.confluent.client import AsyncConfluentProducer + + +class ProducerState(Protocol): + producer: "AsyncConfluentProducer" + + def __bool__(self) -> bool: ... + + async def ping(self, timeout: float) -> bool: ... + + async def stop(self) -> None: ... + + +class EmptyProducerState(ProducerState): + __slots__ = () + + @property + def producer(self) -> "AsyncConfluentProducer": + msg = "You can't use producer here, please connect broker first." + raise IncorrectState(msg) + + async def ping(self, timeout: float) -> bool: + return False + + def __bool__(self) -> bool: + return False + + async def stop(self) -> None: + pass + + +class RealProducer(ProducerState): + __slots__ = ("producer",) + + def __init__(self, producer: "AsyncConfluentProducer") -> None: + self.producer = producer + + def __bool__(self) -> bool: + return True + + async def stop(self) -> None: + await self.producer.stop() + + async def ping(self, timeout: float) -> bool: + return await self.producer.ping(timeout=timeout) diff --git a/faststream/confluent/publisher/usecase.py b/faststream/confluent/publisher/usecase.py index c623bba944..1416632dc6 100644 --- a/faststream/confluent/publisher/usecase.py +++ b/faststream/confluent/publisher/usecase.py @@ -86,7 +86,7 @@ async def request( correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, timeout=timeout, - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) msg: KafkaMessage = await self._basic_request(cmd) @@ -152,7 +152,7 @@ async def publish( correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, no_confirm=no_confirm, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish(cmd, _extra_middlewares=()) @@ -223,7 +223,7 @@ async def publish( correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, no_confirm=no_confirm, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish_batch(cmd, _extra_middlewares=()) diff --git a/faststream/confluent/response.py b/faststream/confluent/response.py index 2a4f84e21a..2d487484f4 100644 --- a/faststream/confluent/response.py +++ b/faststream/confluent/response.py @@ -43,7 +43,7 @@ def as_publish_command(self) -> "KafkaPublishCommand": self.body, headers=self.headers, correlation_id=self.correlation_id, - _publish_type=PublishType.Reply, + _publish_type=PublishType.REPLY, # Kafka specific topic="", key=self.key, diff --git a/faststream/confluent/subscriber/usecase.py b/faststream/confluent/subscriber/usecase.py index e14e2903d2..c02b016b10 100644 --- a/faststream/confluent/subscriber/usecase.py +++ b/faststream/confluent/subscriber/usecase.py @@ -22,9 +22,9 @@ if TYPE_CHECKING: from fast_depends.dependencies import Dependant - from faststream._internal.basic_types import AnyDict, LoggerProto - from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto - from faststream._internal.setup import SetupState + from faststream._internal.basic_types import AnyDict + from faststream._internal.publisher.proto import BasePublisherProto + from faststream._internal.state import BrokerState from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -102,24 +102,18 @@ def _setup( # type: ignore[override] *, client_id: Optional[str], builder: Callable[..., "AsyncConfluentConsumer"], - # basic args - logger: Optional["LoggerProto"], - producer: Optional["ProducerProto"], - graceful_timeout: Optional[float], + # basic args, extra_context: "AnyDict", # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "SetupState", + state: "BrokerState", ) -> None: self.client_id = client_id self.builder = builder super()._setup( - logger=logger, - producer=producer, - graceful_timeout=graceful_timeout, extra_context=extra_context, broker_parser=broker_parser, broker_decoder=broker_decoder, @@ -174,7 +168,7 @@ async def get_one( return await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -185,12 +179,9 @@ def _make_response_publisher( self, message: "StreamMessage[Any]", ) -> Sequence["BasePublisherProto"]: - if self._producer is None: - return () - return ( KafkaFakePublisher( - self._producer, + self._state.producer, topic=message.reply_to, ), ) diff --git a/faststream/confluent/testing.py b/faststream/confluent/testing.py index 27a81703e3..c254098d25 100644 --- a/faststream/confluent/testing.py +++ b/faststream/confluent/testing.py @@ -1,4 +1,5 @@ -from collections.abc import Generator, Iterable +from collections.abc import Generator, Iterable, Iterator +from contextlib import contextmanager from datetime import datetime, timezone from typing import ( TYPE_CHECKING, @@ -24,24 +25,30 @@ if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage - from faststream._internal.setup.logger import LoggerState from faststream.confluent.publisher.specified import SpecificationPublisher from faststream.confluent.response import KafkaPublishCommand from faststream.confluent.subscriber.usecase import LogicSubscriber + __all__ = ("TestKafkaBroker",) class TestKafkaBroker(TestBroker[KafkaBroker]): """A class to test Kafka brokers.""" + @contextmanager + def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: + old_producer = broker._state.producer + broker._state.producer = FakeProducer(broker) + yield + broker._state.producer = old_producer + @staticmethod async def _fake_connect( # type: ignore[override] broker: KafkaBroker, *args: Any, **kwargs: Any, ) -> Callable[..., AsyncMock]: - broker._producer = FakeProducer(broker) return _fake_connection @staticmethod @@ -99,8 +106,11 @@ def __init__(self, broker: KafkaBroker) -> None: self._parser = resolve_custom_func(broker._parser, default.parse_message) self._decoder = resolve_custom_func(broker._decoder, default.decode_message) - def _setup(self, logger_stater: "LoggerState") -> None: - pass + def __bool__(self) -> bool: + return True + + async def ping(self, timeout: float) -> None: + return True @override async def publish( # type: ignore[override] diff --git a/faststream/kafka/broker/broker.py b/faststream/kafka/broker/broker.py index ad50958ace..0c03172e93 100644 --- a/faststream/kafka/broker/broker.py +++ b/faststream/kafka/broker/broker.py @@ -238,7 +238,7 @@ class KafkaBroker( ], ): url: list[str] - _producer: Optional["AioKafkaFastProducer"] + _producer: "AioKafkaFastProducer" def __init__( self, @@ -580,7 +580,10 @@ def __init__( ) self.client_id = client_id - self._producer = None + self._state.producer = AioKafkaFastProducer( + parser=self._parser, + decoder=self._decoder, + ) async def close( self, @@ -590,9 +593,7 @@ async def close( ) -> None: await super().close(exc_type, exc_val, exc_tb) - if self._producer is not None: # pragma: no branch - await self._producer.stop() - self._producer = None + await self._producer.disconnect() self._connection = None @@ -635,12 +636,7 @@ async def _connect( # type: ignore[override] client_id=client_id, ) - await producer.start() - self._producer = AioKafkaFastProducer( - producer=producer, - parser=self._parser, - decoder=self._decoder, - ) + await self._producer.connect(producer) return partial( aiokafka.AIOKafkaConsumer, @@ -742,7 +738,7 @@ async def publish( # type: ignore[override] reply_to=reply_to, no_confirm=no_confirm, correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await super()._basic_publish(cmd, producer=self._producer) @@ -815,7 +811,7 @@ async def request( # type: ignore[override] headers=headers, timeout=timeout, correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) msg: KafkaMessage = await super()._basic_request(cmd, producer=self._producer) @@ -880,7 +876,7 @@ async def publish_batch( reply_to=reply_to, no_confirm=no_confirm, correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish_batch(cmd, producer=self._producer) @@ -890,14 +886,14 @@ async def ping(self, timeout: Optional[float]) -> bool: sleep_time = (timeout or 10) / 10 with anyio.move_on_after(timeout) as cancel_scope: - if self._producer is None: + if not self._producer: return False while True: if cancel_scope.cancel_called: return False - if not self._producer._producer._closed: + if not self._producer.closed: return True await anyio.sleep(sleep_time) diff --git a/faststream/kafka/broker/logging.py b/faststream/kafka/broker/logging.py index 9518a4f7ec..72a1420325 100644 --- a/faststream/kafka/broker/logging.py +++ b/faststream/kafka/broker/logging.py @@ -1,8 +1,9 @@ +import logging from functools import partial from typing import TYPE_CHECKING, Optional from faststream._internal.log.logging import get_broker_logger -from faststream._internal.setup.logger import ( +from faststream._internal.state.logger import ( DefaultLoggerStorage, make_logger_state, ) @@ -22,6 +23,11 @@ def __init__( self._max_topic_len = 4 self._max_group_len = 0 + self.logger_log_level = logging.INFO + + def set_level(self, level: int) -> None: + self.logger_log_level = level + def setup_log_contest(self, params: "AnyDict") -> None: self._max_topic_len = max( ( @@ -60,10 +66,11 @@ def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: "- %(message)s", )), context=context, + log_level=self.logger_log_level, ) make_kafka_logger_state = partial( make_logger_state, - default_storag_cls=KafkaParamsStorage, + default_storage_cls=KafkaParamsStorage, ) diff --git a/faststream/kafka/broker/registrator.py b/faststream/kafka/broker/registrator.py index 76cb4ec77b..bd2c4bd735 100644 --- a/faststream/kafka/broker/registrator.py +++ b/faststream/kafka/broker/registrator.py @@ -1563,7 +1563,7 @@ def subscriber( # subscriber args ack_policy=ack_policy, no_reply=no_reply, - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, broker_dependencies=self._dependencies, # Specification title_=title, @@ -1906,7 +1906,7 @@ def publisher( headers=headers, reply_to=reply_to, # publisher-specific - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, middlewares=middlewares, # Specification title_=title, diff --git a/faststream/kafka/publisher/producer.py b/faststream/kafka/publisher/producer.py index 661d899118..eba2276e6b 100644 --- a/faststream/kafka/publisher/producer.py +++ b/faststream/kafka/publisher/producer.py @@ -9,6 +9,8 @@ from faststream.kafka.parser import AioKafkaParser from faststream.message import encode_message +from .state import EmptyProducerState, ProducerState, RealProducer + if TYPE_CHECKING: from aiokafka import AIOKafkaProducer @@ -21,22 +23,34 @@ class AioKafkaFastProducer(ProducerProto): def __init__( self, - producer: "AIOKafkaProducer", parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - self._producer = producer + self._producer: ProducerState = EmptyProducerState() # NOTE: register default parser to be compatible with request default = AioKafkaParser( msg_class=KafkaMessage, regex=None, ) + self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) - async def stop(self) -> None: + async def connect(self, producer: "AIOKafkaProducer") -> None: + await producer.start() + self._producer = RealProducer(producer) + + async def disconnect(self) -> None: await self._producer.stop() + self._producer = EmptyProducerState() + + def __bool__(self) -> None: + return bool(self._producer) + + @property + def closed(self) -> bool: + return self._producer.closed @override async def publish( # type: ignore[override] @@ -51,7 +65,7 @@ async def publish( # type: ignore[override] **cmd.headers_to_publish(), } - send_future = await self._producer.send( + send_future = await self._producer.producer.send( topic=cmd.destination, value=message, key=cmd.key, @@ -68,7 +82,7 @@ async def publish_batch( cmd: "KafkaPublishCommand", ) -> None: """Publish a batch of messages to a topic.""" - batch = self._producer.create_batch() + batch = self._producer.producer.create_batch() headers_to_send = cmd.headers_to_publish() @@ -90,7 +104,7 @@ async def publish_batch( headers=[(i, j.encode()) for i, j in final_headers.items()], ) - send_future = await self._producer.send_batch( + send_future = await self._producer.producer.send_batch( batch, cmd.destination, partition=cmd.partition, diff --git a/faststream/kafka/publisher/state.py b/faststream/kafka/publisher/state.py new file mode 100644 index 0000000000..3094cf02c1 --- /dev/null +++ b/faststream/kafka/publisher/state.py @@ -0,0 +1,49 @@ +from typing import TYPE_CHECKING, Protocol + +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from aiokafka import AIOKafkaProducer + + +class ProducerState(Protocol): + producer: "AIOKafkaProducer" + closed: bool + + def __bool__(self) -> bool: ... + + async def stop(self) -> None: ... + + +class EmptyProducerState(ProducerState): + __slots__ = () + + closed = True + + @property + def producer(self) -> "AIOKafkaProducer": + msg = "You can't use producer here, please connect broker first." + raise IncorrectState(msg) + + def __bool__(self) -> bool: + return False + + async def stop(self) -> None: + pass + + +class RealProducer(ProducerState): + __slots__ = ("producer",) + + def __init__(self, producer: "AIOKafkaProducer") -> None: + self.producer = producer + + def __bool__(self) -> bool: + return True + + async def stop(self) -> None: + await self.producer.stop() + + @property + def closed(self) -> bool: + return self.producer._closed or False diff --git a/faststream/kafka/publisher/usecase.py b/faststream/kafka/publisher/usecase.py index 495d539cea..7174237e49 100644 --- a/faststream/kafka/publisher/usecase.py +++ b/faststream/kafka/publisher/usecase.py @@ -135,7 +135,7 @@ async def request( correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, timeout=timeout, - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) msg: KafkaMessage = await self._basic_request(cmd) @@ -251,7 +251,7 @@ async def publish( correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, no_confirm=no_confirm, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish(cmd, _extra_middlewares=()) @@ -406,7 +406,7 @@ async def publish( correlation_id=correlation_id or gen_cor_id(), timestamp_ms=timestamp_ms, no_confirm=no_confirm, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish_batch(cmd, _extra_middlewares=()) diff --git a/faststream/kafka/response.py b/faststream/kafka/response.py index ac3cd1019d..4f1b46dc3e 100644 --- a/faststream/kafka/response.py +++ b/faststream/kafka/response.py @@ -35,7 +35,7 @@ def as_publish_command(self) -> "KafkaPublishCommand": self.body, headers=self.headers, correlation_id=self.correlation_id, - _publish_type=PublishType.Reply, + _publish_type=PublishType.REPLY, # Kafka specific topic="", key=self.key, diff --git a/faststream/kafka/subscriber/usecase.py b/faststream/kafka/subscriber/usecase.py index f3cf9c2122..f622ad6b9d 100644 --- a/faststream/kafka/subscriber/usecase.py +++ b/faststream/kafka/subscriber/usecase.py @@ -32,9 +32,9 @@ from aiokafka.abc import ConsumerRebalanceListener from fast_depends.dependencies import Dependant - from faststream._internal.basic_types import AnyDict, LoggerProto - from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto - from faststream._internal.setup import SetupState + from faststream._internal.basic_types import AnyDict + from faststream._internal.publisher.proto import BasePublisherProto + from faststream._internal.state import BrokerState from faststream.message import StreamMessage from faststream.middlewares import AckPolicy @@ -110,23 +110,17 @@ def _setup( # type: ignore[override] client_id: Optional[str], builder: Callable[..., "AIOKafkaConsumer"], # basic args - logger: Optional["LoggerProto"], - producer: Optional["ProducerProto"], - graceful_timeout: Optional[float], extra_context: "AnyDict", # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "SetupState", + state: "BrokerState", ) -> None: self.client_id = client_id self.builder = builder super()._setup( - logger=logger, - producer=producer, - graceful_timeout=graceful_timeout, extra_context=extra_context, broker_parser=broker_parser, broker_decoder=broker_decoder, @@ -197,7 +191,7 @@ async def get_one( msg: StreamMessage[MsgType] = await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -209,12 +203,9 @@ def _make_response_publisher( self, message: "StreamMessage[Any]", ) -> Sequence["BasePublisherProto"]: - if self._producer is None: - return () - return ( KafkaFakePublisher( - self._producer, + self._state.producer, topic=message.reply_to, ), ) diff --git a/faststream/kafka/testing.py b/faststream/kafka/testing.py index a73e18ade8..90c097eac1 100755 --- a/faststream/kafka/testing.py +++ b/faststream/kafka/testing.py @@ -1,5 +1,6 @@ import re -from collections.abc import Generator, Iterable +from collections.abc import Generator, Iterable, Iterator +from contextlib import contextmanager from datetime import datetime, timezone from typing import ( TYPE_CHECKING, @@ -37,13 +38,19 @@ class TestKafkaBroker(TestBroker[KafkaBroker]): """A class to test Kafka brokers.""" + @contextmanager + def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: + old_producer = broker._state.producer + broker._state.producer = FakeProducer(broker) + yield + broker._state.producer = old_producer + @staticmethod async def _fake_connect( # type: ignore[override] broker: KafkaBroker, *args: Any, **kwargs: Any, ) -> Callable[..., AsyncMock]: - broker._producer = FakeProducer(broker) return _fake_connection @staticmethod @@ -97,6 +104,13 @@ def __init__(self, broker: KafkaBroker) -> None: self._parser = resolve_custom_func(broker._parser, default.parse_message) self._decoder = resolve_custom_func(broker._decoder, default.decode_message) + def __bool__(self) -> None: + return True + + @property + def closed(self) -> bool: + return False + @override async def publish( # type: ignore[override] self, diff --git a/faststream/middlewares/logging.py b/faststream/middlewares/logging.py index f0eeffbe4d..2fa5987a0e 100644 --- a/faststream/middlewares/logging.py +++ b/faststream/middlewares/logging.py @@ -11,7 +11,7 @@ from faststream._internal.basic_types import AsyncFuncAny from faststream._internal.context.repository import ContextRepo - from faststream._internal.setup.logger import LoggerState + from faststream._internal.state.logger import LoggerState from faststream.message import StreamMessage diff --git a/faststream/nats/broker/broker.py b/faststream/nats/broker/broker.py index 4aa6812c6a..72e9687580 100644 --- a/faststream/nats/broker/broker.py +++ b/faststream/nats/broker/broker.py @@ -68,7 +68,6 @@ LoggerProto, SendableMessage, ) - from faststream._internal.publisher.proto import ProducerProto from faststream._internal.types import ( BrokerMiddleware, CustomCallable, @@ -547,9 +546,9 @@ def __init__( _call_decorators=_call_decorators, ) - self._producer = NatsFastProducer( - decoder=self._decoder, + self._state.producer = NatsFastProducer( parser=self._parser, + decoder=self._decoder, ) self._js_producer = NatsJSFastProducer( @@ -590,7 +589,7 @@ async def _connect(self, **kwargs: Any) -> "Client": stream = connection.jetstream() - self._producer.connect(connection) + self._state.producer.connect(connection) self._js_producer.connect(stream) self._kv_declarer.connect(stream) @@ -612,7 +611,7 @@ async def close( await self._connection.drain() self._connection = None - self._producer.disconnect() + self._state.producer.disconnect() self._js_producer.disconnect() self._kv_declarer.disconnect() self._os_declarer.disconnect() @@ -730,11 +729,10 @@ async def publish( # type: ignore[override] reply_to=reply_to, stream=stream, timeout=timeout, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) - producer: Optional[ProducerProto] - producer = self._producer if stream is None else self._js_producer + producer = self._state.producer if stream is None else self._js_producer await super()._basic_publish(cmd, producer=producer) @@ -785,11 +783,10 @@ async def request( # type: ignore[override] headers=headers, timeout=timeout, stream=stream, - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) - producer: Optional[ProducerProto] - producer = self._producer if stream is None else self._js_producer + producer = self._state.producer if stream is None else self._js_producer msg: NatsMessage = await super()._basic_request(cmd, producer=producer) return msg @@ -811,7 +808,9 @@ def setup_publisher( # type: ignore[override] self, publisher: "SpecificationPublisher", ) -> None: - producer = self._producer if publisher.stream is None else self._js_producer + producer = ( + self._state.producer if publisher.stream is None else self._js_producer + ) super().setup_publisher(publisher, producer=producer) diff --git a/faststream/nats/broker/logging.py b/faststream/nats/broker/logging.py index 0238e2208d..d67cb8e4bf 100644 --- a/faststream/nats/broker/logging.py +++ b/faststream/nats/broker/logging.py @@ -1,8 +1,9 @@ +import logging from functools import partial from typing import TYPE_CHECKING, Optional from faststream._internal.log.logging import get_broker_logger -from faststream._internal.setup.logger import ( +from faststream._internal.state.logger import ( DefaultLoggerStorage, make_logger_state, ) @@ -23,6 +24,11 @@ def __init__( self._max_stream_len = 0 self._max_subject_len = 4 + self.logger_log_level = logging.INFO + + def set_level(self, level: int) -> None: + self.logger_log_level = level + def setup_log_contest(self, params: "AnyDict") -> None: self._max_subject_len = max( ( @@ -69,10 +75,11 @@ def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: "%(message)s", )), context=context, + log_level=self.logger_log_level, ) make_nats_logger_state = partial( make_logger_state, - default_storag_cls=NatsParamsStorage, + default_storage_cls=NatsParamsStorage, ) diff --git a/faststream/nats/broker/registrator.py b/faststream/nats/broker/registrator.py index f8feacb294..1473890702 100644 --- a/faststream/nats/broker/registrator.py +++ b/faststream/nats/broker/registrator.py @@ -221,7 +221,7 @@ def subscriber( # type: ignore[override] # subscriber args ack_policy=ack_policy, no_reply=no_reply, - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, broker_dependencies=self._dependencies, # AsyncAPI title_=title, @@ -320,7 +320,7 @@ def publisher( # type: ignore[override] timeout=timeout, stream=stream, # Specific - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, middlewares=middlewares, # AsyncAPI title_=title, diff --git a/faststream/nats/publisher/producer.py b/faststream/nats/publisher/producer.py index 62245c2c7c..a90bf89ff0 100644 --- a/faststream/nats/publisher/producer.py +++ b/faststream/nats/publisher/producer.py @@ -37,7 +37,6 @@ class NatsFastProducer(ProducerProto): def __init__( self, - *, parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: diff --git a/faststream/nats/publisher/usecase.py b/faststream/nats/publisher/usecase.py index 880479546c..b2ca2b3c3e 100644 --- a/faststream/nats/publisher/usecase.py +++ b/faststream/nats/publisher/usecase.py @@ -27,7 +27,7 @@ class LogicPublisher(PublisherUsecase[Msg]): """A class to represent a NATS publisher.""" - _producer: Union["NatsFastProducer", "NatsJSFastProducer", None] + _producer: Union["NatsFastProducer", "NatsJSFastProducer"] def __init__( self, @@ -100,7 +100,7 @@ async def publish( correlation_id=correlation_id or gen_cor_id(), stream=stream or getattr(self.stream, "name", None), timeout=timeout or self.timeout, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) return await self._basic_publish(cmd, _extra_middlewares=()) @@ -165,7 +165,7 @@ async def request( timeout=timeout or self.timeout, correlation_id=correlation_id or gen_cor_id(), stream=getattr(self.stream, "name", None), - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) msg: NatsMessage = await self._basic_request(cmd) diff --git a/faststream/nats/response.py b/faststream/nats/response.py index a6fcee1961..40289436f5 100644 --- a/faststream/nats/response.py +++ b/faststream/nats/response.py @@ -31,7 +31,7 @@ def as_publish_command(self) -> "NatsPublishCommand": message=self.body, headers=self.headers, correlation_id=self.correlation_id, - _publish_type=PublishType.Reply, + _publish_type=PublishType.REPLY, # Nats specific subject="", stream=self.stream, diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index 2c1e35cd56..b04c5cc6d0 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -49,11 +49,10 @@ from faststream._internal.basic_types import ( AnyDict, - LoggerProto, SendableMessage, ) from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto - from faststream._internal.setup import SetupState + from faststream._internal.state import BrokerState as BasicState from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -124,15 +123,12 @@ def _setup( # type: ignore[override] os_declarer: "OSBucketDeclarer", kv_declarer: "KVBucketDeclarer", # basic args - logger: Optional["LoggerProto"], - producer: Optional["ProducerProto"], - graceful_timeout: Optional[float], extra_context: "AnyDict", # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "SetupState", + state: "BasicState", ) -> None: self._connection_state = ConnectedSubscriberState( parent_state=connection_state, @@ -141,9 +137,6 @@ def _setup( # type: ignore[override] ) super()._setup( - logger=logger, - producer=producer, - graceful_timeout=graceful_timeout, extra_context=extra_context, broker_parser=broker_parser, broker_decoder=broker_decoder, @@ -265,12 +258,9 @@ def _make_response_publisher( message: "StreamMessage[Any]", ) -> Iterable["BasePublisherProto"]: """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" - if self._producer is None: - return () - return ( NatsFakePublisher( - producer=self._producer, + producer=self._state.producer, subject=message.reply_to, ), ) @@ -360,7 +350,7 @@ async def get_one( msg: NatsMessage = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -552,7 +542,7 @@ async def get_one( msg: NatsMessage = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -864,7 +854,7 @@ async def get_one( await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -979,7 +969,7 @@ async def get_one( msg: NatsKvMessage = await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -1133,7 +1123,7 @@ async def get_one( msg: NatsObjMessage = await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -1174,7 +1164,7 @@ async def __consume_watch(self) -> None: ) if message: - with self._state.depends_params.context.scope( + with self._state.di_state.context.scope( OBJECT_STORAGE_CONTEXT_KEY, self.bucket ): await self.consume(message) diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index d709554993..c91fd5529b 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -1,4 +1,5 @@ -from collections.abc import Generator, Iterable +from collections.abc import Generator, Iterable, Iterator +from contextlib import contextmanager from typing import ( TYPE_CHECKING, Any, @@ -54,16 +55,20 @@ def create_publisher_fake_subscriber( return sub, is_real - @staticmethod - async def _fake_connect( # type: ignore[override] + @contextmanager + def _patch_producer(self, broker: NatsBroker) -> Iterator[None]: + old_js_producer, old_producer = broker._js_producer, broker._state.producer + broker._js_producer = broker._state.producer = FakeProducer(broker) + yield + broker._js_producer, broker._state.producer = old_js_producer, old_producer + + async def _fake_connect( + self, broker: NatsBroker, *args: Any, **kwargs: Any, ) -> AsyncMock: broker._connection_state = ConnectedState(AsyncMock(), AsyncMock()) - broker._js_producer = broker._producer = FakeProducer( # type: ignore[assignment] - broker, - ) return AsyncMock() diff --git a/faststream/prometheus/middleware.py b/faststream/prometheus/middleware.py index ffdea49d4f..d61dc42b0c 100644 --- a/faststream/prometheus/middleware.py +++ b/faststream/prometheus/middleware.py @@ -165,7 +165,7 @@ async def publish_scope( call_next: "AsyncFunc", cmd: "PublishCommand", ) -> Any: - if self._settings_provider is None or cmd.publish_type is PublishType.Reply: + if self._settings_provider is None or cmd.publish_type is PublishType.REPLY: return await call_next(cmd) destination_name = ( diff --git a/faststream/rabbit/broker/broker.py b/faststream/rabbit/broker/broker.py index 49286ea81b..7ba92c36c1 100644 --- a/faststream/rabbit/broker/broker.py +++ b/faststream/rabbit/broker/broker.py @@ -18,7 +18,6 @@ from faststream.__about__ import SERVICE_NAME from faststream._internal.broker.broker import BrokerUsecase from faststream._internal.constants import EMPTY -from faststream.exceptions import NOT_CONNECTED_YET from faststream.message import gen_cor_id from faststream.rabbit.helpers.declarer import RabbitDeclarer from faststream.rabbit.publisher.producer import AioPikaFastProducer @@ -69,9 +68,10 @@ class RabbitBroker( """A class to represent a RabbitMQ broker.""" url: str - _producer: Optional["AioPikaFastProducer"] - declarer: Optional[RabbitDeclarer] + _producer: "AioPikaFastProducer" + declarer: RabbitDeclarer + _channel: Optional["RobustChannel"] def __init__( @@ -290,7 +290,13 @@ def __init__( self.app_id = app_id self._channel = None - self.declarer = None + + declarer = self.declarer = RabbitDeclarer() + self._state.producer = AioPikaFastProducer( + declarer=declarer, + decoder=self._decoder, + parser=self._parser, + ) @property def _subscriber_setup_extra(self) -> "AnyDict": @@ -461,18 +467,14 @@ async def _connect( # type: ignore[override] ), ) - declarer = self.declarer = RabbitDeclarer(channel) - await declarer.declare_queue(RABBIT_REPLY) - - self._producer = AioPikaFastProducer( - declarer=declarer, - decoder=self._decoder, - parser=self._parser, - ) - if self._max_consumers: await channel.set_qos(prefetch_count=int(self._max_consumers)) + self.declarer.connect(connection=connection, channel=channel) + await self.declarer.declare_queue(RABBIT_REPLY) + + self._producer.connect() + return connection async def close( @@ -493,25 +495,23 @@ async def close( await self._connection.close() self._connection = None - self.declarer = None - self._producer = None + self.declarer.disconnect() + self._producer.disconnect() async def start(self) -> None: """Connect broker to RabbitMQ and startup all subscribers.""" await self.connect() self._setup() - if self._max_consumers: - self._state.logger_state.log(f"Set max consumers to {self._max_consumers}") - - assert self.declarer, NOT_CONNECTED_YET # nosec B101 - for publisher in self._publishers: if publisher.exchange is not None: await self.declare_exchange(publisher.exchange) await super().start() + if self._max_consumers: + self._state.logger_state.log(f"Set max consumers to {self._max_consumers}") + @override async def publish( # type: ignore[override] self, @@ -639,7 +639,7 @@ async def publish( # type: ignore[override] user_id=user_id, timeout=timeout, priority=priority, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) return await super()._basic_publish(cmd, producer=self._producer) @@ -757,7 +757,7 @@ async def request( # type: ignore[override] user_id=user_id, timeout=timeout, priority=priority, - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) msg: RabbitMessage = await super()._basic_request(cmd, producer=self._producer) @@ -771,7 +771,6 @@ async def declare_queue( ], ) -> "RobustQueue": """Declares queue object in **RabbitMQ**.""" - assert self.declarer, NOT_CONNECTED_YET # nosec B101 return await self.declarer.declare_queue(queue) async def declare_exchange( @@ -782,7 +781,6 @@ async def declare_exchange( ], ) -> "RobustExchange": """Declares exchange object in **RabbitMQ**.""" - assert self.declarer, NOT_CONNECTED_YET # nosec B101 return await self.declarer.declare_exchange(exchange) @override diff --git a/faststream/rabbit/broker/logging.py b/faststream/rabbit/broker/logging.py index 1d1451cca2..21b0172004 100644 --- a/faststream/rabbit/broker/logging.py +++ b/faststream/rabbit/broker/logging.py @@ -1,8 +1,9 @@ +import logging from functools import partial from typing import TYPE_CHECKING, Optional from faststream._internal.log.logging import get_broker_logger -from faststream._internal.setup.logger import ( +from faststream._internal.state.logger import ( DefaultLoggerStorage, make_logger_state, ) @@ -22,6 +23,11 @@ def __init__( self._max_exchange_len = 4 self._max_queue_len = 4 + self.logger_log_level = logging.INFO + + def set_level(self, level: int) -> None: + self.logger_log_level = level + def setup_log_contest(self, params: "AnyDict") -> None: self._max_exchange_len = max( self._max_exchange_len, @@ -52,10 +58,11 @@ def get_logger(self, *, context: "ContextRepo") -> "LoggerProto": "- %(message)s" ), context=context, + log_level=self.logger_log_level, ) make_rabbit_logger_state = partial( make_logger_state, - default_storag_cls=RabbitParamsStorage, + default_storage_cls=RabbitParamsStorage, ) diff --git a/faststream/rabbit/broker/registrator.py b/faststream/rabbit/broker/registrator.py index 117aafc1de..e3e861124c 100644 --- a/faststream/rabbit/broker/registrator.py +++ b/faststream/rabbit/broker/registrator.py @@ -112,7 +112,7 @@ def subscriber( # type: ignore[override] # subscriber args ack_policy=ack_policy, no_reply=no_reply, - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, broker_dependencies=self._dependencies, # AsyncAPI title_=title, @@ -269,7 +269,7 @@ def publisher( # type: ignore[override] exchange=RabbitExchange.validate(exchange), message_kwargs=message_kwargs, # Specific - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, middlewares=middlewares, # AsyncAPI title_=title, diff --git a/faststream/rabbit/helpers/declarer.py b/faststream/rabbit/helpers/declarer.py index b7bf52165c..dc890617d0 100644 --- a/faststream/rabbit/helpers/declarer.py +++ b/faststream/rabbit/helpers/declarer.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING, cast +from .state import ConnectedState, ConnectionState, EmptyConnectionState + if TYPE_CHECKING: import aio_pika @@ -9,12 +11,22 @@ class RabbitDeclarer: """An utility class to declare RabbitMQ queues and exchanges.""" - __channel: "aio_pika.RobustChannel" - __queues: dict["RabbitQueue", "aio_pika.RobustQueue"] - __exchanges: dict["RabbitExchange", "aio_pika.RobustExchange"] + def __init__(self) -> None: + self.__queues: dict[RabbitQueue, aio_pika.RobustQueue] = {} + self.__exchanges: dict[RabbitExchange, aio_pika.RobustExchange] = {} + + self.__connection: ConnectionState = EmptyConnectionState() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(<{self.__connection.__class__.__name__}>, queues={list(self.__queues.keys())}, exchanges={list(self.__exchanges.keys())})" + + def connect( + self, connection: "aio_pika.RobustConnection", channel: "aio_pika.RobustChannel" + ) -> None: + self.__connection = ConnectedState(connection=connection, channel=channel) - def __init__(self, channel: "aio_pika.RobustChannel") -> None: - self.__channel = channel + def disconnect(self) -> None: + self.__connection = EmptyConnectionState() self.__queues = {} self.__exchanges = {} @@ -27,7 +39,7 @@ async def declare_queue( if (q := self.__queues.get(queue)) is None: self.__queues[queue] = q = cast( "aio_pika.RobustQueue", - await self.__channel.declare_queue( + await self.__connection.channel.declare_queue( name=queue.name, durable=queue.durable, exclusive=queue.exclusive, @@ -48,12 +60,12 @@ async def declare_exchange( ) -> "aio_pika.RobustExchange": """Declare an exchange, parent exchanges and bind them each other.""" if not exchange.name: - return self.__channel.default_exchange + return self.__connection.channel.default_exchange if (exch := self.__exchanges.get(exchange)) is None: self.__exchanges[exchange] = exch = cast( "aio_pika.RobustExchange", - await self.__channel.declare_exchange( + await self.__connection.channel.declare_exchange( name=exchange.name, type=exchange.type.value, durable=exchange.durable, diff --git a/faststream/rabbit/helpers/state.py b/faststream/rabbit/helpers/state.py new file mode 100644 index 0000000000..182b588557 --- /dev/null +++ b/faststream/rabbit/helpers/state.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING, Protocol + +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from aio_pika import RobustChannel, RobustConnection + + +class ConnectionState(Protocol): + connection: "RobustConnection" + channel: "RobustChannel" + + +class EmptyConnectionState(ConnectionState): + __slots__ = () + + error_msg = "You should connect broker first." + + @property + def connection(self) -> "RobustConnection": + raise IncorrectState(self.error_msg) + + @property + def channel(self) -> "RobustChannel": + raise IncorrectState(self.error_msg) + + +class ConnectedState(ConnectionState): + __slots__ = ("channel", "connection") + + def __init__( + self, connection: "RobustConnection", channel: "RobustChannel" + ) -> None: + self.connection = connection + self.channel = channel diff --git a/faststream/rabbit/publisher/producer.py b/faststream/rabbit/publisher/producer.py index ec509842a3..55fe050c19 100644 --- a/faststream/rabbit/publisher/producer.py +++ b/faststream/rabbit/publisher/producer.py @@ -1,6 +1,7 @@ from typing import ( TYPE_CHECKING, Optional, + Protocol, cast, ) @@ -9,7 +10,7 @@ from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func -from faststream.exceptions import FeatureNotSupportedException +from faststream.exceptions import FeatureNotSupportedException, IncorrectState from faststream.rabbit.parser import AioPikaParser from faststream.rabbit.schemas import RABBIT_REPLY, RabbitExchange @@ -30,6 +31,26 @@ from faststream.rabbit.types import AioPikaSendableMessage +class LockState(Protocol): + lock: "anyio.Lock" + + +class LockUnset(LockState): + __slots__ = () + + @property + def lock(self) -> "anyio.Lock": + msg = "You should call `producer.connect()` method at first." + raise IncorrectState(msg) + + +class RealLock(LockState): + __slots__ = ("lock",) + + def __init__(self) -> None: + self.lock = anyio.Lock() + + class AioPikaFastProducer(ProducerProto): """A class for fast producing messages using aio-pika.""" @@ -45,12 +66,22 @@ def __init__( ) -> None: self.declarer = declarer - self._rpc_lock = anyio.Lock() + self.__lock: LockState = LockUnset() default_parser = AioPikaParser() self._parser = resolve_custom_func(parser, default_parser.parse_message) self._decoder = resolve_custom_func(decoder, default_parser.decode_message) + def connect(self) -> None: + """Lock initialization. + + Should be called in async context due `anyio.Lock` object can't be created outside event loop. + """ + self.__lock = RealLock() + + def disconnect(self) -> None: + self.__lock = LockUnset() + @override async def publish( # type: ignore[override] self, @@ -75,7 +106,7 @@ async def request( # type: ignore[override] ) -> "IncomingMessage": """Publish a message to a RabbitMQ queue.""" async with _RPCCallback( - self._rpc_lock, + self.__lock.lock, await self.declarer.declare_queue(RABBIT_REPLY), ) as response_queue: with anyio.fail_after(cmd.timeout): diff --git a/faststream/rabbit/publisher/usecase.py b/faststream/rabbit/publisher/usecase.py index 71a357576f..bc24c1af48 100644 --- a/faststream/rabbit/publisher/usecase.py +++ b/faststream/rabbit/publisher/usecase.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: import aiormq - from faststream._internal.setup import SetupState + from faststream._internal.state import BrokerState from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.rabbit.message import RabbitMessage from faststream.rabbit.publisher.producer import AioPikaFastProducer @@ -108,7 +108,7 @@ def _setup( # type: ignore[override] producer: Optional["AioPikaFastProducer"], app_id: Optional[str], virtual_host: str, - state: "SetupState", + state: "BrokerState", ) -> None: if app_id: self.message_options["app_id"] = app_id @@ -167,7 +167,7 @@ async def publish( exchange=RabbitExchange.validate(exchange or self.exchange), correlation_id=correlation_id or gen_cor_id(), headers=headers, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, **(self.publish_options | self.message_options | publish_kwargs), ) @@ -242,7 +242,7 @@ async def request( exchange=RabbitExchange.validate(exchange or self.exchange), correlation_id=correlation_id or gen_cor_id(), headers=headers, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, **(self.publish_options | self.message_options | publish_kwargs), ) diff --git a/faststream/rabbit/response.py b/faststream/rabbit/response.py index 560a45bb57..9bac3f6417 100644 --- a/faststream/rabbit/response.py +++ b/faststream/rabbit/response.py @@ -45,7 +45,7 @@ def as_publish_command(self) -> "RabbitPublishCommand": message=self.body, headers=self.headers, correlation_id=self.correlation_id, - _publish_type=PublishType.Reply, + _publish_type=PublishType.REPLY, # RMQ specific routing_key="", **self.publish_options, diff --git a/faststream/rabbit/schemas/exchange.py b/faststream/rabbit/schemas/exchange.py index a95f78bba8..af146f78b0 100644 --- a/faststream/rabbit/schemas/exchange.py +++ b/faststream/rabbit/schemas/exchange.py @@ -28,6 +28,14 @@ class RabbitExchange(NameRequired): "type", ) + def __repr__(self) -> str: + if self.passive: + body = "" + else: + body = f", robust={self.robust}, durable={self.durable}, auto_delete={self.auto_delete})" + + return f"{self.__class__.__name__}({self.name}, type={self.type}, routing_key='{self.routing}'{body})" + def __hash__(self) -> int: """Supports hash to store real objects in declarer.""" return sum( diff --git a/faststream/rabbit/schemas/queue.py b/faststream/rabbit/schemas/queue.py index 6a26a64dba..35f9955750 100644 --- a/faststream/rabbit/schemas/queue.py +++ b/faststream/rabbit/schemas/queue.py @@ -34,6 +34,14 @@ class RabbitQueue(NameRequired): "timeout", ) + def __repr__(self) -> str: + if self.passive: + body = "" + else: + body = f", robust={self.robust}, durable={self.durable}, exclusive={self.exclusive}, auto_delete={self.auto_delete})" + + return f"{self.__class__.__name__}({self.name}, routing_key='{self.routing}'{body})" + def __hash__(self) -> int: """Supports hash to store real objects in declarer.""" return sum( diff --git a/faststream/rabbit/subscriber/usecase.py b/faststream/rabbit/subscriber/usecase.py index 119938afb5..8bad07afad 100644 --- a/faststream/rabbit/subscriber/usecase.py +++ b/faststream/rabbit/subscriber/usecase.py @@ -20,9 +20,9 @@ from aio_pika import IncomingMessage, RobustQueue from fast_depends.dependencies import Dependant - from faststream._internal.basic_types import AnyDict, LoggerProto + from faststream._internal.basic_types import AnyDict from faststream._internal.publisher.proto import BasePublisherProto - from faststream._internal.setup import SetupState + from faststream._internal.state import BrokerState from faststream._internal.types import BrokerMiddleware, CustomCallable from faststream.message import StreamMessage from faststream.rabbit.helpers.declarer import RabbitDeclarer @@ -100,24 +100,18 @@ def _setup( # type: ignore[override] virtual_host: str, declarer: "RabbitDeclarer", # basic args - logger: Optional["LoggerProto"], - producer: Optional["AioPikaFastProducer"], - graceful_timeout: Optional[float], extra_context: "AnyDict", # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "SetupState", + state: "BrokerState", ) -> None: self.app_id = app_id self.virtual_host = virtual_host self.declarer = declarer super()._setup( - logger=logger, - producer=producer, - graceful_timeout=graceful_timeout, extra_context=extra_context, broker_parser=broker_parser, broker_decoder=broker_decoder, @@ -173,6 +167,7 @@ async def get_one( self, *, timeout: float = 5.0, + # TODO: make it no_ack ack_policy: AckPolicy = AckPolicy.REJECT_ON_ERROR, ) -> "Optional[RabbitMessage]": assert self._queue_obj, "You should start subscriber at first." # nosec B101 @@ -197,7 +192,7 @@ async def get_one( msg: Optional[RabbitMessage] = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -209,12 +204,9 @@ def _make_response_publisher( self, message: "StreamMessage[Any]", ) -> Sequence["BasePublisherProto"]: - if self._producer is None: - return () - return ( RabbitFakePublisher( - self._producer, + self._state.producer, routing_key=message.reply_to, app_id=self.app_id, ), diff --git a/faststream/rabbit/testing.py b/faststream/rabbit/testing.py index 97472fa5fe..3aa50e6dc5 100644 --- a/faststream/rabbit/testing.py +++ b/faststream/rabbit/testing.py @@ -1,4 +1,4 @@ -from collections.abc import Generator, Mapping +from collections.abc import Generator, Iterator, Mapping from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Optional, Union from unittest import mock @@ -56,9 +56,16 @@ def _patch_broker(self, broker: "RabbitBroker") -> Generator[None, None, None]: ): yield + @contextmanager + def _patch_producer(self, broker: RabbitBroker) -> Iterator[None]: + old_producer = broker._state.producer + broker._state.producer = FakeProducer(broker) + yield + broker._state.producer = old_producer + @staticmethod async def _fake_connect(broker: "RabbitBroker", *args: Any, **kwargs: Any) -> None: - broker._producer = FakeProducer(broker) + pass @staticmethod def create_publisher_fake_subscriber( diff --git a/faststream/redis/broker/broker.py b/faststream/redis/broker/broker.py index edfc5774c6..c1982d6236 100644 --- a/faststream/redis/broker/broker.py +++ b/faststream/redis/broker/broker.py @@ -90,7 +90,7 @@ class RedisBroker( """Redis broker.""" url: str - _producer: Optional[RedisFastProducer] + _producer: "RedisFastProducer" def __init__( self, @@ -193,8 +193,6 @@ def __init__( Doc("Any custom decorator to apply to wrapped functions."), ] = (), ) -> None: - self._producer = None - if specification_url is None: specification_url = url @@ -250,6 +248,11 @@ def __init__( _call_decorators=_call_decorators, ) + self._state.producer = RedisFastProducer( + parser=self._parser, + decoder=self._decoder, + ) + @override async def connect( # type: ignore[override] self, @@ -328,11 +331,7 @@ async def _connect( # type: ignore[override] ) client: Redis[bytes] = Redis.from_pool(pool) # type: ignore[attr-defined] - self._producer = RedisFastProducer( - connection=client, - parser=self._parser, - decoder=self._decoder, - ) + self._producer.connect(client) return client async def close( @@ -343,6 +342,8 @@ async def close( ) -> None: await super().close(exc_type, exc_val, exc_tb) + self._producer.disconnect() + if self._connection is not None: await self._connection.aclose() # type: ignore[attr-defined] self._connection = None @@ -418,7 +419,7 @@ async def publish( # type: ignore[override] maxlen=maxlen, reply_to=reply_to, headers=headers, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await super()._basic_publish(cmd, producer=self._producer) @@ -444,7 +445,7 @@ async def request( # type: ignore[override] maxlen=maxlen, headers=headers, timeout=timeout, - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, ) msg: RedisMessage = await super()._basic_request(cmd, producer=self._producer) return msg @@ -482,7 +483,7 @@ async def publish_batch( reply_to=reply_to, headers=headers, correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish_batch(cmd, producer=self._producer) diff --git a/faststream/redis/broker/logging.py b/faststream/redis/broker/logging.py index 3855efbdf8..6fe8f718ae 100644 --- a/faststream/redis/broker/logging.py +++ b/faststream/redis/broker/logging.py @@ -1,8 +1,9 @@ +import logging from functools import partial from typing import TYPE_CHECKING, Optional from faststream._internal.log.logging import get_broker_logger -from faststream._internal.setup.logger import ( +from faststream._internal.state.logger import ( DefaultLoggerStorage, make_logger_state, ) @@ -21,6 +22,11 @@ def __init__( self._max_channel_name = 4 + self.logger_log_level = logging.INFO + + def set_level(self, level: int) -> None: + self.logger_log_level = level + def setup_log_contest(self, params: "AnyDict") -> None: self._max_channel_name = max( ( @@ -47,10 +53,11 @@ def get_logger(self, *, context: "ContextRepo") -> Optional["LoggerProto"]: "- %(message)s" ), context=context, + log_level=self.logger_log_level, ) make_redis_logger_state = partial( make_logger_state, - default_storag_cls=RedisParamsStorage, + default_storage_cls=RedisParamsStorage, ) diff --git a/faststream/redis/broker/registrator.py b/faststream/redis/broker/registrator.py index 1599a10d46..598105ef9b 100644 --- a/faststream/redis/broker/registrator.py +++ b/faststream/redis/broker/registrator.py @@ -103,7 +103,7 @@ def subscriber( # type: ignore[override] # subscriber args ack_policy=ack_policy, no_reply=no_reply, - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, broker_dependencies=self._dependencies, # AsyncAPI title_=title, @@ -189,7 +189,7 @@ def publisher( # type: ignore[override] headers=headers, reply_to=reply_to, # Specific - broker_middlewares=self._middlewares, + broker_middlewares=self.middlewares, middlewares=middlewares, # AsyncAPI title_=title, diff --git a/faststream/redis/helpers/__init__.py b/faststream/redis/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/faststream/redis/helpers/state.py b/faststream/redis/helpers/state.py new file mode 100644 index 0000000000..1d3d2a6bad --- /dev/null +++ b/faststream/redis/helpers/state.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING, Protocol + +from faststream.exceptions import IncorrectState + +if TYPE_CHECKING: + from redis.asyncio.client import Redis + + +class ConnectionState(Protocol): + client: "Redis[bytes]" + + +class EmptyConnectionState(ConnectionState): + __slots__ = () + + error_msg = "You should connect broker first." + + @property + def client(self) -> "Redis[bytes]": + raise IncorrectState(self.error_msg) + + +class ConnectedState(ConnectionState): + __slots__ = ("client",) + + def __init__(self, client: "Redis[bytes]") -> None: + self.client = client diff --git a/faststream/redis/publisher/producer.py b/faststream/redis/publisher/producer.py index 57afef2d31..fe6c050202 100644 --- a/faststream/redis/publisher/producer.py +++ b/faststream/redis/publisher/producer.py @@ -6,6 +6,11 @@ from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func from faststream._internal.utils.nuid import NUID +from faststream.redis.helpers.state import ( + ConnectedState, + ConnectionState, + EmptyConnectionState, +) from faststream.redis.message import DATA_KEY from faststream.redis.parser import RawMessage, RedisPubSubParser from faststream.redis.response import DestinationType, RedisPublishCommand @@ -22,17 +27,15 @@ class RedisFastProducer(ProducerProto): """A class to represent a Redis producer.""" - _connection: "Redis[bytes]" _decoder: "AsyncCallable" _parser: "AsyncCallable" def __init__( self, - connection: "Redis[bytes]", parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - self._connection = connection + self._connection: ConnectionState = EmptyConnectionState() default = RedisPubSubParser() self._parser = resolve_custom_func( @@ -44,6 +47,12 @@ def __init__( default.decode_message, ) + def connect(self, client: "Redis[bytes]") -> None: + self._connection = ConnectedState(client) + + def disconnect(self) -> None: + self._connection = EmptyConnectionState() + @override async def publish( # type: ignore[override] self, @@ -65,7 +74,7 @@ async def request( # type: ignore[override] ) -> "Any": nuid = NUID() reply_to = str(nuid.next(), "utf-8") - psub = self._connection.pubsub() + psub = self._connection.client.pubsub() await psub.subscribe(reply_to) msg = RawMessage.encode( @@ -112,15 +121,15 @@ async def publish_batch( ) for msg in cmd.batch_bodies ] - await self._connection.rpush(cmd.destination, *batch) + await self._connection.client.rpush(cmd.destination, *batch) async def __publish(self, msg: bytes, cmd: "RedisPublishCommand") -> None: if cmd.destination_type is DestinationType.Channel: - await self._connection.publish(cmd.destination, msg) + await self._connection.client.publish(cmd.destination, msg) elif cmd.destination_type is DestinationType.List: - await self._connection.rpush(cmd.destination, msg) + await self._connection.client.rpush(cmd.destination, msg) elif cmd.destination_type is DestinationType.Stream: - await self._connection.xadd( + await self._connection.client.xadd( name=cmd.destination, fields={DATA_KEY: msg}, maxlen=cmd.maxlen, diff --git a/faststream/redis/publisher/usecase.py b/faststream/redis/publisher/usecase.py index 34e6f934c6..09c1fae294 100644 --- a/faststream/redis/publisher/usecase.py +++ b/faststream/redis/publisher/usecase.py @@ -134,7 +134,7 @@ async def publish( reply_to=reply_to or self.reply_to, headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish(cmd, _extra_middlewares=()) @@ -188,7 +188,7 @@ async def request( channel=channel or self.channel.name, headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, timeout=timeout, ) @@ -271,7 +271,7 @@ async def publish( reply_to=reply_to or self.reply_to, headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) return await self._basic_publish(cmd, _extra_middlewares=()) @@ -326,7 +326,7 @@ async def request( list=list or self.list.name, headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, timeout=timeout, ) @@ -368,7 +368,7 @@ async def publish( # type: ignore[override] reply_to=reply_to or self.reply_to, headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) await self._basic_publish_batch(cmd, _extra_middlewares=()) @@ -475,7 +475,7 @@ async def publish( headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), maxlen=maxlen or self.stream.maxlen, - _publish_type=PublishType.Publish, + _publish_type=PublishType.PUBLISH, ) return await self._basic_publish(cmd, _extra_middlewares=()) @@ -538,7 +538,7 @@ async def request( stream=stream or self.stream.name, headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), - _publish_type=PublishType.Request, + _publish_type=PublishType.REQUEST, maxlen=maxlen or self.stream.maxlen, timeout=timeout, ) diff --git a/faststream/redis/response.py b/faststream/redis/response.py index a0b830b6c9..6328dbbd28 100644 --- a/faststream/redis/response.py +++ b/faststream/redis/response.py @@ -41,7 +41,7 @@ def as_publish_command(self) -> "RedisPublishCommand": self.body, headers=self.headers, correlation_id=self.correlation_id, - _publish_type=PublishType.Reply, + _publish_type=PublishType.REPLY, # Kafka specific channel="fake-channel", # it will be replaced by reply-sender maxlen=self.maxlen, diff --git a/faststream/redis/subscriber/usecase.py b/faststream/redis/subscriber/usecase.py index 995fc70454..9c8ca4ddd5 100644 --- a/faststream/redis/subscriber/usecase.py +++ b/faststream/redis/subscriber/usecase.py @@ -45,9 +45,9 @@ if TYPE_CHECKING: from fast_depends.dependencies import Dependant - from faststream._internal.basic_types import AnyDict, LoggerProto - from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto - from faststream._internal.setup import SetupState + from faststream._internal.basic_types import AnyDict + from faststream._internal.publisher.proto import BasePublisherProto + from faststream._internal.state import BrokerState from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -104,22 +104,16 @@ def _setup( # type: ignore[override] *, connection: Optional["Redis[bytes]"], # basic args - logger: Optional["LoggerProto"], - producer: Optional["ProducerProto"], - graceful_timeout: Optional[float], extra_context: "AnyDict", # broker options broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "SetupState", + state: "BrokerState", ) -> None: self._client = connection super()._setup( - logger=logger, - producer=producer, - graceful_timeout=graceful_timeout, extra_context=extra_context, broker_parser=broker_parser, broker_decoder=broker_decoder, @@ -130,12 +124,9 @@ def _make_response_publisher( self, message: "BrokerStreamMessage[UnifyRedisDict]", ) -> Sequence["BasePublisherProto"]: - if self._producer is None: - return () - return ( RedisFakePublisher( - self._producer, + self._state.producer, channel=message.reply_to, ), ) @@ -296,7 +287,7 @@ async def get_one( # type: ignore[override] msg: Optional[RedisMessage] = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.depends_params.context) + m(raw_message, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -423,7 +414,7 @@ async def get_one( # type: ignore[override] msg: RedisListMessage = await process_msg( # type: ignore[assignment] msg=redis_incoming_msg, middlewares=( - m(redis_incoming_msg, context=self._state.depends_params.context) + m(redis_incoming_msg, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, @@ -711,7 +702,7 @@ async def get_one( # type: ignore[override] msg: RedisStreamMessage = await process_msg( # type: ignore[assignment] msg=redis_incoming_msg, middlewares=( - m(redis_incoming_msg, context=self._state.depends_params.context) + m(redis_incoming_msg, context=self._state.di_state.context) for m in self._broker_middlewares ), parser=self._parser, diff --git a/faststream/redis/testing.py b/faststream/redis/testing.py index 2237af768d..e3584fbcb0 100644 --- a/faststream/redis/testing.py +++ b/faststream/redis/testing.py @@ -1,5 +1,6 @@ import re -from collections.abc import Sequence +from collections.abc import Iterator, Sequence +from contextlib import contextmanager from typing import ( TYPE_CHECKING, Any, @@ -72,13 +73,19 @@ def create_publisher_fake_subscriber( return sub, is_real + @contextmanager + def _patch_producer(self, broker: RedisBroker) -> Iterator[None]: + old_producer = broker._state.producer + broker._state.producer = FakeProducer(broker) + yield + broker._state.producer = old_producer + @staticmethod async def _fake_connect( # type: ignore[override] broker: RedisBroker, *args: Any, **kwargs: Any, ) -> AsyncMock: - broker._producer = FakeProducer(broker) connection = MagicMock() pub_sub = AsyncMock() diff --git a/faststream/response/publish_type.py b/faststream/response/publish_type.py index b837b91632..ad74910a1e 100644 --- a/faststream/response/publish_type.py +++ b/faststream/response/publish_type.py @@ -2,11 +2,11 @@ class PublishType(str, Enum): - Publish = "Publish" + PUBLISH = "PUBLISH" """Regular `broker/publisher.publish(...)` call.""" - Reply = "Reply" + REPLY = "REPLY" """Response to RPC/Reply-To request.""" - Request = "Request" + REQUEST = "REQUEST" """RPC request call.""" diff --git a/faststream/response/response.py b/faststream/response/response.py index 09136c8a28..ff44643f35 100644 --- a/faststream/response/response.py +++ b/faststream/response/response.py @@ -24,7 +24,7 @@ def as_publish_command(self) -> "PublishCommand": body=self.body, headers=self.headers, correlation_id=self.correlation_id, - _publish_type=PublishType.Reply, + _publish_type=PublishType.REPLY, ) diff --git a/tests/asgi/testcase.py b/tests/asgi/testcase.py index 20f4ed6fc7..438bd60d49 100644 --- a/tests/asgi/testcase.py +++ b/tests/asgi/testcase.py @@ -49,7 +49,7 @@ def test_asgi_ping_unhealthy(self) -> None: with TestClient(app) as client: response = client.get("/health") - assert response.status_code == 500 + assert response.status_code == 500, response.status_code @pytest.mark.asyncio() async def test_asgi_ping_healthy(self) -> None: diff --git a/tests/brokers/base/router.py b/tests/brokers/base/router.py index 1f50340fb9..92ffbb3269 100644 --- a/tests/brokers/base/router.py +++ b/tests/brokers/base/router.py @@ -448,7 +448,7 @@ def subscriber() -> None: ... sub = next(iter(pub_broker._subscribers)) publisher = next(iter(pub_broker._publishers)) assert len((*sub._broker_middlewares, *sub.calls[0].item_middlewares)) == 5 - assert len((*publisher._broker_middlewares, *publisher._middlewares)) == 4 + assert len((*publisher._broker_middlewares, *publisher.middlewares)) == 4 async def test_router_include_with_middlewares( self, @@ -473,7 +473,7 @@ def subscriber() -> None: ... sub_middlewares = (*sub._broker_middlewares, *sub.calls[0].item_middlewares) assert len(sub_middlewares) == 5, sub_middlewares - assert len((*publisher._broker_middlewares, *publisher._middlewares)) == 4 + assert len((*publisher._broker_middlewares, *publisher.middlewares)) == 4 async def test_router_parser( self, diff --git a/tests/brokers/base/testclient.py b/tests/brokers/base/testclient.py index 6326eacdbb..bd342fcb1f 100644 --- a/tests/brokers/base/testclient.py +++ b/tests/brokers/base/testclient.py @@ -129,17 +129,19 @@ async def test_broker_gets_patched_attrs_within_cm(self, fake_producer_cls) -> N test_broker = self.get_broker() await test_broker.start() + old_producer = test_broker._state.producer + async with self.patch_broker(test_broker) as br: assert isinstance(br.start, Mock) assert isinstance(br._connect, Mock) assert isinstance(br.close, Mock) - assert isinstance(br._producer, fake_producer_cls) + assert isinstance(br._state.producer, fake_producer_cls) assert not isinstance(br.start, Mock) assert not isinstance(br._connect, Mock) assert not isinstance(br.close, Mock) assert br._connection is not None - assert not isinstance(br._producer, fake_producer_cls) + assert br._state.producer == old_producer async def test_broker_with_real_doesnt_get_patched(self) -> None: test_broker = self.get_broker() diff --git a/tests/brokers/rabbit/specific/test_declare.py b/tests/brokers/rabbit/specific/test_declare.py index 02467a1704..874fb403cc 100644 --- a/tests/brokers/rabbit/specific/test_declare.py +++ b/tests/brokers/rabbit/specific/test_declare.py @@ -6,7 +6,8 @@ @pytest.mark.asyncio() async def test_declare_queue(async_mock, queue: str) -> None: - declarer = RabbitDeclarer(async_mock) + declarer = RabbitDeclarer() + declarer.connect(async_mock, async_mock) q1 = await declarer.declare_queue(RabbitQueue(queue)) q2 = await declarer.declare_queue(RabbitQueue(queue)) @@ -20,7 +21,8 @@ async def test_declare_exchange( async_mock, queue: str, ) -> None: - declarer = RabbitDeclarer(async_mock) + declarer = RabbitDeclarer() + declarer.connect(async_mock, async_mock) ex1 = await declarer.declare_exchange(RabbitExchange(queue)) ex2 = await declarer.declare_exchange(RabbitExchange(queue)) @@ -34,7 +36,8 @@ async def test_declare_nested_exchange_cash_nested( async_mock, queue: str, ) -> None: - declarer = RabbitDeclarer(async_mock) + declarer = RabbitDeclarer() + declarer.connect(async_mock, async_mock) exchange = RabbitExchange(queue) @@ -50,7 +53,8 @@ async def test_publisher_declare( async_mock, queue: str, ) -> None: - declarer = RabbitDeclarer(async_mock) + declarer = RabbitDeclarer() + declarer.connect(async_mock, async_mock) broker = RabbitBroker() broker._connection = async_mock diff --git a/tests/cli/rabbit/test_logs.py b/tests/cli/rabbit/test_logs.py index d9abbcd0cc..4cef8e2a76 100644 --- a/tests/cli/rabbit/test_logs.py +++ b/tests/cli/rabbit/test_logs.py @@ -20,8 +20,8 @@ ) def test_set_level(level, app: FastStream) -> None: level = get_log_level(level) - app._setup() set_log_level(level, app) + app.broker._state._setup_logger_state() broker_logger = app.broker._state.logger_state.logger.logger assert app.logger.level is broker_logger.level is level diff --git a/tests/cli/test_publish.py b/tests/cli/test_publish.py index c545a03a72..5d1f57b82a 100644 --- a/tests/cli/test_publish.py +++ b/tests/cli/test_publish.py @@ -31,7 +31,7 @@ def get_mock_app(broker_type, producer_type) -> tuple[FastStream, AsyncMock]: mock_producer.publish = AsyncMock() mock_producer._parser = AsyncMock() mock_producer._decoder = AsyncMock() - broker._producer = mock_producer + broker._state.producer = mock_producer return FastStream(broker), mock_producer @@ -230,4 +230,4 @@ def test_publish_nats_request_command(runner: CliRunner) -> None: assert cmd.destination == "subjectname" assert cmd.timeout == 1.0 - assert cmd.publish_type is PublishType.Request + assert cmd.publish_type is PublishType.REQUEST diff --git a/tests/opentelemetry/basic.py b/tests/opentelemetry/basic.py index 446365895c..30d4ba0895 100644 --- a/tests/opentelemetry/basic.py +++ b/tests/opentelemetry/basic.py @@ -288,7 +288,7 @@ async def handler(m) -> None: async with broker: await broker.start() - broker._middlewares = () + broker.middlewares = () tasks = ( asyncio.create_task(broker.publish(msg, queue)), asyncio.create_task(event.wait()), @@ -560,7 +560,7 @@ async def test_get_baggage_from_headers( queue: str, ): mid = self.telemetry_middleware_class() - broker = self.broker_class(middlewares=(mid,)) + broker = self.get_broker(middlewares=(mid,), apply_types=True) args, kwargs = self.get_subscriber_params(queue) diff --git a/tests/prometheus/confluent/test_provider.py b/tests/prometheus/confluent/test_provider.py index 87d9f5659b..6949a1ff26 100644 --- a/tests/prometheus/confluent/test_provider.py +++ b/tests/prometheus/confluent/test_provider.py @@ -86,7 +86,7 @@ def test_get_consume_attrs_from_message(self, queue: str) -> None: pytest.param( (SimpleNamespace(), SimpleNamespace()), BatchConfluentMetricsSettingsProvider(), - id="message is batch", + id="batch message", ), pytest.param( SimpleNamespace(), @@ -96,7 +96,7 @@ def test_get_consume_attrs_from_message(self, queue: str) -> None: pytest.param( None, ConfluentMetricsSettingsProvider(), - id="message is None", + id="None message", ), ), ) diff --git a/tests/prometheus/kafka/test_provider.py b/tests/prometheus/kafka/test_provider.py index 4127eed58e..1e0c980981 100644 --- a/tests/prometheus/kafka/test_provider.py +++ b/tests/prometheus/kafka/test_provider.py @@ -81,7 +81,7 @@ def test_get_consume_attrs_from_message(self, queue: str) -> None: pytest.param( (SimpleNamespace(), SimpleNamespace()), BatchKafkaMetricsSettingsProvider(), - id="message is batch", + id="batch message", ), pytest.param( SimpleNamespace(), @@ -91,7 +91,7 @@ def test_get_consume_attrs_from_message(self, queue: str) -> None: pytest.param( None, KafkaMetricsSettingsProvider(), - id="message is None", + id="None message", ), ), ) diff --git a/tests/prometheus/redis/test_provider.py b/tests/prometheus/redis/test_provider.py index 9bafd26402..469f5237b8 100644 --- a/tests/prometheus/redis/test_provider.py +++ b/tests/prometheus/redis/test_provider.py @@ -105,7 +105,7 @@ def test_get_consume_attrs_from_message(self, queue: str, destination: str) -> N pytest.param( {"type": "blist"}, BatchRedisMetricsSettingsProvider(), - id="message is batch", + id="batch message", ), pytest.param( {"type": "not_blist"}, @@ -115,7 +115,7 @@ def test_get_consume_attrs_from_message(self, queue: str, destination: str) -> N pytest.param( None, RedisMetricsSettingsProvider(), - id="message is None", + id="None message", ), ), ) From cd87d8c4c079b3129f74ab18687d1021003beb5f Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Wed, 6 Nov 2024 20:50:31 +0300 Subject: [PATCH 37/48] fix: correct RMQ subscriber get_one logic --- faststream/rabbit/subscriber/usecase.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/faststream/rabbit/subscriber/usecase.py b/faststream/rabbit/subscriber/usecase.py index 8bad07afad..29e1d738cd 100644 --- a/faststream/rabbit/subscriber/usecase.py +++ b/faststream/rabbit/subscriber/usecase.py @@ -11,7 +11,6 @@ from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg from faststream.exceptions import SetupError -from faststream.middlewares import AckPolicy from faststream.rabbit.parser import AioPikaParser from faststream.rabbit.publisher.fake import RabbitFakePublisher from faststream.rabbit.schemas import BaseRMQInformation @@ -25,6 +24,7 @@ from faststream._internal.state import BrokerState from faststream._internal.types import BrokerMiddleware, CustomCallable from faststream.message import StreamMessage + from faststream.middlewares import AckPolicy from faststream.rabbit.helpers.declarer import RabbitDeclarer from faststream.rabbit.message import RabbitMessage from faststream.rabbit.publisher.producer import AioPikaFastProducer @@ -167,8 +167,7 @@ async def get_one( self, *, timeout: float = 5.0, - # TODO: make it no_ack - ack_policy: AckPolicy = AckPolicy.REJECT_ON_ERROR, + no_ack: bool = True, ) -> "Optional[RabbitMessage]": assert self._queue_obj, "You should start subscriber at first." # nosec B101 assert ( # nosec B101 @@ -178,7 +177,6 @@ async def get_one( sleep_interval = timeout / 10 raw_message: Optional[IncomingMessage] = None - no_ack = self.ack_policy is AckPolicy.DO_NOTHING with anyio.move_on_after(timeout): while ( # noqa: ASYNC110 raw_message := await self._queue_obj.get( From fc61e913cea6d09b25524141af7272496606a20f Mon Sep 17 00:00:00 2001 From: sheldy <85823514+sheldygg@users.noreply.github.com> Date: Wed, 6 Nov 2024 19:17:25 +0100 Subject: [PATCH 38/48] Warning in NatsSubscriber factory (#1894) * Warnings in nats subscriber factory * add category and stacklavel to warnings * ruff --- faststream/nats/subscriber/factory.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index 1161c66550..c17556fe85 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -1,3 +1,4 @@ +import warnings from typing import TYPE_CHECKING, Any, Iterable, Optional, Union from nats.aio.subscription import ( @@ -123,6 +124,13 @@ def create_subscriber( } if obj_watch is not None: + if max_workers > 1: + warnings.warn( + "`max_workers` has no effect for ObjectValue subscriber.", + RuntimeWarning, + stacklevel=3, + ) + return AsyncAPIObjStoreWatchSubscriber( subject=subject, config=config, @@ -135,6 +143,13 @@ def create_subscriber( ) if kv_watch is not None: + if max_workers > 1: + warnings.warn( + "`max_workers` has no effect for KeyValue subscriber.", + RuntimeWarning, + stacklevel=3, + ) + return AsyncAPIKeyValueWatchSubscriber( subject=subject, config=config, From 9c25b7b6cfee619adaf6553cee9a7f35a0f539cc Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Thu, 7 Nov 2024 19:24:45 +0300 Subject: [PATCH 39/48] fix (#1856): call factory once in regular case (#1898) * fix (#1874): call factory once in regular case * chore: bump version * lint: fix mypy * chore: fix pydantic 2.10 compatibility --------- Co-authored-by: Kumaran Rajendhiran --- faststream/__about__.py | 2 +- faststream/_compat.py | 11 ++++++++--- faststream/cli/main.py | 21 +++++++++++++++++++-- faststream/cli/utils/logs.py | 4 ++-- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/faststream/__about__.py b/faststream/__about__.py index f350fa0af9..2aed3de26e 100644 --- a/faststream/__about__.py +++ b/faststream/__about__.py @@ -1,5 +1,5 @@ """Simple and fast framework to create message brokers based microservices.""" -__version__ = "0.5.28" +__version__ = "0.5.29" SERVICE_NAME = f"faststream-{__version__}" diff --git a/faststream/_compat.py b/faststream/_compat.py index 08c150d382..b23013f4bd 100644 --- a/faststream/_compat.py +++ b/faststream/_compat.py @@ -66,9 +66,14 @@ def json_dumps(*a: Any, **kw: Any) -> bytes: with_info_plain_validator_function as with_info_plain_validator_function, ) else: - from pydantic._internal._annotated_handlers import ( # type: ignore[no-redef] - GetJsonSchemaHandler as GetJsonSchemaHandler, - ) + if PYDANTIC_VERSION >= "2.10": + from pydantic.annotated_handlers import ( + GetJsonSchemaHandler as GetJsonSchemaHandler, + ) + else: + from pydantic._internal._annotated_handlers import ( # type: ignore[no-redef] + GetJsonSchemaHandler as GetJsonSchemaHandler, + ) from pydantic_core.core_schema import ( general_plain_validator_function as with_info_plain_validator_function, ) diff --git a/faststream/cli/main.py b/faststream/cli/main.py index 2775261e72..2a2115061e 100644 --- a/faststream/cli/main.py +++ b/faststream/cli/main.py @@ -159,7 +159,11 @@ def run( _run(*args) else: - _run(*args) + _run_imported_app( + app_obj, + extra_options=extra, + log_level=casted_log_level, + ) def _run( @@ -168,11 +172,24 @@ def _run( extra_options: Dict[str, "SettingField"], is_factory: bool, log_level: int = logging.NOTSET, - app_level: int = logging.INFO, + app_level: int = logging.INFO, # option for reloader only ) -> None: """Runs the specified application.""" _, app_obj = import_from_string(app, is_factory=is_factory) + _run_imported_app( + app_obj, + extra_options=extra_options, + log_level=log_level, + app_level=app_level, + ) + +def _run_imported_app( + app_obj: "Application", + extra_options: Dict[str, "SettingField"], + log_level: int = logging.NOTSET, + app_level: int = logging.INFO, # option for reloader only +) -> None: if not isinstance(app_obj, Application): raise typer.BadParameter( f'Imported object "{app_obj}" must be "Application" type.', diff --git a/faststream/cli/utils/logs.py b/faststream/cli/utils/logs.py index b576db49ee..c695b2e5be 100644 --- a/faststream/cli/utils/logs.py +++ b/faststream/cli/utils/logs.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, DefaultDict, Optional, Union if TYPE_CHECKING: - from faststream.app import FastStream + from faststream._internal.application import Application from faststream.types import LoggerProto @@ -64,7 +64,7 @@ def get_log_level(level: Union[LogLevels, str, int]) -> int: return LOG_LEVELS[level.lower()] -def set_log_level(level: int, app: "FastStream") -> None: +def set_log_level(level: int, app: "Application") -> None: """Sets the log level for an application.""" if app.logger and getattr(app.logger, "setLevel", None): app.logger.setLevel(level) # type: ignore[attr-defined] From 080c684e90be01259dde3e8498bdba7e2b324f4c Mon Sep 17 00:00:00 2001 From: Kursat Aktas Date: Thu, 7 Nov 2024 23:22:54 +0300 Subject: [PATCH 40/48] Introducing FastStream Guru on Gurubase.io (#1903) Signed-off-by: Kursat Aktas Co-authored-by: Pastukhov Nikita --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index f1b579c40b..b499e4a62d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Supported Python versions + + Gurubase + +
From aa1562d852cc9ef9d9a24311a2b1b7535a2b611e Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Thu, 7 Nov 2024 23:44:55 +0300 Subject: [PATCH 41/48] chore: fix pydantic 2.10 compatibility --- faststream/_internal/broker/abc_broker.py | 35 ++- faststream/_internal/broker/broker.py | 82 +++--- faststream/_internal/broker/pub_base.py | 25 +- faststream/_internal/broker/router.py | 2 + faststream/_internal/cli/utils/logs.py | 2 +- faststream/_internal/publisher/proto.py | 4 +- faststream/_internal/publisher/usecase.py | 33 ++- faststream/_internal/state/pointer.py | 11 +- faststream/_internal/subscriber/call_item.py | 14 +- faststream/_internal/subscriber/proto.py | 4 +- faststream/_internal/subscriber/usecase.py | 21 +- faststream/_internal/testing/broker.py | 15 +- faststream/confluent/broker/broker.py | 17 +- faststream/confluent/client.py | 3 +- faststream/confluent/publisher/producer.py | 8 +- faststream/confluent/publisher/usecase.py | 12 +- faststream/confluent/subscriber/usecase.py | 7 +- faststream/confluent/testing.py | 6 +- faststream/kafka/broker/broker.py | 22 +- faststream/kafka/publisher/producer.py | 8 +- faststream/kafka/publisher/usecase.py | 14 +- faststream/kafka/subscriber/usecase.py | 7 +- faststream/kafka/testing.py | 6 +- faststream/nats/broker/broker.py | 272 +++++++++---------- faststream/nats/fastapi/fastapi.py | 16 -- faststream/nats/publisher/producer.py | 12 +- faststream/nats/publisher/usecase.py | 124 ++++++--- faststream/nats/subscriber/factory.py | 2 +- faststream/nats/subscriber/usecase.py | 40 +-- faststream/nats/testing.py | 13 +- faststream/rabbit/broker/broker.py | 36 ++- faststream/rabbit/publisher/usecase.py | 3 +- faststream/rabbit/subscriber/usecase.py | 7 +- faststream/rabbit/testing.py | 6 +- faststream/redis/broker/broker.py | 16 +- faststream/redis/publisher/producer.py | 8 +- faststream/redis/publisher/usecase.py | 16 +- faststream/redis/subscriber/usecase.py | 17 +- faststream/redis/testing.py | 6 +- tests/brokers/base/testclient.py | 6 +- tests/brokers/nats/test_consume.py | 10 +- tests/cli/rabbit/test_logs.py | 5 +- tests/cli/test_publish.py | 2 +- 43 files changed, 527 insertions(+), 448 deletions(-) diff --git a/faststream/_internal/broker/abc_broker.py b/faststream/_internal/broker/abc_broker.py index b50b0195c5..0fc795231e 100644 --- a/faststream/_internal/broker/abc_broker.py +++ b/faststream/_internal/broker/abc_broker.py @@ -7,6 +7,7 @@ Optional, ) +from faststream._internal.state import BrokerState, Pointer from faststream._internal.types import BrokerMiddleware, CustomCallable, MsgType if TYPE_CHECKING: @@ -29,6 +30,7 @@ def __init__( parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], include_in_schema: Optional[bool], + state: "BrokerState", ) -> None: self.prefix = prefix self.include_in_schema = include_in_schema @@ -41,6 +43,8 @@ def __init__( self._parser = parser self._decoder = decoder + self._state = Pointer(state) + def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: """Append BrokerMiddleware to the end of middlewares list. @@ -58,20 +62,37 @@ def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: def subscriber( self, subscriber: "SubscriberProto[MsgType]", + is_running: bool = False, ) -> "SubscriberProto[MsgType]": subscriber.add_prefix(self.prefix) - self._subscribers.append(subscriber) + if not is_running: + self._subscribers.append(subscriber) return subscriber @abstractmethod def publisher( self, publisher: "PublisherProto[MsgType]", + is_running: bool = False, ) -> "PublisherProto[MsgType]": publisher.add_prefix(self.prefix) - self._publishers.append(publisher) + + if not is_running: + self._publishers.append(publisher) + return publisher + def setup_publisher( + self, + publisher: "PublisherProto[MsgType]", + **kwargs: Any, + ) -> None: + """Setup the Publisher to prepare it to starting.""" + publisher._setup(**kwargs, state=self._state) + + def _setup(self, state: "Pointer[BrokerState]") -> None: + self._state.set(state) + def include_router( self, router: "ABCBroker[Any]", @@ -82,6 +103,8 @@ def include_router( include_in_schema: Optional[bool] = None, ) -> None: """Includes a router in the current object.""" + router._setup(self._state) + for h in router._subscribers: h.add_prefix(f"{self.prefix}{prefix}") @@ -126,6 +149,8 @@ def include_routers( self.include_router(r) def _solve_include_in_schema(self, include_in_schema: bool) -> bool: - if self.include_in_schema is None or self.include_in_schema: - return include_in_schema - return self.include_in_schema + # should be `is False` to pass `None` case + if self.include_in_schema is False: + return False + + return include_in_schema diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index 73f7aea2c5..591b14d903 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -22,7 +22,6 @@ SetupAble, ) from faststream._internal.state.broker import ( - BrokerState, InitialBrokerState, ) from faststream._internal.state.producer import ProducerUnset @@ -137,6 +136,20 @@ def __init__( ], **connection_kwargs: Any, ) -> None: + state = InitialBrokerState( + di_state=DIState( + use_fastdepends=apply_types, + get_dependent=_get_dependant, + call_decorators=_call_decorators, + serializer=serializer, + provider=Provider(), + context=ContextRepo(), + ), + logger_state=logger_state, + graceful_timeout=graceful_timeout, + producer=ProducerUnset(), + ) + super().__init__( middlewares=middlewares, dependencies=dependencies, @@ -151,6 +164,7 @@ def __init__( # Broker is a root router include_in_schema=True, prefix="", + state=state, ) self.running = False @@ -163,20 +177,6 @@ def __init__( *self.middlewares, ) - self._state: BrokerState = InitialBrokerState( - di_state=DIState( - use_fastdepends=apply_types, - get_dependent=_get_dependant, - call_decorators=_call_decorators, - serializer=serializer, - provider=Provider(), - context=ContextRepo(), - ), - logger_state=logger_state, - graceful_timeout=graceful_timeout, - producer=ProducerUnset(), - ) - # AsyncAPI information self.url = specification_url self.protocol = protocol @@ -187,15 +187,15 @@ def __init__( @property def _producer(self) -> "ProducerProto": - return self._state.producer + return self._state.get().producer @property def context(self) -> "ContextRepo": - return self._state.di_state.context + return self._state.get().di_state.context @property def provider(self) -> Provider: - return self._state.di_state.provider + return self._state.get().di_state.provider async def __aenter__(self) -> "Self": await self.connect() @@ -213,20 +213,25 @@ async def __aexit__( async def start(self) -> None: """Start the broker async use case.""" # TODO: filter by already running handlers after TestClient refactor + state = self._state.get() + for subscriber in self._subscribers: log_context = subscriber.get_log_context(None) log_context.pop("message_id", None) - self._state.logger_state.params_storage.setup_log_contest(log_context) + state.logger_state.params_storage.setup_log_contest(log_context) - self._state._setup_logger_state() + state._setup_logger_state() for subscriber in self._subscribers: - self._state.logger_state.log( + state.logger_state.log( f"`{subscriber.call_name}` waiting for messages", extra=subscriber.get_log_context(None), ) await subscriber.start() + if not self.running: + self.running = True + async def connect(self, **kwargs: Any) -> ConnectionType: """Connect to a remote server.""" if self._connection is None: @@ -246,13 +251,15 @@ def _setup(self, di_state: Optional[DIState] = None) -> None: Method should be idempotent due could be called twice """ - broker_serializer = self._state.di_state.serializer + broker_state = self._state.get() + current_di_state = broker_state.di_state + broker_serializer = current_di_state.serializer if di_state is not None: if broker_serializer is EMPTY: broker_serializer = di_state.serializer - self._state.di_state.update( + current_di_state.update( serializer=broker_serializer, provider=di_state.provider, context=di_state.context, @@ -266,15 +273,11 @@ def _setup(self, di_state: Optional[DIState] = None) -> None: broker_serializer = PydanticSerializer() - self._state.di_state.update( + current_di_state.update( serializer=broker_serializer, ) - self._state._setup() - - # TODO: move to start - if not self.running: - self.running = True + broker_state._setup() # TODO: move setup to object creation for h in self._subscribers: @@ -293,16 +296,6 @@ def setup_subscriber( data.update(kwargs) subscriber._setup(**data, state=self._state) - def setup_publisher( - self, - publisher: "PublisherProto[MsgType]", - **kwargs: Any, - ) -> None: - """Setup the Publisher to prepare it to starting.""" - data = self._publisher_setup_extra.copy() - data.update(kwargs) - publisher._setup(**data, state=self._state) - @property def _subscriber_setup_extra(self) -> "AnyDict": return { @@ -314,16 +307,9 @@ def _subscriber_setup_extra(self) -> "AnyDict": "broker_decoder": self._decoder, } - @property - def _publisher_setup_extra(self) -> "AnyDict": - return { - "producer": self._producer, - } - def publisher(self, *args: Any, **kwargs: Any) -> "PublisherProto[MsgType]": - pub = super().publisher(*args, **kwargs) - if self.running: - self.setup_publisher(pub) + pub = super().publisher(*args, **kwargs, is_running=self.running) + self.setup_publisher(pub) return pub async def close( diff --git a/faststream/_internal/broker/pub_base.py b/faststream/_internal/broker/pub_base.py index 000f007d52..8d97050cc8 100644 --- a/faststream/_internal/broker/pub_base.py +++ b/faststream/_internal/broker/pub_base.py @@ -1,7 +1,7 @@ from abc import abstractmethod from collections.abc import Iterable from functools import partial -from typing import TYPE_CHECKING, Any, Generic, Optional +from typing import TYPE_CHECKING, Any, Generic from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import MsgType @@ -25,7 +25,7 @@ async def publish( message: "SendableMessage", queue: str, /, - ) -> None: + ) -> Any: raise NotImplementedError async def _basic_publish( @@ -33,11 +33,12 @@ async def _basic_publish( cmd: "PublishCommand", *, producer: "ProducerProto", - ) -> Optional[Any]: + ) -> Any: publish = producer.publish + context = self.context # caches property for m in self.middlewares: - publish = partial(m(None, context=self.context).publish_scope, publish) + publish = partial(m(None, context=context).publish_scope, publish) return await publish(cmd) @@ -46,7 +47,7 @@ async def publish_batch( self, *messages: "SendableMessage", queue: str, - ) -> None: + ) -> Any: raise NotImplementedError async def _basic_publish_batch( @@ -54,13 +55,14 @@ async def _basic_publish_batch( cmd: "PublishCommand", *, producer: "ProducerProto", - ) -> None: + ) -> Any: publish = producer.publish_batch + context = self.context # caches property for m in self.middlewares: - publish = partial(m(None, context=self.context).publish_scope, publish) + publish = partial(m(None, context=context).publish_scope, publish) - await publish(cmd) + return await publish(cmd) @abstractmethod async def request( @@ -79,17 +81,16 @@ async def _basic_request( producer: "ProducerProto", ) -> Any: request = producer.request + context = self.context # caches property for m in self.middlewares: - request = partial(m(None, context=self.context).publish_scope, request) + request = partial(m(None, context=context).publish_scope, request) published_msg = await request(cmd) response_msg: Any = await process_msg( msg=published_msg, - middlewares=( - m(published_msg, context=self.context) for m in self.middlewares - ), + middlewares=(m(published_msg, context=context) for m in self.middlewares), parser=producer._parser, decoder=producer._decoder, source_type=SourceType.RESPONSE, diff --git a/faststream/_internal/broker/router.py b/faststream/_internal/broker/router.py index 6daa2f245a..c2fe269e1d 100644 --- a/faststream/_internal/broker/router.py +++ b/faststream/_internal/broker/router.py @@ -6,6 +6,7 @@ Optional, ) +from faststream._internal.state.broker import EmptyBrokerState from faststream._internal.types import ( BrokerMiddleware, CustomCallable, @@ -81,6 +82,7 @@ def __init__( parser=parser, decoder=decoder, include_in_schema=include_in_schema, + state=EmptyBrokerState("You should include router to any broker."), ) for h in handlers: diff --git a/faststream/_internal/cli/utils/logs.py b/faststream/_internal/cli/utils/logs.py index cd2aa47ea5..d2e5d61b78 100644 --- a/faststream/_internal/cli/utils/logs.py +++ b/faststream/_internal/cli/utils/logs.py @@ -68,4 +68,4 @@ def set_log_level(level: int, app: "FastStream") -> None: if app.logger and getattr(app.logger, "setLevel", None): app.logger.setLevel(level) # type: ignore[attr-defined] - app.broker._state.logger_state.set_level(level) + app.broker._state.get().logger_state.set_level(level) diff --git a/faststream/_internal/publisher/proto.py b/faststream/_internal/publisher/proto.py index 49e7b3151c..710cc65944 100644 --- a/faststream/_internal/publisher/proto.py +++ b/faststream/_internal/publisher/proto.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage - from faststream._internal.state import BrokerState + from faststream._internal.state import BrokerState, Pointer from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -109,7 +109,7 @@ def _setup( # type: ignore[override] self, *, producer: Optional["ProducerProto"], - state: "BrokerState", + state: "Pointer[BrokerState]", ) -> None: ... @abstractmethod diff --git a/faststream/_internal/publisher/usecase.py b/faststream/_internal/publisher/usecase.py index d487e54859..f1d1e276fb 100644 --- a/faststream/_internal/publisher/usecase.py +++ b/faststream/_internal/publisher/usecase.py @@ -33,7 +33,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict from faststream._internal.publisher.proto import ProducerProto - from faststream._internal.state import BrokerState + from faststream._internal.state import BrokerState, Pointer from faststream._internal.types import ( BrokerMiddleware, PublisherMiddleware, @@ -83,7 +83,7 @@ def __init__( self.middlewares = middlewares self._broker_middlewares = broker_middlewares - self._producer: ProducerProto = ProducerUnset() + self.__producer: Optional[ProducerProto] = ProducerUnset() self._fake_handler = False self.mock = None @@ -97,15 +97,20 @@ def __init__( def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: self._broker_middlewares = (*self._broker_middlewares, middleware) + @property + def _producer(self) -> "ProducerProto": + return self.__producer or self._state.get().producer + @override def _setup( # type: ignore[override] self, *, - producer: "ProducerProto", - state: Optional["BrokerState"], + state: "Pointer[BrokerState]", + producer: Optional["ProducerProto"] = None, ) -> None: + # TODO: add EmptyBrokerState to init self._state = state - self._producer = producer + self.__producer = producer def set_test( self, @@ -150,11 +155,13 @@ async def _basic_publish( ) -> Any: pub: Callable[..., Awaitable[Any]] = self._producer.publish + context = self._state.get().di_state.context + for pub_m in chain( ( _extra_middlewares or ( - m(None, context=self._state.di_state.context).publish_scope + m(None, context=context).publish_scope for m in self._broker_middlewares ) ), @@ -168,10 +175,10 @@ async def _basic_request( self, cmd: "PublishCommand", ) -> Optional[Any]: - context = self._state.di_state.context - request = self._producer.request + context = self._state.get().di_state.context + for pub_m in chain( (m(None, context=context).publish_scope for m in self._broker_middlewares), self.middlewares, @@ -199,11 +206,13 @@ async def _basic_publish_batch( ) -> Optional[Any]: pub = self._producer.publish_batch + context = self._state.get().di_state.context + for pub_m in chain( ( _extra_middlewares or ( - m(None, context=self._state.di_state.context).publish_scope + m(None, context=context).publish_scope for m in self._broker_middlewares ) ), @@ -230,11 +239,13 @@ def get_payloads(self) -> list[tuple["AnyDict", str]]: payloads.append((body, "")) else: + di_state = self._state.get().di_state + for call in self.calls: call_model = build_call_model( call, - dependency_provider=self._state.di_state.provider, - serializer_cls=self._state.di_state.serializer, + dependency_provider=di_state.provider, + serializer_cls=di_state.serializer, ) response_type = next( diff --git a/faststream/_internal/state/pointer.py b/faststream/_internal/state/pointer.py index 68a41de65b..dbe927d5f9 100644 --- a/faststream/_internal/state/pointer.py +++ b/faststream/_internal/state/pointer.py @@ -1,7 +1,10 @@ -from typing import Generic, TypeVar +from typing import TYPE_CHECKING, Generic, TypeVar from typing_extensions import Self +if TYPE_CHECKING: + from faststream._internal.basic_types import AnyDict + T = TypeVar("T") @@ -11,9 +14,13 @@ class Pointer(Generic[T]): def __init__(self, value: T) -> None: self.__value = value - def change(self, new_value: T) -> "Self": + def set(self, new_value: T) -> "Self": self.__value = new_value return self def get(self) -> T: return self.__value + + def patch_value(self, **kwargs: "AnyDict") -> None: + for k, v in kwargs.items(): + setattr(self.__value, k, v) diff --git a/faststream/_internal/subscriber/call_item.py b/faststream/_internal/subscriber/call_item.py index 3a172dd432..ddcddacd01 100644 --- a/faststream/_internal/subscriber/call_item.py +++ b/faststream/_internal/subscriber/call_item.py @@ -20,7 +20,7 @@ from fast_depends.dependencies import Dependant from faststream._internal.basic_types import AsyncFuncAny, Decorator - from faststream._internal.state import DIState + from faststream._internal.state import BrokerState, Pointer from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper from faststream._internal.types import ( AsyncCallable, @@ -75,11 +75,13 @@ def _setup( # type: ignore[override] *, parser: "AsyncCallable", decoder: "AsyncCallable", + state: "Pointer[BrokerState]", broker_dependencies: Iterable["Dependant"], - fast_depends_state: "DIState", _call_decorators: Iterable["Decorator"], ) -> None: if self.dependant is None: + di_state = state.get().di_state + self.item_parser = parser self.item_decoder = decoder @@ -87,14 +89,14 @@ def _setup( # type: ignore[override] dependant = self.handler.set_wrapped( dependencies=dependencies, - _call_decorators=_call_decorators, - state=fast_depends_state, + _call_decorators=(*_call_decorators, *di_state.call_decorators), + state=di_state, ) - if fast_depends_state.get_dependent is None: + if di_state.get_dependent is None: self.dependant = dependant else: - self.dependant = fast_depends_state.get_dependent( + self.dependant = di_state.get_dependent( self.handler._original_call, dependencies, ) diff --git a/faststream/_internal/subscriber/proto.py b/faststream/_internal/subscriber/proto.py index 14e9e51f4c..547c04f51c 100644 --- a/faststream/_internal/subscriber/proto.py +++ b/faststream/_internal/subscriber/proto.py @@ -17,7 +17,7 @@ BasePublisherProto, ProducerProto, ) - from faststream._internal.state import BrokerState + from faststream._internal.state import BrokerState, Pointer from faststream._internal.subscriber.call_item import HandlerItem from faststream._internal.types import ( BrokerMiddleware, @@ -61,7 +61,7 @@ def _setup( # type: ignore[override] broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "BrokerState", + state: "Pointer[BrokerState]", ) -> None: ... @abstractmethod diff --git a/faststream/_internal/subscriber/usecase.py b/faststream/_internal/subscriber/usecase.py index 60ac9f1b83..622578fc22 100644 --- a/faststream/_internal/subscriber/usecase.py +++ b/faststream/_internal/subscriber/usecase.py @@ -40,7 +40,7 @@ from faststream._internal.publisher.proto import ( BasePublisherProto, ) - from faststream._internal.state import BrokerState + from faststream._internal.state import BrokerState, Pointer from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -148,8 +148,9 @@ def _setup( # type: ignore[override] broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "BrokerState", + state: "Pointer[BrokerState]", ) -> None: + # TODO: add EmptyBrokerState to init self._state = state self.extra_context = extra_context @@ -171,12 +172,9 @@ def _setup( # type: ignore[override] call._setup( parser=async_parser, decoder=async_decoder, - fast_depends_state=state.di_state, - _call_decorators=( - *self._call_decorators, - *state.di_state.call_decorators, - ), + state=state, broker_dependencies=self._broker_dependencies, + _call_decorators=self._call_decorators, ) call.handler.refresh(with_mock=False) @@ -196,7 +194,7 @@ async def close(self) -> None: """ self.running = False if isinstance(self.lock, MultiLock): - await self.lock.wait_release(self._state.graceful_timeout) + await self.lock.wait_release(self._state.get().graceful_timeout) def add_call( self, @@ -303,7 +301,7 @@ async def consume(self, msg: MsgType) -> Any: # Stop handler at `exit()` call await self.close() - if app := self._state.di_state.context.get("app"): + if app := self._state.get().di_state.context.get("app"): app.exit() except Exception: # nosec B110 @@ -312,14 +310,15 @@ async def consume(self, msg: MsgType) -> Any: async def process_message(self, msg: MsgType) -> "Response": """Execute all message processing stages.""" - context: ContextRepo = self._state.di_state.context + broker_state = self._state.get() + context: ContextRepo = broker_state.di_state.context async with AsyncExitStack() as stack: stack.enter_context(self.lock) # Enter context before middlewares stack.enter_context( - context.scope("logger", self._state.logger_state.logger.logger) + context.scope("logger", broker_state.logger_state.logger.logger) ) for k, v in self.extra_context.items(): stack.enter_context(context.scope(k, v)) diff --git a/faststream/_internal/testing/broker.py b/faststream/_internal/testing/broker.py index 00468fa947..ac57846c80 100644 --- a/faststream/_internal/testing/broker.py +++ b/faststream/_internal/testing/broker.py @@ -100,10 +100,17 @@ def _patch_producer(self, broker: Broker) -> Iterator[None]: @contextmanager def _patch_logger(self, broker: Broker) -> Iterator[None]: - old_logger = broker._state.logger_state.logger - broker._state.logger_state.logger = RealLoggerObject(MagicMock()) - yield - broker._state.logger_state.logger = old_logger + state = broker._state.get() + state._setup_logger_state() + + logger_state = state.logger_state + old_log_object = logger_state.logger + + logger_state.logger = RealLoggerObject(MagicMock()) + try: + yield + finally: + logger_state.logger = old_log_object @contextmanager def _patch_broker(self, broker: Broker) -> Generator[None, None, None]: diff --git a/faststream/confluent/broker/broker.py b/faststream/confluent/broker/broker.py index 9f57f8217e..d8a1cd6671 100644 --- a/faststream/confluent/broker/broker.py +++ b/faststream/confluent/broker/broker.py @@ -33,6 +33,7 @@ from .registrator import KafkaRegistrator if TYPE_CHECKING: + import asyncio from types import TracebackType from confluent_kafka import Message @@ -401,9 +402,11 @@ def __init__( self.config = ConfluentFastConfig(config) - self._state.producer = AsyncConfluentFastProducer( - parser=self._parser, - decoder=self._decoder, + self._state.patch_value( + producer=AsyncConfluentFastProducer( + parser=self._parser, + decoder=self._decoder, + ) ) async def close( @@ -452,7 +455,7 @@ async def _connect( # type: ignore[override] return partial( AsyncConfluentConsumer, **filter_by_dict(ConsumerConnectionParams, kwargs), - logger=self._state.logger_state, + logger=self._state.get().logger_state, config=self.config, ) @@ -503,7 +506,7 @@ async def publish( # type: ignore[override] bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, - ) -> None: + ) -> "asyncio.Future": """Publish message directly. This method allows you to publish message in not AsyncAPI-documented way. You can use it in another frameworks @@ -523,7 +526,7 @@ async def publish( # type: ignore[override] correlation_id=correlation_id or gen_cor_id(), _publish_type=PublishType.PUBLISH, ) - await super()._basic_publish(cmd, producer=self._producer) + return await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] @@ -576,7 +579,7 @@ async def publish_batch( _publish_type=PublishType.PUBLISH, ) - await self._basic_publish_batch(cmd, producer=self._producer) + return await self._basic_publish_batch(cmd, producer=self._producer) @override async def ping(self, timeout: Optional[float]) -> bool: diff --git a/faststream/confluent/client.py b/faststream/confluent/client.py index 32afc81c36..7b7b0b8fe2 100644 --- a/faststream/confluent/client.py +++ b/faststream/confluent/client.py @@ -134,7 +134,7 @@ async def send( timestamp_ms: Optional[int] = None, headers: Optional[list[tuple[str, Union[str, bytes]]]] = None, no_confirm: bool = False, - ) -> None: + ) -> "asyncio.Future": """Sends a single message to a Kafka topic.""" kwargs: _SendKwargs = { "value": value, @@ -164,6 +164,7 @@ def ack_callback(err: Any, msg: Optional[Message]) -> None: if not no_confirm: await result_future + return result_future def create_batch(self) -> "BatchBuilder": """Creates a batch for sending multiple messages.""" diff --git a/faststream/confluent/publisher/producer.py b/faststream/confluent/publisher/producer.py index 0aa22e9eba..8c6144586b 100644 --- a/faststream/confluent/publisher/producer.py +++ b/faststream/confluent/publisher/producer.py @@ -11,6 +11,8 @@ from .state import EmptyProducerState, ProducerState, RealProducer if TYPE_CHECKING: + import asyncio + from faststream._internal.types import CustomCallable from faststream.confluent.client import AsyncConfluentProducer from faststream.confluent.response import KafkaPublishCommand @@ -48,7 +50,7 @@ async def ping(self, timeout: float) -> None: async def publish( # type: ignore[override] self, cmd: "KafkaPublishCommand", - ) -> None: + ) -> "asyncio.Future": """Publish a message to a topic.""" message, content_type = encode_message(cmd.body) @@ -57,7 +59,7 @@ async def publish( # type: ignore[override] **cmd.headers_to_publish(), } - await self._producer.producer.send( + return await self._producer.producer.send( topic=cmd.destination, value=message, key=cmd.key, @@ -105,6 +107,6 @@ async def publish_batch( async def request( self, cmd: "KafkaPublishCommand", - ) -> Optional[Any]: + ) -> Any: msg = "Kafka doesn't support `request` method without test client." raise FeatureNotSupportedException(msg) diff --git a/faststream/confluent/publisher/usecase.py b/faststream/confluent/publisher/usecase.py index 1416632dc6..d6b7132155 100644 --- a/faststream/confluent/publisher/usecase.py +++ b/faststream/confluent/publisher/usecase.py @@ -16,6 +16,8 @@ from faststream.response.publish_type import PublishType if TYPE_CHECKING: + import asyncio + from faststream._internal.basic_types import SendableMessage from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.confluent.message import KafkaMessage @@ -26,7 +28,7 @@ class LogicPublisher(PublisherUsecase[MsgType]): """A class to publish messages to a Kafka topic.""" - _producer: Optional["AsyncConfluentFastProducer"] + _producer: "AsyncConfluentFastProducer" def __init__( self, @@ -59,8 +61,6 @@ def __init__( self.reply_to = reply_to self.headers = headers or {} - self._producer = None - def add_prefix(self, prefix: str) -> None: self.topic = f"{prefix}{self.topic}" @@ -141,7 +141,7 @@ async def publish( correlation_id: Optional[str] = None, reply_to: str = "", no_confirm: bool = False, - ) -> None: + ) -> "asyncio.Future": cmd = KafkaPublishCommand( message, topic=topic or self.topic, @@ -154,7 +154,7 @@ async def publish( no_confirm=no_confirm, _publish_type=PublishType.PUBLISH, ) - await self._basic_publish(cmd, _extra_middlewares=()) + return await self._basic_publish(cmd, _extra_middlewares=()) @override async def _publish( @@ -226,7 +226,7 @@ async def publish( _publish_type=PublishType.PUBLISH, ) - await self._basic_publish_batch(cmd, _extra_middlewares=()) + return await self._basic_publish_batch(cmd, _extra_middlewares=()) @override async def _publish( diff --git a/faststream/confluent/subscriber/usecase.py b/faststream/confluent/subscriber/usecase.py index c02b016b10..adb321dd4a 100644 --- a/faststream/confluent/subscriber/usecase.py +++ b/faststream/confluent/subscriber/usecase.py @@ -165,11 +165,12 @@ async def get_one( raw_message = await self.consumer.getone(timeout=timeout) + context = self._state.get().di_state.context + return await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -181,7 +182,7 @@ def _make_response_publisher( ) -> Sequence["BasePublisherProto"]: return ( KafkaFakePublisher( - self._state.producer, + self._state.get().producer, topic=message.reply_to, ), ) diff --git a/faststream/confluent/testing.py b/faststream/confluent/testing.py index c254098d25..92676d6e7a 100644 --- a/faststream/confluent/testing.py +++ b/faststream/confluent/testing.py @@ -38,10 +38,10 @@ class TestKafkaBroker(TestBroker[KafkaBroker]): @contextmanager def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: - old_producer = broker._state.producer - broker._state.producer = FakeProducer(broker) + old_producer = broker._state.get().producer + broker._state.patch_value(producer=FakeProducer(broker)) yield - broker._state.producer = old_producer + broker._state.patch_value(producer=old_producer) @staticmethod async def _fake_connect( # type: ignore[override] diff --git a/faststream/kafka/broker/broker.py b/faststream/kafka/broker/broker.py index 0c03172e93..0f962f0d3b 100644 --- a/faststream/kafka/broker/broker.py +++ b/faststream/kafka/broker/broker.py @@ -36,7 +36,7 @@ Partition = TypeVar("Partition") if TYPE_CHECKING: - from asyncio import AbstractEventLoop + import asyncio from types import TracebackType from aiokafka import ConsumerRecord @@ -95,7 +95,7 @@ class KafkaInitKwargs(TypedDict, total=False): Optional[AbstractTokenProvider], Doc("OAuthBearer token provider instance."), ] - loop: Optional[AbstractEventLoop] + loop: Optional[asyncio.AbstractEventLoop] client_id: Annotated[ Optional[str], Doc( @@ -292,7 +292,7 @@ def __init__( Optional["AbstractTokenProvider"], Doc("OAuthBearer token provider instance."), ] = None, - loop: Optional["AbstractEventLoop"] = None, + loop: Optional["asyncio.AbstractEventLoop"] = None, client_id: Annotated[ Optional[str], Doc( @@ -580,9 +580,11 @@ def __init__( ) self.client_id = client_id - self._state.producer = AioKafkaFastProducer( - parser=self._parser, - decoder=self._decoder, + self._state.patch_value( + producer=AioKafkaFastProducer( + parser=self._parser, + decoder=self._decoder, + ) ) async def close( @@ -720,7 +722,7 @@ async def publish( # type: ignore[override] bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, - ) -> None: + ) -> "asyncio.Future": """Publish message directly. This method allows you to publish message in not AsyncAPI-documented way. You can use it in another frameworks @@ -740,7 +742,7 @@ async def publish( # type: ignore[override] correlation_id=correlation_id or gen_cor_id(), _publish_type=PublishType.PUBLISH, ) - await super()._basic_publish(cmd, producer=self._producer) + return await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] @@ -864,7 +866,7 @@ async def publish_batch( bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, - ) -> None: + ) -> "asyncio.Future": assert self._producer, NOT_CONNECTED_YET # nosec B101 cmd = KafkaPublishCommand( @@ -879,7 +881,7 @@ async def publish_batch( _publish_type=PublishType.PUBLISH, ) - await self._basic_publish_batch(cmd, producer=self._producer) + return await self._basic_publish_batch(cmd, producer=self._producer) @override async def ping(self, timeout: Optional[float]) -> bool: diff --git a/faststream/kafka/publisher/producer.py b/faststream/kafka/publisher/producer.py index eba2276e6b..a6574e104d 100644 --- a/faststream/kafka/publisher/producer.py +++ b/faststream/kafka/publisher/producer.py @@ -12,6 +12,8 @@ from .state import EmptyProducerState, ProducerState, RealProducer if TYPE_CHECKING: + import asyncio + from aiokafka import AIOKafkaProducer from faststream._internal.types import CustomCallable @@ -56,7 +58,7 @@ def closed(self) -> bool: async def publish( # type: ignore[override] self, cmd: "KafkaPublishCommand", - ) -> None: + ) -> "asyncio.Future": """Publish a message to a topic.""" message, content_type = encode_message(cmd.body) @@ -76,11 +78,12 @@ async def publish( # type: ignore[override] if not cmd.no_confirm: await send_future + return send_future async def publish_batch( self, cmd: "KafkaPublishCommand", - ) -> None: + ) -> "asyncio.Future": """Publish a batch of messages to a topic.""" batch = self._producer.producer.create_batch() @@ -111,6 +114,7 @@ async def publish_batch( ) if not cmd.no_confirm: await send_future + return send_future @override async def request( diff --git a/faststream/kafka/publisher/usecase.py b/faststream/kafka/publisher/usecase.py index 7174237e49..0f005770de 100644 --- a/faststream/kafka/publisher/usecase.py +++ b/faststream/kafka/publisher/usecase.py @@ -18,6 +18,8 @@ from faststream.response.publish_type import PublishType if TYPE_CHECKING: + import asyncio + from faststream._internal.basic_types import SendableMessage from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.kafka.message import KafkaMessage @@ -28,7 +30,7 @@ class LogicPublisher(PublisherUsecase[MsgType]): """A class to publish messages to a Kafka topic.""" - _producer: Optional["AioKafkaFastProducer"] + _producer: "AioKafkaFastProducer" def __init__( self, @@ -61,8 +63,6 @@ def __init__( self.reply_to = reply_to self.headers = headers or {} - self._producer = None - def add_prefix(self, prefix: str) -> None: self.topic = f"{prefix}{self.topic}" @@ -240,7 +240,7 @@ async def publish( bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, - ) -> None: + ) -> "asyncio.Future": cmd = KafkaPublishCommand( message, topic=topic or self.topic, @@ -253,7 +253,7 @@ async def publish( no_confirm=no_confirm, _publish_type=PublishType.PUBLISH, ) - await self._basic_publish(cmd, _extra_middlewares=()) + return await self._basic_publish(cmd, _extra_middlewares=()) @override async def _publish( @@ -395,7 +395,7 @@ async def publish( bool, Doc("Do not wait for Kafka publish confirmation."), ] = False, - ) -> None: + ) -> "asyncio.Future": cmd = KafkaPublishCommand( *messages, key=None, @@ -409,7 +409,7 @@ async def publish( _publish_type=PublishType.PUBLISH, ) - await self._basic_publish_batch(cmd, _extra_middlewares=()) + return await self._basic_publish_batch(cmd, _extra_middlewares=()) @override async def _publish( diff --git a/faststream/kafka/subscriber/usecase.py b/faststream/kafka/subscriber/usecase.py index f622ad6b9d..fab52a66f2 100644 --- a/faststream/kafka/subscriber/usecase.py +++ b/faststream/kafka/subscriber/usecase.py @@ -188,11 +188,12 @@ async def get_one( ((raw_message,),) = raw_messages.values() + context = self._state.get().di_state.context + msg: StreamMessage[MsgType] = await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -205,7 +206,7 @@ def _make_response_publisher( ) -> Sequence["BasePublisherProto"]: return ( KafkaFakePublisher( - self._state.producer, + self._state.get().producer, topic=message.reply_to, ), ) diff --git a/faststream/kafka/testing.py b/faststream/kafka/testing.py index 90c097eac1..96e0614183 100755 --- a/faststream/kafka/testing.py +++ b/faststream/kafka/testing.py @@ -40,10 +40,10 @@ class TestKafkaBroker(TestBroker[KafkaBroker]): @contextmanager def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: - old_producer = broker._state.producer - broker._state.producer = FakeProducer(broker) + old_producer = broker._state.get().producer + broker._state.patch_value(producer=FakeProducer(broker)) yield - broker._state.producer = old_producer + broker._state.patch_value(producer=old_producer) @staticmethod async def _fake_connect( # type: ignore[override] diff --git a/faststream/nats/broker/broker.py b/faststream/nats/broker/broker.py index 72e9687580..f6d24fa15f 100644 --- a/faststream/nats/broker/broker.py +++ b/faststream/nats/broker/broker.py @@ -1,5 +1,4 @@ import logging -import warnings from collections.abc import Iterable from typing import ( TYPE_CHECKING, @@ -27,7 +26,7 @@ from nats.aio.msg import Msg from nats.errors import Error from nats.js.errors import BadRequestError -from typing_extensions import Doc, override +from typing_extensions import Doc, Literal, overload, override from faststream.__about__ import SERVICE_NAME from faststream._internal.broker.broker import BrokerUsecase @@ -45,7 +44,6 @@ from .state import BrokerState, ConnectedState, EmptyBrokerState if TYPE_CHECKING: - import ssl from types import TracebackType from fast_depends.dependencies import Dependant @@ -57,7 +55,7 @@ JWTCallback, SignatureCallback, ) - from nats.js.api import Placement, RePublish, StorageType + from nats.js.api import Placement, PubAck, RePublish, StorageType from nats.js.kv import KeyValue from nats.js.object_store import ObjectStore from typing_extensions import TypedDict, Unpack @@ -153,22 +151,10 @@ class NatsInitKwargs(TypedDict, total=False): bool, Doc("Boolean indicating should commands be echoed."), ] - tls: Annotated[ - Optional["ssl.SSLContext"], - Doc("Some SSL context to make NATS connections secure."), - ] tls_hostname: Annotated[ Optional[str], Doc("Hostname for TLS."), ] - user: Annotated[ - Optional[str], - Doc("Username for NATS auth."), - ] - password: Annotated[ - Optional[str], - Doc("Username password for NATS auth."), - ] token: Annotated[ Optional[str], Doc("Auth token for NATS auth."), @@ -309,22 +295,10 @@ def __init__( bool, Doc("Boolean indicating should commands be echoed."), ] = False, - tls: Annotated[ - Optional["ssl.SSLContext"], - Doc("Some SSL context to make NATS connections secure."), - ] = None, tls_hostname: Annotated[ Optional[str], Doc("Hostname for TLS."), ] = None, - user: Annotated[ - Optional[str], - Doc("Username for NATS auth."), - ] = None, - password: Annotated[ - Optional[str], - Doc("Username password for NATS auth."), - ] = None, token: Annotated[ Optional[str], Doc("Auth token for NATS auth."), @@ -449,32 +423,7 @@ def __init__( ] = (), ) -> None: """Initialize the NatsBroker object.""" - if tls: # pragma: no cover - warnings.warn( - ( - "\nNATS `tls` option was deprecated and will be removed in 0.6.0" - "\nPlease, use `security` with `BaseSecurity` or `SASLPlaintext` instead" - ), - DeprecationWarning, - stacklevel=2, - ) - - if user or password: - warnings.warn( - ( - "\nNATS `user` and `password` options were deprecated and will be removed in 0.6.0" - "\nPlease, use `security` with `SASLPlaintext` instead" - ), - DeprecationWarning, - stacklevel=2, - ) - - secure_kwargs = { - "tls": tls, - "user": user, - "password": password, - **parse_security(security), - } + secure_kwargs = parse_security(security) servers = [servers] if isinstance(servers, str) else list(servers) @@ -546,9 +495,11 @@ def __init__( _call_decorators=_call_decorators, ) - self._state.producer = NatsFastProducer( - parser=self._parser, - decoder=self._decoder, + self._state.patch_value( + producer=NatsFastProducer( + parser=self._parser, + decoder=self._decoder, + ) ) self._js_producer = NatsJSFastProducer( @@ -564,15 +515,19 @@ def __init__( @override async def connect( # type: ignore[override] self, - servers: Annotated[ - Union[str, Iterable[str]], - Doc("NATS cluster addresses to connect."), - ] = EMPTY, + servers: Union[str, Iterable[str]] = EMPTY, **kwargs: "Unpack[NatsInitKwargs]", ) -> "Client": """Connect broker object to NATS cluster. To startup subscribers too you should use `broker.start()` after/instead this method. + + Args: + servers: NATS cluster addresses to connect. + **kwargs: all other options from connection signature. + + Returns: + `nats.aio.Client` connected object. """ if servers is not EMPTY: connect_kwargs: AnyDict = { @@ -589,7 +544,7 @@ async def _connect(self, **kwargs: Any) -> "Client": stream = connection.jetstream() - self._state.producer.connect(connection) + self._producer.connect(connection) self._js_producer.connect(stream) self._kv_declarer.connect(stream) @@ -611,7 +566,7 @@ async def close( await self._connection.drain() self._connection = None - self._state.producer.disconnect() + self._producer.disconnect() self._js_producer.disconnect() self._kv_declarer.disconnect() self._os_declarer.disconnect() @@ -643,13 +598,15 @@ async def start(self) -> None: stream=stream.name, ) + logger_state = self._state.get().logger_state + if ( e.description == "stream name already in use with a different configuration" ): old_config = (await stream_context.stream_info(stream.name)).config - self._state.logger_state.log(str(e), logging.WARNING, log_context) + logger_state.log(str(e), logging.WARNING, log_context) for subject in old_config.subjects or (): stream.add_subject(subject) @@ -657,7 +614,7 @@ async def start(self) -> None: await stream_context.update_stream(config=stream.config) else: # pragma: no cover - self._state.logger_state.log( + logger_state.log( str(e), logging.ERROR, log_context, @@ -670,56 +627,71 @@ async def start(self) -> None: await super().start() + @overload + async def publish( + self, + message: "SendableMessage", + subject: str, + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + correlation_id: Optional[str] = None, + stream: Literal[None] = None, + timeout: Optional[float] = None, + ) -> None: ... + + @overload + async def publish( + self, + message: "SendableMessage", + subject: str, + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + correlation_id: Optional[str] = None, + stream: Optional[str] = None, + timeout: Optional[float] = None, + ) -> "PubAck": ... + @override - async def publish( # type: ignore[override] + async def publish( self, - message: Annotated[ - "SendableMessage", - Doc( - "Message body to send. " - "Can be any encodable object (native python types or `pydantic.BaseModel`).", - ), - ], - subject: Annotated[ - str, - Doc("NATS subject to send message."), - ], - headers: Annotated[ - Optional[dict[str, str]], - Doc( - "Message headers to store metainformation. " - "**content-type** and **correlation_id** will be set automatically by framework anyway.", - ), - ] = None, - reply_to: Annotated[ - str, - Doc("NATS subject name to send response."), - ] = "", - correlation_id: Annotated[ - Optional[str], - Doc( - "Manual message **correlation_id** setter. " - "**correlation_id** is a useful option to trace messages.", - ), - ] = None, - stream: Annotated[ - Optional[str], - Doc( - "This option validates that the target subject is in presented stream. " - "Can be omitted without any effect.", - ), - ] = None, - timeout: Annotated[ - Optional[float], - Doc("Timeout to send message to NATS."), - ] = None, - ) -> None: + message: "SendableMessage", + subject: str, + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + correlation_id: Optional[str] = None, + stream: Optional[str] = None, + timeout: Optional[float] = None, + ) -> Optional["PubAck"]: """Publish message directly. This method allows you to publish message in not AsyncAPI-documented way. You can use it in another frameworks applications or to publish messages from time to time. Please, use `@broker.publisher(...)` or `broker.publisher(...).publish(...)` instead in a regular way. + + Args: + message: + Message body to send. + Can be any encodable object (native python types or `pydantic.BaseModel`). + subject: + NATS subject to send message. + headers: + Message headers to store metainformation. + **content-type** and **correlation_id** will be set automatically by framework anyway. + reply_to: + NATS subject name to send response. + correlation_id: + Manual message **correlation_id** setter. + **correlation_id** is a useful option to trace messages. + stream: + This option validates that the target subject is in presented stream. + Can be omitted without any effect if you doesn't want PubAck frame. + timeout: + Timeout to send message to NATS. + + Returns: + `None` if you publishes a regular message. + `nats.js.api.PubAck` if you publishes a message to stream. """ cmd = NatsPublishCommand( message=message, @@ -732,50 +704,48 @@ async def publish( # type: ignore[override] _publish_type=PublishType.PUBLISH, ) - producer = self._state.producer if stream is None else self._js_producer + producer = self._js_producer if stream is not None else self._producer - await super()._basic_publish(cmd, producer=producer) + return await super()._basic_publish(cmd, producer=producer) @override async def request( # type: ignore[override] self, - message: Annotated[ - "SendableMessage", - Doc( - "Message body to send. " - "Can be any encodable object (native python types or `pydantic.BaseModel`).", - ), - ], - subject: Annotated[ - str, - Doc("NATS subject to send message."), - ], - headers: Annotated[ - Optional[dict[str, str]], - Doc( - "Message headers to store metainformation. " - "**content-type** and **correlation_id** will be set automatically by framework anyway.", - ), - ] = None, - correlation_id: Annotated[ - Optional[str], - Doc( - "Manual message **correlation_id** setter. " - "**correlation_id** is a useful option to trace messages.", - ), - ] = None, - stream: Annotated[ - Optional[str], - Doc( - "This option validates that the target subject is in presented stream. " - "Can be omitted without any effect.", - ), - ] = None, - timeout: Annotated[ - float, - Doc("Timeout to send message to NATS."), - ] = 0.5, + message: "SendableMessage", + subject: str, + headers: Optional[dict[str, str]] = None, + correlation_id: Optional[str] = None, + stream: Optional[str] = None, + timeout: float = 0.5, ) -> "NatsMessage": + """Make a synchronous request to outer subscriber. + + If out subscriber listens subject by stream, you should setup the same **stream** explicitly. + Another way you will reseave confirmation frame as a response. + + Args: + message: + Message body to send. + Can be any encodable object (native python types or `pydantic.BaseModel`). + subject: + NATS subject to send message. + headers: + Message headers to store metainformation. + **content-type** and **correlation_id** will be set automatically by framework anyway. + reply_to: + NATS subject name to send response. + correlation_id: + Manual message **correlation_id** setter. + **correlation_id** is a useful option to trace messages. + stream: + This option validates that the target subject is in presented stream. + Can be omitted without any effect if you doesn't want PubAck frame. + timeout: + Timeout to send message to NATS. + + Returns: + `faststream.nats.message.NatsMessage` object as an outer subscriber response. + """ cmd = NatsPublishCommand( message=message, correlation_id=correlation_id or gen_cor_id(), @@ -786,7 +756,7 @@ async def request( # type: ignore[override] _publish_type=PublishType.REQUEST, ) - producer = self._state.producer if stream is None else self._js_producer + producer = self._js_producer if stream is not None else self._producer msg: NatsMessage = await super()._basic_request(cmd, producer=producer) return msg @@ -808,9 +778,7 @@ def setup_publisher( # type: ignore[override] self, publisher: "SpecificationPublisher", ) -> None: - producer = ( - self._state.producer if publisher.stream is None else self._js_producer - ) + producer = self._js_producer if publisher.stream is not None else self._producer super().setup_publisher(publisher, producer=producer) @@ -881,7 +849,7 @@ async def wrapper(err: Exception) -> None: await error_cb(err) if isinstance(err, Error) and self._connection_state: - self._state.logger_state.log( + self._state.get().logger_state.log( f"Connection broken with {err!r}", logging.WARNING, c, @@ -902,7 +870,9 @@ async def wrapper() -> None: await cb() if not self._connection_state: - self._state.logger_state.log("Connection established", logging.INFO, c) + self._state.get().logger_state.log( + "Connection established", logging.INFO, c + ) self._connection_state = self._connection_state.reconnect() return wrapper diff --git a/faststream/nats/fastapi/fastapi.py b/faststream/nats/fastapi/fastapi.py index aa449acde7..31adeca227 100644 --- a/faststream/nats/fastapi/fastapi.py +++ b/faststream/nats/fastapi/fastapi.py @@ -37,7 +37,6 @@ from faststream.nats.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: - import ssl from enum import Enum from fastapi import params @@ -154,22 +153,10 @@ def __init__( bool, Doc("Boolean indicating should commands be echoed."), ] = False, - tls: Annotated[ - Optional["ssl.SSLContext"], - Doc("Some SSL context to make NATS connections secure."), - ] = None, tls_hostname: Annotated[ Optional[str], Doc("Hostname for TLS."), ] = None, - user: Annotated[ - Optional[str], - Doc("Username for NATS auth."), - ] = None, - password: Annotated[ - Optional[str], - Doc("Username password for NATS auth."), - ] = None, token: Annotated[ Optional[str], Doc("Auth token for NATS auth."), @@ -521,10 +508,7 @@ def __init__( dont_randomize=dont_randomize, flusher_queue_size=flusher_queue_size, no_echo=no_echo, - tls=tls, tls_hostname=tls_hostname, - user=user, - password=password, token=token, drain_timeout=drain_timeout, signature_cb=signature_cb, diff --git a/faststream/nats/publisher/producer.py b/faststream/nats/publisher/producer.py index a90bf89ff0..35cf40028e 100644 --- a/faststream/nats/publisher/producer.py +++ b/faststream/nats/publisher/producer.py @@ -1,5 +1,5 @@ import asyncio -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Optional import anyio import nats @@ -20,7 +20,7 @@ if TYPE_CHECKING: from nats.aio.client import Client from nats.aio.msg import Msg - from nats.js import JetStreamContext + from nats.js import JetStreamContext, api from faststream._internal.types import ( AsyncCallable, @@ -64,7 +64,7 @@ async def publish( # type: ignore[override] **cmd.headers_to_publish(), } - await self.__state.connection.publish( + return await self.__state.connection.publish( subject=cmd.destination, payload=payload, reply=cmd.reply_to, @@ -127,7 +127,7 @@ def disconnect(self) -> None: async def publish( # type: ignore[override] self, cmd: "NatsPublishCommand", - ) -> Optional[Any]: + ) -> "api.PubAck": payload, content_type = encode_message(cmd.body) headers_to_send = { @@ -135,7 +135,7 @@ async def publish( # type: ignore[override] **cmd.headers_to_publish(js=True), } - await self.__state.connection.publish( + return await self.__state.connection.publish( subject=cmd.destination, payload=payload, headers=headers_to_send, @@ -143,8 +143,6 @@ async def publish( # type: ignore[override] timeout=cmd.timeout, ) - return None - @override async def request( # type: ignore[override] self, diff --git a/faststream/nats/publisher/usecase.py b/faststream/nats/publisher/usecase.py index b2ca2b3c3e..9d3ccd92dc 100644 --- a/faststream/nats/publisher/usecase.py +++ b/faststream/nats/publisher/usecase.py @@ -1,14 +1,13 @@ from collections.abc import Iterable from typing import ( TYPE_CHECKING, - Annotated, Any, Optional, Union, ) from nats.aio.msg import Msg -from typing_extensions import Doc, override +from typing_extensions import Literal, overload, override from faststream._internal.publisher.usecase import PublisherUsecase from faststream.message import gen_cor_id @@ -16,6 +15,8 @@ from faststream.response.publish_type import PublishType if TYPE_CHECKING: + from nats.js import api + from faststream._internal.basic_types import SendableMessage from faststream._internal.types import BrokerMiddleware, PublisherMiddleware from faststream.nats.message import NatsMessage @@ -63,34 +64,66 @@ def __init__( self.headers = headers or {} self.reply_to = reply_to + @overload + async def publish( + self, + message: "SendableMessage", + subject: str = "", + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + correlation_id: Optional[str] = None, + stream: Literal[None] = None, + timeout: Optional[float] = None, + ) -> None: ... + + @overload + async def publish( + self, + message: "SendableMessage", + subject: str = "", + headers: Optional[dict[str, str]] = None, + reply_to: str = "", + correlation_id: Optional[str] = None, + stream: Optional[str] = None, + timeout: Optional[float] = None, + ) -> "api.PubAck": ... + @override async def publish( self, message: "SendableMessage", subject: str = "", - *, headers: Optional[dict[str, str]] = None, reply_to: str = "", correlation_id: Optional[str] = None, stream: Optional[str] = None, timeout: Optional[float] = None, - ) -> None: + ) -> Optional["api.PubAck"]: """Publish message directly. Args: - message (SendableMessage): Message body to send. + message: + Message body to send. Can be any encodable object (native python types or `pydantic.BaseModel`). - subject (str): NATS subject to send message (default is `''`). - headers (:obj:`dict` of :obj:`str`: :obj:`str`, optional): Message headers to store metainformation (default is `None`). + subject: + NATS subject to send message. + headers: + Message headers to store metainformation. **content-type** and **correlation_id** will be set automatically by framework anyway. - - reply_to (str): NATS subject name to send response (default is `None`). - correlation_id (str, optional): Manual message **correlation_id** setter (default is `None`). + reply_to: + NATS subject name to send response. + correlation_id: + Manual message **correlation_id** setter. **correlation_id** is a useful option to trace messages. - - stream (str, optional): This option validates that the target subject is in presented stream (default is `None`). - Can be omitted without any effect. - timeout (float, optional): Timeout to send message to NATS in seconds (default is `None`). + stream: + This option validates that the target subject is in presented stream. + Can be omitted without any effect if you doesn't want PubAck frame. + timeout: + Timeout to send message to NATS. + + Returns: + `None` if you publishes a regular message. + `nats.js.api.PubAck` if you publishes a message to stream. """ cmd = NatsPublishCommand( message, @@ -127,37 +160,40 @@ async def _publish( @override async def request( self, - message: Annotated[ - "SendableMessage", - Doc( - "Message body to send. " - "Can be any encodable object (native python types or `pydantic.BaseModel`).", - ), - ], - subject: Annotated[ - str, - Doc("NATS subject to send message."), - ] = "", - *, - headers: Annotated[ - Optional[dict[str, str]], - Doc( - "Message headers to store metainformation. " - "**content-type** and **correlation_id** will be set automatically by framework anyway.", - ), - ] = None, - correlation_id: Annotated[ - Optional[str], - Doc( - "Manual message **correlation_id** setter. " - "**correlation_id** is a useful option to trace messages.", - ), - ] = None, - timeout: Annotated[ - float, - Doc("Timeout to send message to NATS."), - ] = 0.5, + message: "SendableMessage", + subject: str = "", + headers: Optional[dict[str, str]] = None, + correlation_id: Optional[str] = None, + timeout: float = 0.5, ) -> "NatsMessage": + """Make a synchronous request to outer subscriber. + + If out subscriber listens subject by stream, you should setup the same **stream** explicitly. + Another way you will reseave confirmation frame as a response. + + Note: + To setup **stream** option, please use `__init__` method. + + Args: + message: + Message body to send. + Can be any encodable object (native python types or `pydantic.BaseModel`). + subject: + NATS subject to send message. + headers: + Message headers to store metainformation. + **content-type** and **correlation_id** will be set automatically by framework anyway. + reply_to: + NATS subject name to send response. + correlation_id: + Manual message **correlation_id** setter. + **correlation_id** is a useful option to trace messages. + timeout: + Timeout to send message to NATS. + + Returns: + `faststream.nats.message.NatsMessage` object as an outer subscriber response. + """ cmd = NatsPublishCommand( message=message, subject=subject or self.subject, diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index deb852bcfd..a679353c65 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -1,6 +1,6 @@ +import warnings from collections.abc import Iterable from typing import TYPE_CHECKING, Any, Optional, Union -import warnings from nats.aio.subscription import ( DEFAULT_SUB_PENDING_BYTES_LIMIT, diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index b04c5cc6d0..7dc7cd1cc1 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -52,7 +52,10 @@ SendableMessage, ) from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto - from faststream._internal.state import BrokerState as BasicState + from faststream._internal.state import ( + BrokerState as BasicState, + Pointer, + ) from faststream._internal.types import ( AsyncCallable, BrokerMiddleware, @@ -128,7 +131,7 @@ def _setup( # type: ignore[override] broker_parser: Optional["CustomCallable"], broker_decoder: Optional["CustomCallable"], # dependant args - state: "BasicState", + state: "Pointer[BasicState]", ) -> None: self._connection_state = ConnectedSubscriberState( parent_state=connection_state, @@ -260,7 +263,7 @@ def _make_response_publisher( """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" return ( NatsFakePublisher( - producer=self._state.producer, + producer=self._state.get().producer, subject=message.reply_to, ), ) @@ -347,11 +350,12 @@ async def get_one( except TimeoutError: return None + context = self._state.get().di_state.context + msg: NatsMessage = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -539,11 +543,12 @@ async def get_one( except (TimeoutError, ConnectionClosedError): return None + context = self._state.get().di_state.context + msg: NatsMessage = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -849,13 +854,14 @@ async def get_one( except TimeoutError: return None + context = self._state.get().di_state.context + return cast( NatsMessage, await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -966,11 +972,12 @@ async def get_one( ) is None: await anyio.sleep(sleep_interval) + context = self._state.get().di_state.context + msg: NatsKvMessage = await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -1120,11 +1127,12 @@ async def get_one( ) is None: await anyio.sleep(sleep_interval) + context = self._state.get().di_state.context + msg: NatsObjMessage = await process_msg( msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -1155,6 +1163,8 @@ async def __consume_watch(self) -> None: self.subscription = UnsubscribeAdapter["ObjectStore.ObjectWatcher"](obj_watch) + context = self._state.get().di_state.context + while self.running: with suppress(TimeoutError): message = cast( @@ -1164,9 +1174,7 @@ async def __consume_watch(self) -> None: ) if message: - with self._state.di_state.context.scope( - OBJECT_STORAGE_CONTEXT_KEY, self.bucket - ): + with context.scope(OBJECT_STORAGE_CONTEXT_KEY, self.bucket): await self.consume(message) def _make_response_publisher( diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index c91fd5529b..f417d9e63e 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -57,10 +57,15 @@ def create_publisher_fake_subscriber( @contextmanager def _patch_producer(self, broker: NatsBroker) -> Iterator[None]: - old_js_producer, old_producer = broker._js_producer, broker._state.producer - broker._js_producer = broker._state.producer = FakeProducer(broker) - yield - broker._js_producer, broker._state.producer = old_js_producer, old_producer + old_js_producer, old_producer = broker._js_producer, broker._producer + fake_producer = broker._js_producer = FakeProducer(broker) + + broker._state.patch_value(producer=fake_producer) + try: + yield + finally: + broker._js_producer = old_js_producer + broker._state.patch_value(producer=old_producer) async def _fake_connect( self, diff --git a/faststream/rabbit/broker/broker.py b/faststream/rabbit/broker/broker.py index 7ba92c36c1..b0e60e62cf 100644 --- a/faststream/rabbit/broker/broker.py +++ b/faststream/rabbit/broker/broker.py @@ -18,6 +18,7 @@ from faststream.__about__ import SERVICE_NAME from faststream._internal.broker.broker import BrokerUsecase from faststream._internal.constants import EMPTY +from faststream._internal.publisher.proto import PublisherProto from faststream.message import gen_cor_id from faststream.rabbit.helpers.declarer import RabbitDeclarer from faststream.rabbit.publisher.producer import AioPikaFastProducer @@ -292,10 +293,12 @@ def __init__( self._channel = None declarer = self.declarer = RabbitDeclarer() - self._state.producer = AioPikaFastProducer( - declarer=declarer, - decoder=self._decoder, - parser=self._parser, + self._state.patch_value( + producer=AioPikaFastProducer( + declarer=declarer, + decoder=self._decoder, + parser=self._parser, + ) ) @property @@ -307,13 +310,21 @@ def _subscriber_setup_extra(self) -> "AnyDict": "declarer": self.declarer, } - @property - def _publisher_setup_extra(self) -> "AnyDict": - return { - **super()._publisher_setup_extra, - "app_id": self.app_id, - "virtual_host": self.virtual_host, - } + def setup_publisher( + self, + publisher: PublisherProto[IncomingMessage], + **kwargs: Any, + ) -> None: + return super().setup_publisher( + publisher, + **( + { + "app_id": self.app_id, + "virtual_host": self.virtual_host, + } + | kwargs + ), + ) @override async def connect( # type: ignore[override] @@ -509,8 +520,9 @@ async def start(self) -> None: await super().start() + logger_state = self._state.get().logger_state if self._max_consumers: - self._state.logger_state.log(f"Set max consumers to {self._max_consumers}") + logger_state.log(f"Set max consumers to {self._max_consumers}") @override async def publish( # type: ignore[override] diff --git a/faststream/rabbit/publisher/usecase.py b/faststream/rabbit/publisher/usecase.py index bc24c1af48..f34cf06b3c 100644 --- a/faststream/rabbit/publisher/usecase.py +++ b/faststream/rabbit/publisher/usecase.py @@ -105,7 +105,6 @@ def __init__( def _setup( # type: ignore[override] self, *, - producer: Optional["AioPikaFastProducer"], app_id: Optional[str], virtual_host: str, state: "BrokerState", @@ -116,7 +115,7 @@ def _setup( # type: ignore[override] self.virtual_host = virtual_host - super()._setup(producer=producer, state=state) + super()._setup(state=state) @property def routing(self) -> str: diff --git a/faststream/rabbit/subscriber/usecase.py b/faststream/rabbit/subscriber/usecase.py index 29e1d738cd..d659977ddf 100644 --- a/faststream/rabbit/subscriber/usecase.py +++ b/faststream/rabbit/subscriber/usecase.py @@ -187,11 +187,12 @@ async def get_one( ) is None: await anyio.sleep(sleep_interval) + context = self._state.get().di_state.context + msg: Optional[RabbitMessage] = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -204,7 +205,7 @@ def _make_response_publisher( ) -> Sequence["BasePublisherProto"]: return ( RabbitFakePublisher( - self._state.producer, + self._state.get().producer, routing_key=message.reply_to, app_id=self.app_id, ), diff --git a/faststream/rabbit/testing.py b/faststream/rabbit/testing.py index 3aa50e6dc5..97b5619184 100644 --- a/faststream/rabbit/testing.py +++ b/faststream/rabbit/testing.py @@ -58,10 +58,10 @@ def _patch_broker(self, broker: "RabbitBroker") -> Generator[None, None, None]: @contextmanager def _patch_producer(self, broker: RabbitBroker) -> Iterator[None]: - old_producer = broker._state.producer - broker._state.producer = FakeProducer(broker) + old_producer = broker._state.get().producer + broker._state.patch_value(producer=FakeProducer(broker)) yield - broker._state.producer = old_producer + broker._state.patch_value(producer=old_producer) @staticmethod async def _fake_connect(broker: "RabbitBroker", *args: Any, **kwargs: Any) -> None: diff --git a/faststream/redis/broker/broker.py b/faststream/redis/broker/broker.py index c1982d6236..8b5a0a0017 100644 --- a/faststream/redis/broker/broker.py +++ b/faststream/redis/broker/broker.py @@ -248,9 +248,11 @@ def __init__( _call_decorators=_call_decorators, ) - self._state.producer = RedisFastProducer( - parser=self._parser, - decoder=self._decoder, + self._state.patch_value( + producer=RedisFastProducer( + parser=self._parser, + decoder=self._decoder, + ) ) @override @@ -402,7 +404,7 @@ async def publish( # type: ignore[override] "Remove eldest message if maxlen exceeded.", ), ] = None, - ) -> None: + ) -> int: """Publish message directly. This method allows you to publish message in not AsyncAPI-documented way. You can use it in another frameworks @@ -421,7 +423,7 @@ async def publish( # type: ignore[override] headers=headers, _publish_type=PublishType.PUBLISH, ) - await super()._basic_publish(cmd, producer=self._producer) + return await super()._basic_publish(cmd, producer=self._producer) @override async def request( # type: ignore[override] @@ -475,7 +477,7 @@ async def publish_batch( Optional["AnyDict"], Doc("Message headers to store metainformation."), ] = None, - ) -> None: + ) -> int: """Publish multiple messages to Redis List by one request.""" cmd = RedisPublishCommand( *messages, @@ -486,7 +488,7 @@ async def publish_batch( _publish_type=PublishType.PUBLISH, ) - await self._basic_publish_batch(cmd, producer=self._producer) + return await self._basic_publish_batch(cmd, producer=self._producer) @override async def ping(self, timeout: Optional[float]) -> bool: diff --git a/faststream/redis/publisher/producer.py b/faststream/redis/publisher/producer.py index fe6c050202..77fa98f5ca 100644 --- a/faststream/redis/publisher/producer.py +++ b/faststream/redis/publisher/producer.py @@ -57,7 +57,7 @@ def disconnect(self) -> None: async def publish( # type: ignore[override] self, cmd: "RedisPublishCommand", - ) -> None: + ) -> int: msg = RawMessage.encode( message=cmd.body, reply_to=cmd.reply_to, @@ -65,7 +65,7 @@ async def publish( # type: ignore[override] correlation_id=cmd.correlation_id, ) - await self.__publish(msg, cmd) + return await self.__publish(msg, cmd) @override async def request( # type: ignore[override] @@ -111,7 +111,7 @@ async def request( # type: ignore[override] async def publish_batch( self, cmd: "RedisPublishCommand", - ) -> None: + ) -> int: batch = [ RawMessage.encode( message=msg, @@ -121,7 +121,7 @@ async def publish_batch( ) for msg in cmd.batch_bodies ] - await self._connection.client.rpush(cmd.destination, *batch) + return await self._connection.client.rpush(cmd.destination, *batch) async def __publish(self, msg: bytes, cmd: "RedisPublishCommand") -> None: if cmd.destination_type is DestinationType.Channel: diff --git a/faststream/redis/publisher/usecase.py b/faststream/redis/publisher/usecase.py index 09c1fae294..479b9ccf66 100644 --- a/faststream/redis/publisher/usecase.py +++ b/faststream/redis/publisher/usecase.py @@ -23,7 +23,7 @@ class LogicPublisher(PublisherUsecase[UnifyRedisDict]): """A class to represent a Redis publisher.""" - _producer: Optional["RedisFastProducer"] + _producer: "RedisFastProducer" def __init__( self, @@ -52,8 +52,6 @@ def __init__( self.reply_to = reply_to self.headers = headers or {} - self._producer = None - @abstractmethod def subscriber_property(self, *, name_only: bool) -> "AnyDict": raise NotImplementedError @@ -127,7 +125,7 @@ async def publish( "**correlation_id** is a useful option to trace messages.", ), ] = None, - ) -> None: + ) -> int: cmd = RedisPublishCommand( message, channel=channel or self.channel.name, @@ -136,7 +134,7 @@ async def publish( correlation_id=correlation_id or gen_cor_id(), _publish_type=PublishType.PUBLISH, ) - await self._basic_publish(cmd, _extra_middlewares=()) + return await self._basic_publish(cmd, _extra_middlewares=()) @override async def _publish( @@ -264,7 +262,7 @@ async def publish( "**correlation_id** is a useful option to trace messages.", ), ] = None, - ) -> None: + ) -> int: cmd = RedisPublishCommand( message, list=list or self.list.name, @@ -361,7 +359,7 @@ async def publish( # type: ignore[override] Optional["AnyDict"], Doc("Message headers to store metainformation."), ] = None, - ) -> None: + ) -> int: cmd = RedisPublishCommand( *messages, list=list or self.list.name, @@ -371,7 +369,7 @@ async def publish( # type: ignore[override] _publish_type=PublishType.PUBLISH, ) - await self._basic_publish_batch(cmd, _extra_middlewares=()) + return await self._basic_publish_batch(cmd, _extra_middlewares=()) @override async def _publish( # type: ignore[override] @@ -467,7 +465,7 @@ async def publish( "Remove eldest message if maxlen exceeded.", ), ] = None, - ) -> None: + ) -> Any: cmd = RedisPublishCommand( message, stream=stream or self.stream.name, diff --git a/faststream/redis/subscriber/usecase.py b/faststream/redis/subscriber/usecase.py index 9c8ca4ddd5..a424e409b2 100644 --- a/faststream/redis/subscriber/usecase.py +++ b/faststream/redis/subscriber/usecase.py @@ -126,7 +126,7 @@ def _make_response_publisher( ) -> Sequence["BasePublisherProto"]: return ( RedisFakePublisher( - self._state.producer, + self._state.get().producer, channel=message.reply_to, ), ) @@ -284,11 +284,12 @@ async def get_one( # type: ignore[override] while (raw_message := await self._get_message(self.subscription)) is None: # noqa: ASYNC110 await anyio.sleep(sleep_interval) + context = self._state.get().di_state.context + msg: Optional[RedisMessage] = await process_msg( # type: ignore[assignment] msg=raw_message, middlewares=( - m(raw_message, context=self._state.di_state.context) - for m in self._broker_middlewares + m(raw_message, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -411,11 +412,12 @@ async def get_one( # type: ignore[override] channel=self.list_sub.name, ) + context = self._state.get().di_state.context + msg: RedisListMessage = await process_msg( # type: ignore[assignment] msg=redis_incoming_msg, middlewares=( - m(redis_incoming_msg, context=self._state.di_state.context) - for m in self._broker_middlewares + m(redis_incoming_msg, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, @@ -699,11 +701,12 @@ async def get_one( # type: ignore[override] data=raw_message, ) + context = self._state.get().di_state.context + msg: RedisStreamMessage = await process_msg( # type: ignore[assignment] msg=redis_incoming_msg, middlewares=( - m(redis_incoming_msg, context=self._state.di_state.context) - for m in self._broker_middlewares + m(redis_incoming_msg, context=context) for m in self._broker_middlewares ), parser=self._parser, decoder=self._decoder, diff --git a/faststream/redis/testing.py b/faststream/redis/testing.py index e3584fbcb0..558a0fd5ae 100644 --- a/faststream/redis/testing.py +++ b/faststream/redis/testing.py @@ -75,10 +75,10 @@ def create_publisher_fake_subscriber( @contextmanager def _patch_producer(self, broker: RedisBroker) -> Iterator[None]: - old_producer = broker._state.producer - broker._state.producer = FakeProducer(broker) + old_producer = broker._state.get().producer + broker._state.patch_value(producer=FakeProducer(broker)) yield - broker._state.producer = old_producer + broker._state.patch_value(producer=old_producer) @staticmethod async def _fake_connect( # type: ignore[override] diff --git a/tests/brokers/base/testclient.py b/tests/brokers/base/testclient.py index bd342fcb1f..63a15c4343 100644 --- a/tests/brokers/base/testclient.py +++ b/tests/brokers/base/testclient.py @@ -129,19 +129,19 @@ async def test_broker_gets_patched_attrs_within_cm(self, fake_producer_cls) -> N test_broker = self.get_broker() await test_broker.start() - old_producer = test_broker._state.producer + old_producer = test_broker._producer async with self.patch_broker(test_broker) as br: assert isinstance(br.start, Mock) assert isinstance(br._connect, Mock) assert isinstance(br.close, Mock) - assert isinstance(br._state.producer, fake_producer_cls) + assert isinstance(br._producer, fake_producer_cls) assert not isinstance(br.start, Mock) assert not isinstance(br._connect, Mock) assert not isinstance(br.close, Mock) assert br._connection is not None - assert br._state.producer == old_producer + assert br._producer == old_producer async def test_broker_with_real_doesnt_get_patched(self) -> None: test_broker = self.get_broker() diff --git a/tests/brokers/nats/test_consume.py b/tests/brokers/nats/test_consume.py index d253bcb716..c7359c2ba5 100644 --- a/tests/brokers/nats/test_consume.py +++ b/tests/brokers/nats/test_consume.py @@ -4,6 +4,7 @@ import pytest from nats.aio.msg import Msg +from nats.js.api import PubAck from faststream import AckPolicy from faststream.exceptions import AckMessage @@ -32,7 +33,7 @@ def subscriber(m) -> None: async with self.patch_broker(consume_broker) as br: await br.start() - await asyncio.wait( + completed, _ = await asyncio.wait( ( asyncio.create_task(br.publish("hello", queue, stream=stream.name)), asyncio.create_task(event.wait()), @@ -40,7 +41,14 @@ def subscriber(m) -> None: timeout=3, ) + publish_with_stream_returns_ack_frame = False + for task in completed: + if isinstance(task.result(), PubAck): + publish_with_stream_returns_ack_frame = True + break + assert event.is_set() + assert publish_with_stream_returns_ack_frame async def test_consume_with_filter( self, diff --git a/tests/cli/rabbit/test_logs.py b/tests/cli/rabbit/test_logs.py index 4cef8e2a76..4ac67ae728 100644 --- a/tests/cli/rabbit/test_logs.py +++ b/tests/cli/rabbit/test_logs.py @@ -21,8 +21,9 @@ def test_set_level(level, app: FastStream) -> None: level = get_log_level(level) set_log_level(level, app) - app.broker._state._setup_logger_state() - broker_logger = app.broker._state.logger_state.logger.logger + broker_state = app.broker._state.get() + broker_state._setup_logger_state() + broker_logger = broker_state.logger_state.logger.logger assert app.logger.level is broker_logger.level is level diff --git a/tests/cli/test_publish.py b/tests/cli/test_publish.py index 5d1f57b82a..3f55bd6e82 100644 --- a/tests/cli/test_publish.py +++ b/tests/cli/test_publish.py @@ -31,7 +31,7 @@ def get_mock_app(broker_type, producer_type) -> tuple[FastStream, AsyncMock]: mock_producer.publish = AsyncMock() mock_producer._parser = AsyncMock() mock_producer._decoder = AsyncMock() - broker._state.producer = mock_producer + broker._state.patch_value(producer=mock_producer) return FastStream(broker), mock_producer From 3cca7f8dadcfb097ef7eaa783f5037e2028ebee3 Mon Sep 17 00:00:00 2001 From: "airt-release-notes-updater[bot]" <153718812+airt-release-notes-updater[bot]@users.noreply.github.com> Date: Thu, 7 Nov 2024 21:06:29 +0000 Subject: [PATCH 42/48] Update Release Notes for 0.5.29 (#1902) * Update Release Notes for 0.5.29 * chore: fix CI --------- Co-authored-by: Lancetnik <44573917+Lancetnik@users.noreply.github.com> Co-authored-by: Nikita Pastukhov --- .secrets.baseline | 4 ++-- docs/docs/en/release.md | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 2c0f438938..65fa5ef883 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -153,7 +153,7 @@ "filename": "docs/docs/en/release.md", "hashed_secret": "35675e68f4b5af7b995d9205ad0fc43842f16450", "is_verified": false, - "line_number": 1812, + "line_number": 1835, "is_secret": false } ], @@ -178,5 +178,5 @@ } ] }, - "generated_at": "2024-10-20T20:04:20Z" + "generated_at": "2024-11-07T20:55:07Z" } diff --git a/docs/docs/en/release.md b/docs/docs/en/release.md index cab058065b..7a8dc7cf4a 100644 --- a/docs/docs/en/release.md +++ b/docs/docs/en/release.md @@ -12,6 +12,29 @@ hide: --- # Release Notes +## 0.5.29 + +### What's Changed + +* feat: add explicit message source enum by [@Lancetnik](https://github.com/Lancetnik){.external-link target="_blank"} in [#1866](https://github.com/airtai/faststream/pull/1866){.external-link target="_blank"} +* Change uv manual installation to setup-uv in CI by [@pavelepanov](https://github.com/pavelepanov){.external-link target="_blank"} in [#1871](https://github.com/airtai/faststream/pull/1871){.external-link target="_blank"} +* refactor: make Task and Concurrent mixins broker-agnostic by [@Lancetnik](https://github.com/Lancetnik){.external-link target="_blank"} in [#1873](https://github.com/airtai/faststream/pull/1873){.external-link target="_blank"} +* Add support for environment variables in faststream run command by [@ulbwa](https://github.com/ulbwa){.external-link target="_blank"} in [#1876](https://github.com/airtai/faststream/pull/1876){.external-link target="_blank"} +* fastapi example update by [@xodiumx](https://github.com/xodiumx){.external-link target="_blank"} in [#1875](https://github.com/airtai/faststream/pull/1875){.external-link target="_blank"} +* Do not import `fake_context` if not needed by [@sobolevn](https://github.com/sobolevn){.external-link target="_blank"} in [#1877](https://github.com/airtai/faststream/pull/1877){.external-link target="_blank"} +* build: add warning about manual lifespan_context by [@vectorvp](https://github.com/vectorvp){.external-link target="_blank"} in [#1878](https://github.com/airtai/faststream/pull/1878){.external-link target="_blank"} +* Add trending badge by [@davorrunje](https://github.com/davorrunje){.external-link target="_blank"} in [#1882](https://github.com/airtai/faststream/pull/1882){.external-link target="_blank"} +* feat: add class method to create a baggage instance from headers by [@vectorvp](https://github.com/vectorvp){.external-link target="_blank"} in [#1885](https://github.com/airtai/faststream/pull/1885){.external-link target="_blank"} +* ops: update docker compose commands to compose V2 in scripts by [@vectorvp](https://github.com/vectorvp){.external-link target="_blank"} in [#1889](https://github.com/airtai/faststream/pull/1889){.external-link target="_blank"} + +### New Contributors +* [@pavelepanov](https://github.com/pavelepanov){.external-link target="_blank"} made their first contribution in [#1871](https://github.com/airtai/faststream/pull/1871){.external-link target="_blank"} +* [@xodiumx](https://github.com/xodiumx){.external-link target="_blank"} made their first contribution in [#1875](https://github.com/airtai/faststream/pull/1875){.external-link target="_blank"} +* [@sobolevn](https://github.com/sobolevn){.external-link target="_blank"} made their first contribution in [#1877](https://github.com/airtai/faststream/pull/1877){.external-link target="_blank"} +* [@vectorvp](https://github.com/vectorvp){.external-link target="_blank"} made their first contribution in [#1878](https://github.com/airtai/faststream/pull/1878){.external-link target="_blank"} + +**Full Changelog**: [#0.5.28...0.5.29](https://github.com/airtai/faststream/compare/0.5.28...0.5.29){.external-link target="_blank"} + ## 0.5.28 ### What's Changed From a3d5eb993759d4ee7945c5c6bdbf13c467ff983c Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Fri, 8 Nov 2024 10:06:43 +0300 Subject: [PATCH 43/48] docs: add gurubase badge to the doc (#1905) Co-authored-by: Kumaran Rajendhiran --- README.md | 10 ++++++---- docs/docs/en/faststream.md | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b499e4a62d..b0dca7da08 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,6 @@ Supported Python versions - - Gurubase - -
@@ -59,6 +55,12 @@ Telegram + +
+ + + Gurubase +

--- diff --git a/docs/docs/en/faststream.md b/docs/docs/en/faststream.md index 18ca543802..30389fd1e6 100644 --- a/docs/docs/en/faststream.md +++ b/docs/docs/en/faststream.md @@ -62,6 +62,12 @@ search: Telegram + +
+ + + Gurubase +

--- From 362f6c9a9e54f451b8525696d25a3da5984a5d73 Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Fri, 8 Nov 2024 19:18:36 +0300 Subject: [PATCH 44/48] refactor: create default middlewares in runtime --- faststream/_internal/_compat.py | 5 - faststream/_internal/broker/broker.py | 6 - faststream/_internal/publisher/proto.py | 4 - faststream/_internal/publisher/specified.py | 88 ++++++++++ faststream/_internal/publisher/usecase.py | 150 +++++------------- .../_internal/state/logger/logger_proxy.py | 55 +++---- .../_internal/state/logger/params_storage.py | 2 +- faststream/_internal/state/logger/state.py | 4 +- .../_internal/subscriber/call_wrapper/call.py | 39 +++-- faststream/_internal/subscriber/proto.py | 2 - faststream/_internal/subscriber/specified.py | 70 ++++++++ faststream/_internal/subscriber/usecase.py | 114 +++++-------- faststream/confluent/response.py | 21 ++- faststream/kafka/response.py | 21 ++- .../middlewares/acknowledgement/middleware.py | 52 +++--- faststream/middlewares/logging.py | 1 + faststream/redis/response.py | 21 ++- faststream/redis/schemas/proto.py | 4 +- faststream/redis/subscriber/usecase.py | 14 +- faststream/specification/base/proto.py | 2 +- tests/brokers/base/router.py | 27 ++-- tests/brokers/base/testclient.py | 19 +++ .../brokers/confluent/test_publish_command.py | 1 + tests/brokers/kafka/test_publish_command.py | 1 + tests/brokers/redis/test_publish_command.py | 1 + 25 files changed, 410 insertions(+), 314 deletions(-) create mode 100644 faststream/_internal/publisher/specified.py create mode 100644 faststream/_internal/subscriber/specified.py diff --git a/faststream/_internal/_compat.py b/faststream/_internal/_compat.py index fd0ab263fb..8965fc951b 100644 --- a/faststream/_internal/_compat.py +++ b/faststream/_internal/_compat.py @@ -1,5 +1,4 @@ import json -import os import sys import warnings from collections.abc import Iterable, Mapping @@ -28,10 +27,6 @@ ModelVar = TypeVar("ModelVar", bound=BaseModel) -def is_test_env() -> bool: - return bool(os.getenv("PYTEST_CURRENT_TEST")) - - json_dumps: Callable[..., bytes] orjson: Any ujson: Any diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index 591b14d903..9914040cb3 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -34,7 +34,6 @@ MsgType, ) from faststream._internal.utils.functions import to_async -from faststream.middlewares.logging import CriticalLogMiddleware from .abc_broker import ABCBroker from .pub_base import BrokerPublishMixin @@ -172,11 +171,6 @@ def __init__( self._connection_kwargs = connection_kwargs self._connection = None - self.middlewares = ( - CriticalLogMiddleware(logger_state), - *self.middlewares, - ) - # AsyncAPI information self.url = specification_url self.protocol = protocol diff --git a/faststream/_internal/publisher/proto.py b/faststream/_internal/publisher/proto.py index 710cc65944..31d66c0268 100644 --- a/faststream/_internal/publisher/proto.py +++ b/faststream/_internal/publisher/proto.py @@ -7,7 +7,6 @@ from faststream._internal.proto import Endpoint from faststream._internal.types import MsgType from faststream.response.response import PublishCommand -from faststream.specification.base.proto import EndpointProto if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage @@ -89,13 +88,10 @@ async def request( class PublisherProto( - EndpointProto, Endpoint, BasePublisherProto, Generic[MsgType], ): - schema_: Any - _broker_middlewares: Iterable["BrokerMiddleware[MsgType]"] _middlewares: Iterable["PublisherMiddleware"] _producer: Optional["ProducerProto"] diff --git a/faststream/_internal/publisher/specified.py b/faststream/_internal/publisher/specified.py new file mode 100644 index 0000000000..8ad62a1d00 --- /dev/null +++ b/faststream/_internal/publisher/specified.py @@ -0,0 +1,88 @@ +from inspect import Parameter, unwrap +from typing import TYPE_CHECKING, Any, Optional + +from fast_depends.core import build_call_model +from fast_depends.pydantic._compat import create_model, get_config_base + +from faststream._internal.publisher.proto import PublisherProto +from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper +from faststream._internal.types import ( + MsgType, + P_HandlerParams, + T_HandlerReturn, +) +from faststream.specification.asyncapi.message import get_model_schema +from faststream.specification.asyncapi.utils import to_camelcase +from faststream.specification.base.proto import SpecificationEndpoint + +if TYPE_CHECKING: + from faststream._internal.basic_types import AnyDict + + +class BaseSpicificationPublisher(SpecificationEndpoint, PublisherProto[MsgType]): + """A base class for publishers in an asynchronous API.""" + + def __init__( + self, + *, + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + self.calls = [] + + self.title_ = title_ + self.description_ = description_ + self.include_in_schema = include_in_schema + self.schema_ = schema_ + + def __call__( + self, + func: HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn], + ) -> HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]: + self.calls.append(func._original_call) + return func + + def get_payloads(self) -> list[tuple["AnyDict", str]]: + payloads: list[tuple[AnyDict, str]] = [] + + if self.schema_: + body = get_model_schema( + call=create_model( + "", + __config__=get_config_base(), + response__=(self.schema_, ...), + ), + prefix=f"{self.name}:Message", + ) + + if body: # pragma: no branch + payloads.append((body, "")) + + else: + di_state = self._state.get().di_state + + for call in self.calls: + call_model = build_call_model( + call, + dependency_provider=di_state.provider, + serializer_cls=di_state.serializer, + ) + + response_type = next( + iter(call_model.serializer.response_option.values()) + ).field_type + if response_type is not None and response_type is not Parameter.empty: + body = get_model_schema( + create_model( + "", + __config__=get_config_base(), + response__=(response_type, ...), + ), + prefix=f"{self.name}:Message", + ) + if body: + payloads.append((body, to_camelcase(unwrap(call).__name__))) + + return payloads diff --git a/faststream/_internal/publisher/usecase.py b/faststream/_internal/publisher/usecase.py index f1d1e276fb..c729a5b13d 100644 --- a/faststream/_internal/publisher/usecase.py +++ b/faststream/_internal/publisher/usecase.py @@ -1,23 +1,24 @@ from collections.abc import Awaitable, Iterable from functools import partial -from inspect import Parameter, unwrap from itertools import chain from typing import ( TYPE_CHECKING, - Annotated, Any, Callable, Optional, + Union, ) from unittest.mock import MagicMock -from fast_depends.core import build_call_model -from fast_depends.pydantic._compat import create_model, get_config_base -from typing_extensions import Doc, override +from typing_extensions import override from faststream._internal.publisher.proto import PublisherProto +from faststream._internal.state import BrokerState, EmptyBrokerState, Pointer from faststream._internal.state.producer import ProducerUnset -from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper +from faststream._internal.subscriber.call_wrapper.call import ( + HandlerCallWrapper, + ensure_call_wrapper, +) from faststream._internal.subscriber.utils import process_msg from faststream._internal.types import ( MsgType, @@ -25,15 +26,11 @@ T_HandlerReturn, ) from faststream.message.source_type import SourceType -from faststream.specification.asyncapi.message import ( - get_model_schema, -) -from faststream.specification.asyncapi.utils import to_camelcase + +from .specified import BaseSpicificationPublisher if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict from faststream._internal.publisher.proto import ProducerProto - from faststream._internal.state import BrokerState, Pointer from faststream._internal.types import ( BrokerMiddleware, PublisherMiddleware, @@ -41,58 +38,38 @@ from faststream.response.response import PublishCommand -class PublisherUsecase(PublisherProto[MsgType]): +class PublisherUsecase(BaseSpicificationPublisher, PublisherProto[MsgType]): """A base class for publishers in an asynchronous API.""" - mock: Optional[MagicMock] - calls: list[Callable[..., Any]] - def __init__( self, *, - broker_middlewares: Annotated[ - Iterable["BrokerMiddleware[MsgType]"], - Doc("Top-level middlewares to use in direct `.publish` call."), - ], - middlewares: Annotated[ - Iterable["PublisherMiddleware"], - Doc("Publisher middlewares."), - ], + broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], + middlewares: Iterable["PublisherMiddleware"], # AsyncAPI args - schema_: Annotated[ - Optional[Any], - Doc( - "AsyncAPI publishing message type" - "Should be any python-native object annotation or `pydantic.BaseModel`.", - ), - ], - title_: Annotated[ - Optional[str], - Doc("AsyncAPI object title."), - ], - description_: Annotated[ - Optional[str], - Doc("AsyncAPI object description."), - ], - include_in_schema: Annotated[ - bool, - Doc("Whetever to include operation in AsyncAPI schema or not."), - ], + schema_: Optional[Any], + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, ) -> None: - self.calls = [] self.middlewares = middlewares self._broker_middlewares = broker_middlewares self.__producer: Optional[ProducerProto] = ProducerUnset() self._fake_handler = False - self.mock = None + self.mock: Optional[MagicMock] = None - # AsyncAPI - self.title_ = title_ - self.description_ = description_ - self.include_in_schema = include_in_schema - self.schema_ = schema_ + super().__init__( + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + schema_=schema_, + ) + + self._state: Pointer[BrokerState] = Pointer( + EmptyBrokerState("You should include publisher to any broker.") + ) def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: self._broker_middlewares = (*self._broker_middlewares, middleware) @@ -108,21 +85,14 @@ def _setup( # type: ignore[override] state: "Pointer[BrokerState]", producer: Optional["ProducerProto"] = None, ) -> None: - # TODO: add EmptyBrokerState to init self._state = state self.__producer = producer def set_test( self, *, - mock: Annotated[ - MagicMock, - Doc("Mock object to check in tests."), - ], - with_fake: Annotated[ - bool, - Doc("Whetevet publisher's fake subscriber created or not."), - ], + mock: MagicMock, + with_fake: bool, ) -> None: """Turn publisher to testing mode.""" self.mock = mock @@ -135,17 +105,18 @@ def reset_test(self) -> None: def __call__( self, - func: Callable[P_HandlerParams, T_HandlerReturn], + func: Union[ + Callable[P_HandlerParams, T_HandlerReturn], + HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn], + ], ) -> HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]: """Decorate user's function by current publisher.""" - handler_call = HandlerCallWrapper[ - MsgType, - P_HandlerParams, - T_HandlerReturn, - ](func) - handler_call._publishers.append(self) - self.calls.append(handler_call._original_call) - return handler_call + handler: HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn] = ( + ensure_call_wrapper(func) + ) + handler._publishers.append(self) + super().__call__(handler) + return handler async def _basic_publish( self, @@ -221,46 +192,3 @@ async def _basic_publish_batch( pub = partial(pub_m, pub) await pub(cmd) - - def get_payloads(self) -> list[tuple["AnyDict", str]]: - payloads: list[tuple[AnyDict, str]] = [] - - if self.schema_: - body = get_model_schema( - call=create_model( - "", - __config__=get_config_base(), - response__=(self.schema_, ...), - ), - prefix=f"{self.name}:Message", - ) - - if body: # pragma: no branch - payloads.append((body, "")) - - else: - di_state = self._state.get().di_state - - for call in self.calls: - call_model = build_call_model( - call, - dependency_provider=di_state.provider, - serializer_cls=di_state.serializer, - ) - - response_type = next( - iter(call_model.serializer.response_option.values()) - ).field_type - if response_type is not None and response_type is not Parameter.empty: - body = get_model_schema( - create_model( - "", - __config__=get_config_base(), - response__=(response_type, ...), - ), - prefix=f"{self.name}:Message", - ) - if body: - payloads.append((body, to_camelcase(unwrap(call).__name__))) - - return payloads diff --git a/faststream/_internal/state/logger/logger_proxy.py b/faststream/_internal/state/logger/logger_proxy.py index a7ca07b920..690a42c6dd 100644 --- a/faststream/_internal/state/logger/logger_proxy.py +++ b/faststream/_internal/state/logger/logger_proxy.py @@ -1,24 +1,15 @@ -from typing import TYPE_CHECKING, Optional, Protocol +from collections.abc import Mapping +from typing import Any, Optional +from faststream._internal.basic_types import LoggerProto from faststream.exceptions import IncorrectState -if TYPE_CHECKING: - from faststream._internal.basic_types import AnyDict, LoggerProto - -class LoggerObject(Protocol): +class LoggerObject(LoggerProto): logger: Optional["LoggerProto"] def __bool__(self) -> bool: ... - def log( - self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, - ) -> None: ... - class NotSetLoggerObject(LoggerObject): """Default logger proxy for state. @@ -37,13 +28,15 @@ def __repr__(self) -> str: def log( self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, + level: int, + msg: Any, + /, + *, + exc_info: Any = None, + extra: Optional[Mapping[str, Any]] = None, ) -> None: - msg = "Logger object not set. Please, call `_setup_logger_state` of parent broker state." - raise IncorrectState(msg) + err_msg = "Logger object not set. Please, call `_setup_logger_state` of parent broker state." + raise IncorrectState(err_msg) class EmptyLoggerObject(LoggerObject): @@ -63,10 +56,12 @@ def __repr__(self) -> str: def log( self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, + level: int, + msg: Any, + /, + *, + exc_info: Any = None, + extra: Optional[Mapping[str, Any]] = None, ) -> None: pass @@ -89,14 +84,16 @@ def __repr__(self) -> str: def log( self, - message: str, - log_level: int, - extra: Optional["AnyDict"] = None, - exc_info: Optional[Exception] = None, + level: int, + msg: Any, + /, + *, + exc_info: Any = None, + extra: Optional[Mapping[str, Any]] = None, ) -> None: self.logger.log( - log_level, - message, + level, + msg, extra=extra, exc_info=exc_info, ) diff --git a/faststream/_internal/state/logger/params_storage.py b/faststream/_internal/state/logger/params_storage.py index c63e0b8e99..ee12344a7a 100644 --- a/faststream/_internal/state/logger/params_storage.py +++ b/faststream/_internal/state/logger/params_storage.py @@ -12,7 +12,7 @@ def make_logger_storage( logger: Optional["LoggerProto"], log_fmt: Optional[str], - default_storage_cls: type["DefaultLoggerStorage"], + default_storage_cls: type["LoggerParamsStorage"], ) -> "LoggerParamsStorage": if logger is EMPTY: return default_storage_cls(log_fmt) diff --git a/faststream/_internal/state/logger/state.py b/faststream/_internal/state/logger/state.py index 132cedf7c5..2fc29707b8 100644 --- a/faststream/_internal/state/logger/state.py +++ b/faststream/_internal/state/logger/state.py @@ -58,8 +58,8 @@ def log( exc_info: Optional[Exception] = None, ) -> None: self.logger.log( - log_level=(log_level or self.log_level), - message=message, + (log_level or self.log_level), + message, extra=extra, exc_info=exc_info, ) diff --git a/faststream/_internal/subscriber/call_wrapper/call.py b/faststream/_internal/subscriber/call_wrapper/call.py index dcb93f8a42..e7ad845024 100644 --- a/faststream/_internal/subscriber/call_wrapper/call.py +++ b/faststream/_internal/subscriber/call_wrapper/call.py @@ -32,6 +32,18 @@ from faststream.message import StreamMessage +def ensure_call_wrapper( + call: Union[ + "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]", + Callable[P_HandlerParams, T_HandlerReturn], + ], +) -> "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]": + if isinstance(call, HandlerCallWrapper): + return call + + return HandlerCallWrapper(call) + + class HandlerCallWrapper(Generic[MsgType, P_HandlerParams, T_HandlerReturn]): """A generic class to wrap handler calls.""" @@ -52,31 +64,18 @@ class HandlerCallWrapper(Generic[MsgType, P_HandlerParams, T_HandlerReturn]): "mock", ) - def __new__( - cls, - call: Union[ - "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]", - Callable[P_HandlerParams, T_HandlerReturn], - ], - ) -> "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]": - """Create a new instance of the class.""" - if isinstance(call, cls): - return call - return super().__new__(cls) - def __init__( self, call: Callable[P_HandlerParams, T_HandlerReturn], ) -> None: """Initialize a handler.""" - if not isinstance(call, HandlerCallWrapper): - self._original_call = call - self._wrapped_call = None - self._publishers = [] - - self.mock = None - self.future = None - self.is_test = False + self._original_call = call + self._wrapped_call = None + self._publishers = [] + + self.mock = None + self.future = None + self.is_test = False def __call__( self, diff --git a/faststream/_internal/subscriber/proto.py b/faststream/_internal/subscriber/proto.py index 547c04f51c..a402009407 100644 --- a/faststream/_internal/subscriber/proto.py +++ b/faststream/_internal/subscriber/proto.py @@ -7,7 +7,6 @@ from faststream._internal.proto import Endpoint from faststream._internal.subscriber.call_wrapper.proto import WrapperProto from faststream._internal.types import MsgType -from faststream.specification.base.proto import EndpointProto if TYPE_CHECKING: from fast_depends.dependencies import Dependant @@ -30,7 +29,6 @@ class SubscriberProto( - EndpointProto, Endpoint, WrapperProto[MsgType], ): diff --git a/faststream/_internal/subscriber/specified.py b/faststream/_internal/subscriber/specified.py new file mode 100644 index 0000000000..e6dec70970 --- /dev/null +++ b/faststream/_internal/subscriber/specified.py @@ -0,0 +1,70 @@ +from typing import ( + TYPE_CHECKING, + Optional, +) + +from faststream._internal.subscriber.proto import SubscriberProto +from faststream._internal.types import MsgType +from faststream.exceptions import SetupError +from faststream.specification.asyncapi.message import parse_handler_params +from faststream.specification.asyncapi.utils import to_camelcase +from faststream.specification.base.proto import SpecificationEndpoint + +if TYPE_CHECKING: + from faststream._internal.basic_types import AnyDict + + +class BaseSpicificationSubscriber(SpecificationEndpoint, SubscriberProto[MsgType]): + def __init__( + self, + *, + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + self.title_ = title_ + self.description_ = description_ + self.include_in_schema = include_in_schema + + @property + def call_name(self) -> str: + """Returns the name of the handler call.""" + if not self.calls: + return "Subscriber" + + return to_camelcase(self.calls[0].call_name) + + def get_description(self) -> Optional[str]: + """Returns the description of the handler.""" + if not self.calls: # pragma: no cover + return None + + return self.calls[0].description + + def get_payloads(self) -> list[tuple["AnyDict", str]]: + """Get the payloads of the handler.""" + payloads: list[tuple[AnyDict, str]] = [] + + for h in self.calls: + if h.dependant is None: + msg = "You should setup `Handler` at first." + raise SetupError(msg) + + body = parse_handler_params( + h.dependant, + prefix=f"{self.title_ or self.call_name}:Message", + ) + + payloads.append((body, to_camelcase(h.call_name))) + + if not self.calls: + payloads.append( + ( + { + "title": f"{self.title_ or self.call_name}:Message:Payload", + }, + to_camelcase(self.call_name), + ), + ) + + return payloads diff --git a/faststream/_internal/subscriber/usecase.py b/faststream/_internal/subscriber/usecase.py index 622578fc22..c8ee25678a 100644 --- a/faststream/_internal/subscriber/usecase.py +++ b/faststream/_internal/subscriber/usecase.py @@ -13,7 +13,10 @@ from typing_extensions import Self, override from faststream._internal.subscriber.call_item import HandlerItem -from faststream._internal.subscriber.call_wrapper.call import HandlerCallWrapper +from faststream._internal.subscriber.call_wrapper.call import ( + HandlerCallWrapper, + ensure_call_wrapper, +) from faststream._internal.subscriber.proto import SubscriberProto from faststream._internal.subscriber.utils import ( MultiLock, @@ -28,9 +31,10 @@ from faststream._internal.utils.functions import sync_fake_context, to_async from faststream.exceptions import SetupError, StopConsume, SubscriberNotFound from faststream.middlewares import AckPolicy, AcknowledgementMiddleware +from faststream.middlewares.logging import CriticalLogMiddleware from faststream.response import ensure_response -from faststream.specification.asyncapi.message import parse_handler_params -from faststream.specification.asyncapi.utils import to_camelcase + +from .specified import BaseSpicificationSubscriber if TYPE_CHECKING: from fast_depends.dependencies import Dependant @@ -75,7 +79,7 @@ def __init__( self.dependencies = dependencies -class SubscriberUsecase(SubscriberProto[MsgType]): +class SubscriberUsecase(BaseSpicificationSubscriber, SubscriberProto[MsgType]): """A class representing an asynchronous handler.""" lock: "AbstractContextManager[Any]" @@ -102,6 +106,12 @@ def __init__( include_in_schema: bool, ) -> None: """Initialize a new instance of the class.""" + super().__init__( + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) + self.calls = [] self._parser = default_parser @@ -111,6 +121,7 @@ def __init__( self._call_options = None self._call_decorators = () + self.running = False self.lock = sync_fake_context() @@ -122,20 +133,6 @@ def __init__( self.extra_context = {} self.extra_watcher_options = {} - # AsyncAPI - self.title_ = title_ - self.description_ = description_ - self.include_in_schema = include_in_schema - - if self.ack_policy is not AckPolicy.DO_NOTHING: - self._broker_middlewares = ( - AcknowledgementMiddleware( - self.ack_policy, - self.extra_watcher_options, - ), - *self._broker_middlewares, - ) - def add_middleware(self, middleware: "BrokerMiddleware[MsgType]") -> None: self._broker_middlewares = (*self._broker_middlewares, middleware) @@ -264,8 +261,8 @@ def __call__( def real_wrapper( func: Callable[P_HandlerParams, T_HandlerReturn], ) -> "HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]": - handler = HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn]( - func, + handler: HandlerCallWrapper[MsgType, P_HandlerParams, T_HandlerReturn] = ( + ensure_call_wrapper(func) ) self.calls.append( HandlerItem[MsgType]( @@ -312,22 +309,19 @@ async def process_message(self, msg: MsgType) -> "Response": """Execute all message processing stages.""" broker_state = self._state.get() context: ContextRepo = broker_state.di_state.context + logger_state = broker_state.logger_state async with AsyncExitStack() as stack: stack.enter_context(self.lock) # Enter context before middlewares - stack.enter_context( - context.scope("logger", broker_state.logger_state.logger.logger) - ) + stack.enter_context(context.scope("logger", logger_state.logger.logger)) for k, v in self.extra_context.items(): stack.enter_context(context.scope(k, v)) - stack.enter_context(context.scope("handler_", self)) - # enter all middlewares middlewares: list[BaseMiddleware] = [] - for base_m in self._broker_middlewares: + for base_m in self.__build__middlewares_stack(): middleware = base_m(msg, context=context) middlewares.append(middleware) await middleware.__aenter__() @@ -379,6 +373,7 @@ async def process_message(self, msg: MsgType) -> "Response": for m in middlewares: stack.push_async_exit(m.__aexit__) + # Reraise it to catch in tests if parsing_error: raise parsing_error @@ -388,6 +383,28 @@ async def process_message(self, msg: MsgType) -> "Response": # An error was raised and processed by some middleware return ensure_response(None) + def __build__middlewares_stack(self) -> tuple["BaseMiddleware", ...]: + logger_state = self._state.get().logger_state + + if self.ack_policy is AckPolicy.DO_NOTHING: + broker_middlewares = ( + CriticalLogMiddleware(logger_state), + *self._broker_middlewares, + ) + + else: + broker_middlewares = ( + AcknowledgementMiddleware( + logger=logger_state, + ack_policy=self.ack_policy, + extra_options=self.extra_watcher_options, + ), + CriticalLogMiddleware(logger_state), + *self._broker_middlewares, + ) + + return broker_middlewares + def __get_response_publisher( self, message: "StreamMessage[MsgType]", @@ -405,48 +422,3 @@ def get_log_context( return { "message_id": getattr(message, "message_id", ""), } - - # AsyncAPI methods - - @property - def call_name(self) -> str: - """Returns the name of the handler call.""" - if not self.calls: - return "Subscriber" - - return to_camelcase(self.calls[0].call_name) - - def get_description(self) -> Optional[str]: - """Returns the description of the handler.""" - if not self.calls: # pragma: no cover - return None - - return self.calls[0].description - - def get_payloads(self) -> list[tuple["AnyDict", str]]: - """Get the payloads of the handler.""" - payloads: list[tuple[AnyDict, str]] = [] - - for h in self.calls: - if h.dependant is None: - msg = "You should setup `Handler` at first." - raise SetupError(msg) - - body = parse_handler_params( - h.dependant, - prefix=f"{self.title_ or self.call_name}:Message", - ) - - payloads.append((body, to_camelcase(h.call_name))) - - if not self.calls: - payloads.append( - ( - { - "title": f"{self.title_ or self.call_name}:Message:Payload", - }, - to_camelcase(self.call_name), - ), - ) - - return payloads diff --git a/faststream/confluent/response.py b/faststream/confluent/response.py index 2d487484f4..3473e291bc 100644 --- a/faststream/confluent/response.py +++ b/faststream/confluent/response.py @@ -3,6 +3,7 @@ from typing_extensions import override +from faststream._internal.constants import EMPTY from faststream.response.publish_type import PublishType from faststream.response.response import PublishCommand, Response @@ -88,9 +89,9 @@ def __init__( @property def batch_bodies(self) -> tuple["SendableMessage", ...]: - if self.body: - return (self.body, *self.extra_bodies) - return self.extra_bodies + if self.body is EMPTY: + return self.extra_bodies + return (self.body, *self.extra_bodies) @classmethod def from_cmd( @@ -104,11 +105,15 @@ def from_cmd( return cmd body, extra_bodies = cmd.body, [] - if batch and isinstance(body, Sequence) and not isinstance(body, str): - if body: - body, extra_bodies = body[0], body[1:] - else: - body = None + if batch: + if body is None: + body = EMPTY + + if isinstance(body, Sequence) and not isinstance(body, str): + if body: + body, extra_bodies = body[0], body[1:] + else: + body = EMPTY return cls( body, diff --git a/faststream/kafka/response.py b/faststream/kafka/response.py index 4f1b46dc3e..13d3c186bf 100644 --- a/faststream/kafka/response.py +++ b/faststream/kafka/response.py @@ -3,6 +3,7 @@ from typing_extensions import override +from faststream._internal.constants import EMPTY from faststream.response.publish_type import PublishType from faststream.response.response import PublishCommand, Response @@ -80,9 +81,9 @@ def __init__( @property def batch_bodies(self) -> tuple["SendableMessage", ...]: - if self.body: - return (self.body, *self.extra_bodies) - return self.extra_bodies + if self.body is EMPTY: + return self.extra_bodies + return (self.body, *self.extra_bodies) @classmethod def from_cmd( @@ -96,11 +97,15 @@ def from_cmd( return cmd body, extra_bodies = cmd.body, [] - if batch and isinstance(body, Sequence) and not isinstance(body, str): - if body: - body, extra_bodies = body[0], body[1:] - else: - body = None + if batch: + if body is None: + body = EMPTY + + if isinstance(body, Sequence) and not isinstance(body, str): + if body: + body, extra_bodies = body[0], body[1:] + else: + body = EMPTY return cls( body, diff --git a/faststream/middlewares/acknowledgement/middleware.py b/faststream/middlewares/acknowledgement/middleware.py index 409bc28262..dd04e9c22e 100644 --- a/faststream/middlewares/acknowledgement/middleware.py +++ b/faststream/middlewares/acknowledgement/middleware.py @@ -15,19 +15,24 @@ from faststream._internal.basic_types import AnyDict, AsyncFuncAny from faststream._internal.context.repository import ContextRepo + from faststream._internal.state import LoggerState from faststream.message import StreamMessage class AcknowledgementMiddleware: - def __init__(self, ack_policy: AckPolicy, extra_options: "AnyDict") -> None: + def __init__( + self, logger: "LoggerState", ack_policy: "AckPolicy", extra_options: "AnyDict" + ) -> None: self.ack_policy = ack_policy self.extra_options = extra_options + self.logger = logger def __call__( self, msg: Optional[Any], context: "ContextRepo" ) -> "_AcknowledgementMiddleware": return _AcknowledgementMiddleware( msg, + logger=self.logger, ack_policy=self.ack_policy, extra_options=self.extra_options, context=context, @@ -40,14 +45,19 @@ def __init__( msg: Optional[Any], /, *, + logger: "LoggerState", context: "ContextRepo", - ack_policy: AckPolicy, extra_options: "AnyDict", + # can't be created with AckPolicy.DO_NOTHING + ack_policy: AckPolicy, ) -> None: super().__init__(msg, context=context) + self.ack_policy = ack_policy self.extra_options = extra_options - self.logger = context.get_local("logger") + self.logger = logger + + self.message: Optional[StreamMessage[Any]] = None async def consume_scope( self, @@ -63,9 +73,6 @@ async def __aexit__( exc_val: Optional[BaseException] = None, exc_tb: Optional["TracebackType"] = None, ) -> Optional[bool]: - if self.ack_policy is AckPolicy.DO_NOTHING: - return False - if not exc_type: await self.__ack() @@ -92,22 +99,25 @@ async def __aexit__( return False async def __ack(self, **exc_extra_options: Any) -> None: - try: - await self.message.ack(**exc_extra_options, **self.extra_options) - except Exception as er: - if self.logger is not None: - self.logger.log(logging.ERROR, er, exc_info=er) + if self.message: + try: + await self.message.ack(**exc_extra_options, **self.extra_options) + except Exception as er: + if self.logger is not None: + self.logger.log(er, logging.CRITICAL, exc_info=er) async def __nack(self, **exc_extra_options: Any) -> None: - try: - await self.message.nack(**exc_extra_options, **self.extra_options) - except Exception as er: - if self.logger is not None: - self.logger.log(logging.ERROR, er, exc_info=er) + if self.message: + try: + await self.message.nack(**exc_extra_options, **self.extra_options) + except Exception as er: + if self.logger is not None: + self.logger.log(er, logging.CRITICAL, exc_info=er) async def __reject(self, **exc_extra_options: Any) -> None: - try: - await self.message.reject(**exc_extra_options, **self.extra_options) - except Exception as er: - if self.logger is not None: - self.logger.log(logging.ERROR, er, exc_info=er) + if self.message: + try: + await self.message.reject(**exc_extra_options, **self.extra_options) + except Exception as er: + if self.logger is not None: + self.logger.log(er, logging.CRITICAL, exc_info=er) diff --git a/faststream/middlewares/logging.py b/faststream/middlewares/logging.py index 2fa5987a0e..fbc7412507 100644 --- a/faststream/middlewares/logging.py +++ b/faststream/middlewares/logging.py @@ -74,6 +74,7 @@ async def __aexit__( c = self.context.get_local("log_context", {}) if exc_type: + # TODO: move critical logging to `subscriber.consume()` method if issubclass(exc_type, IgnoredException): self.logger.log( log_level=logging.INFO, diff --git a/faststream/redis/response.py b/faststream/redis/response.py index 6328dbbd28..d48ee0ba1a 100644 --- a/faststream/redis/response.py +++ b/faststream/redis/response.py @@ -4,6 +4,7 @@ from typing_extensions import override +from faststream._internal.constants import EMPTY from faststream.exceptions import SetupError from faststream.redis.schemas import INCORRECT_SETUP_MSG from faststream.response.publish_type import PublishType @@ -109,9 +110,9 @@ def set_destination( @property def batch_bodies(self) -> tuple["SendableMessage", ...]: - if self.body: - return (self.body, *self.extra_bodies) - return self.extra_bodies + if self.body is EMPTY: + return self.extra_bodies + return (self.body, *self.extra_bodies) @classmethod def from_cmd( @@ -125,11 +126,15 @@ def from_cmd( return cmd body, extra_bodies = cmd.body, [] - if batch and isinstance(body, Sequence) and not isinstance(body, str): - if body: - body, extra_bodies = body[0], body[1:] - else: - body = None + if batch: + if body is None: + body = EMPTY + + if isinstance(body, Sequence) and not isinstance(body, str): + if body: + body, extra_bodies = body[0], body[1:] + else: + body = EMPTY return cls( body, diff --git a/faststream/redis/schemas/proto.py b/faststream/redis/schemas/proto.py index 2644432dcd..685d4aa679 100644 --- a/faststream/redis/schemas/proto.py +++ b/faststream/redis/schemas/proto.py @@ -2,14 +2,14 @@ from typing import TYPE_CHECKING, Any, Union from faststream.exceptions import SetupError -from faststream.specification.base.proto import EndpointProto +from faststream.specification.base.proto import SpecificationEndpoint if TYPE_CHECKING: from faststream.redis.schemas import ListSub, PubSub, StreamSub from faststream.specification.schema.bindings import redis -class RedisSpecificationProtocol(EndpointProto): +class RedisSpecificationProtocol(SpecificationEndpoint): @property @abstractmethod def channel_binding(self) -> "redis.ChannelBinding": ... diff --git a/faststream/redis/subscriber/usecase.py b/faststream/redis/subscriber/usecase.py index a424e409b2..40a3c3a4f6 100644 --- a/faststream/redis/subscriber/usecase.py +++ b/faststream/redis/subscriber/usecase.py @@ -462,19 +462,21 @@ def __init__( ) async def _get_msgs(self, client: "Redis[bytes]") -> None: - raw_msg = await client.lpop(name=self.list_sub.name) + raw_msg = await client.blpop( + self.list_sub.name, + timeout=self.list_sub.polling_interval, + ) if raw_msg: + _, msg_data = raw_msg + msg = DefaultListMessage( type="list", - data=raw_msg, + data=msg_data, channel=self.list_sub.name, ) - await self.consume(msg) # type: ignore[arg-type] - - else: - await anyio.sleep(self.list_sub.polling_interval) + await self.consume(msg) class BatchListSubscriber(_ListHandlerMixin): diff --git a/faststream/specification/base/proto.py b/faststream/specification/base/proto.py index 09d6e36366..42d118c46c 100644 --- a/faststream/specification/base/proto.py +++ b/faststream/specification/base/proto.py @@ -4,7 +4,7 @@ from faststream.specification.schema.channel import Channel -class EndpointProto(Protocol): +class SpecificationEndpoint(Protocol): """A class representing an asynchronous API operation.""" title_: Optional[str] diff --git a/tests/brokers/base/router.py b/tests/brokers/base/router.py index 92ffbb3269..7b8628172d 100644 --- a/tests/brokers/base/router.py +++ b/tests/brokers/base/router.py @@ -3,7 +3,7 @@ import pytest -from faststream import BaseMiddleware, Depends +from faststream import Depends from faststream._internal.broker.router import ( ArgsContainer, BrokerRouter, @@ -433,8 +433,8 @@ async def test_router_middlewares( ) -> None: pub_broker = self.get_broker() - router = type(router)(middlewares=(BaseMiddleware,)) - router2 = type(router)(middlewares=(BaseMiddleware,)) + router = type(router)(middlewares=(1,)) + router2 = type(router)(middlewares=(2,)) args, kwargs = self.get_subscriber_params(queue, middlewares=(3,)) @@ -447,8 +447,15 @@ def subscriber() -> None: ... sub = next(iter(pub_broker._subscribers)) publisher = next(iter(pub_broker._publishers)) - assert len((*sub._broker_middlewares, *sub.calls[0].item_middlewares)) == 5 - assert len((*publisher._broker_middlewares, *publisher.middlewares)) == 4 + + subscriber_middlewares = ( + *sub._broker_middlewares, + *sub.calls[0].item_middlewares, + ) + assert subscriber_middlewares == (1, 2, 3) + + publisher_middlewares = (*publisher._broker_middlewares, *publisher.middlewares) + assert publisher_middlewares == (1, 2, 3) async def test_router_include_with_middlewares( self, @@ -465,15 +472,17 @@ async def test_router_include_with_middlewares( @router2.publisher(queue, middlewares=(3,)) def subscriber() -> None: ... - router.include_router(router2, middlewares=(BaseMiddleware,)) - pub_broker.include_router(router, middlewares=(BaseMiddleware,)) + router.include_router(router2, middlewares=(2,)) + pub_broker.include_router(router, middlewares=(1,)) sub = next(iter(pub_broker._subscribers)) publisher = next(iter(pub_broker._publishers)) sub_middlewares = (*sub._broker_middlewares, *sub.calls[0].item_middlewares) - assert len(sub_middlewares) == 5, sub_middlewares - assert len((*publisher._broker_middlewares, *publisher.middlewares)) == 4 + assert sub_middlewares == (1, 2, 3), sub_middlewares + + publisher_middlewares = (*publisher._broker_middlewares, *publisher.middlewares) + assert publisher_middlewares == (1, 2, 3) async def test_router_parser( self, diff --git a/tests/brokers/base/testclient.py b/tests/brokers/base/testclient.py index 63a15c4343..2076561581 100644 --- a/tests/brokers/base/testclient.py +++ b/tests/brokers/base/testclient.py @@ -125,6 +125,25 @@ async def m(msg): # pragma: no cover with pytest.raises(ValueError): # noqa: PT011 await br.publish("hello", queue) + @pytest.mark.asyncio() + async def test_parser_exception_raises(self, queue: str) -> None: + test_broker = self.get_broker() + + def parser(msg): + raise ValueError + + args, kwargs = self.get_subscriber_params(queue, parser=parser) + + @test_broker.subscriber(*args, **kwargs) + async def m(msg): # pragma: no cover + pass + + async with self.patch_broker(test_broker) as br: + await br.start() + + with pytest.raises(ValueError): # noqa: PT011 + await br.publish("hello", queue) + async def test_broker_gets_patched_attrs_within_cm(self, fake_producer_cls) -> None: test_broker = self.get_broker() await test_broker.start() diff --git a/tests/brokers/confluent/test_publish_command.py b/tests/brokers/confluent/test_publish_command.py index 0f21843038..43e089afbb 100644 --- a/tests/brokers/confluent/test_publish_command.py +++ b/tests/brokers/confluent/test_publish_command.py @@ -35,6 +35,7 @@ def test_kafka_response_class(): pytest.param((), (), id="Empty Sequence"), pytest.param("123", ("123",), id="String Response"), pytest.param([1, 2, 3], (1, 2, 3), id="Sequence Data"), + pytest.param([0, 1, 2], (0, 1, 2), id="Sequence Data with False first element"), ), ) def test_batch_response(data: Any, expected_body: Any): diff --git a/tests/brokers/kafka/test_publish_command.py b/tests/brokers/kafka/test_publish_command.py index 0c2b43b781..912989aa1c 100644 --- a/tests/brokers/kafka/test_publish_command.py +++ b/tests/brokers/kafka/test_publish_command.py @@ -35,6 +35,7 @@ def test_kafka_response_class(): pytest.param((), (), id="Empty Sequence"), pytest.param("123", ("123",), id="String Response"), pytest.param([1, 2, 3], (1, 2, 3), id="Sequence Data"), + pytest.param([0, 1, 2], (0, 1, 2), id="Sequence Data with False first element"), ), ) def test_batch_response(data: Any, expected_body: Any): diff --git a/tests/brokers/redis/test_publish_command.py b/tests/brokers/redis/test_publish_command.py index 78c272e26e..6539ee0b62 100644 --- a/tests/brokers/redis/test_publish_command.py +++ b/tests/brokers/redis/test_publish_command.py @@ -35,6 +35,7 @@ def test_kafka_response_class(): pytest.param((), (), id="Empty Sequence"), pytest.param("123", ("123",), id="String Response"), pytest.param([1, 2, 3], (1, 2, 3), id="Sequence Data"), + pytest.param([0, 1, 2], (0, 1, 2), id="Sequence Data with False first element"), ), ) def test_batch_response(data: Any, expected_body: Any): From 0d4c237184aeadabf6f4b3f889dca7d3a94553ea Mon Sep 17 00:00:00 2001 From: Mark <83531949+Drakorgaur@users.noreply.github.com> Date: Fri, 8 Nov 2024 17:54:39 +0100 Subject: [PATCH 45/48] fix: allow users to pass `nkeys_seed_str` as argument for NATS broker. (#1908) * fix: allow users to pass `nkeys_seed_str` as argument for NATS broker. This would simplify the usage of applications without a need to read files. * fix: nkeys_seed_str default None --------- Co-authored-by: Pastukhov Nikita --- faststream/__about__.py | 2 +- faststream/nats/broker/broker.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/faststream/__about__.py b/faststream/__about__.py index 2aed3de26e..b817eed0eb 100644 --- a/faststream/__about__.py +++ b/faststream/__about__.py @@ -1,5 +1,5 @@ """Simple and fast framework to create message brokers based microservices.""" -__version__ = "0.5.29" +__version__ = "0.5.30" SERVICE_NAME = f"faststream-{__version__}" diff --git a/faststream/nats/broker/broker.py b/faststream/nats/broker/broker.py index ae956d50e7..3653727da2 100644 --- a/faststream/nats/broker/broker.py +++ b/faststream/nats/broker/broker.py @@ -194,6 +194,10 @@ class NatsInitKwargs(TypedDict, total=False): Doc("A user credentials file or tuple of files."), ] nkeys_seed: Annotated[ + Optional[str], + Doc("Path-like object containing nkeys seed that will be used."), + ] + nkeys_seed_str: Annotated[ Optional[str], Doc("Nkeys seed to be used."), ] @@ -350,7 +354,11 @@ def __init__( ] = None, nkeys_seed: Annotated[ Optional[str], - Doc("Nkeys seed to be used."), + Doc("Path-like object containing nkeys seed that will be used."), + ] = None, + nkeys_seed_str: Annotated[ + Optional[str], + Doc("Raw nkeys seed to be used."), ] = None, inbox_prefix: Annotated[ Union[str, bytes], @@ -509,6 +517,7 @@ def __init__( token=token, user_credentials=user_credentials, nkeys_seed=nkeys_seed, + nkeys_seed_str=nkeys_seed_str, **secure_kwargs, # callbacks error_cb=self._log_connection_broken(error_cb), From bd188c7c94c3c9c55f5142301ab02da89b453340 Mon Sep 17 00:00:00 2001 From: sheldy <85823514+sheldygg@users.noreply.github.com> Date: Mon, 11 Nov 2024 06:48:14 +0100 Subject: [PATCH 46/48] Add more warning's to nats subscription factory (#1907) * Add dynaconf example for nats * Update index * :) * Add type for new_value and uncomment * Update dynaconf.md * docs: polish markup * Proofread howto docs page * Add more warning's to nats subscription factory * docs: generate API References * Add `validate_input_for_warnings` function * docs: generate API References * refactor: merge options to config object * refactor: use correct defaults * Moreeeee warnings * Show warnings for core subscription without category * Upd warning text * chore: correct text * Pre-final variant * Add warnings for nats core subscriber * docs: generate API References * Add end-line * Add return type * refactor: polish NATS warnings * refactor: polish conditions * Proofread warning messages * Add missing full stop * Fix linting issues --------- Co-authored-by: Nikita Pastukhov Co-authored-by: Kumaran Rajendhiran Co-authored-by: sheldygg Co-authored-by: Pastukhov Nikita --- faststream/nats/broker/registrator.py | 4 +- faststream/nats/fastapi/fastapi.py | 4 +- faststream/nats/router.py | 4 +- faststream/nats/subscriber/factory.py | 237 +++++++++++++++++++++++--- 4 files changed, 219 insertions(+), 30 deletions(-) diff --git a/faststream/nats/broker/registrator.py b/faststream/nats/broker/registrator.py index fb7aaf8e7f..8be4f57803 100644 --- a/faststream/nats/broker/registrator.py +++ b/faststream/nats/broker/registrator.py @@ -95,9 +95,9 @@ def subscriber( # type: ignore[override] Doc("Enable Heartbeats for a consumer to detect failures."), ] = None, flow_control: Annotated[ - bool, + Optional[bool], Doc("Enable Flow Control for a consumer."), - ] = False, + ] = None, deliver_policy: Annotated[ Optional["api.DeliverPolicy"], Doc("Deliver Policy to be used for subscription."), diff --git a/faststream/nats/fastapi/fastapi.py b/faststream/nats/fastapi/fastapi.py index 263465543e..4a871426ec 100644 --- a/faststream/nats/fastapi/fastapi.py +++ b/faststream/nats/fastapi/fastapi.py @@ -630,9 +630,9 @@ def subscriber( # type: ignore[override] Doc("Enable Heartbeats for a consumer to detect failures."), ] = None, flow_control: Annotated[ - bool, + Optional[bool], Doc("Enable Flow Control for a consumer."), - ] = False, + ] = None, deliver_policy: Annotated[ Optional["api.DeliverPolicy"], Doc("Deliver Policy to be used for subscription."), diff --git a/faststream/nats/router.py b/faststream/nats/router.py index ace895ba59..ed838f133a 100644 --- a/faststream/nats/router.py +++ b/faststream/nats/router.py @@ -184,9 +184,9 @@ def __init__( Doc("Enable Heartbeats for a consumer to detect failures."), ] = None, flow_control: Annotated[ - bool, + Optional[bool], Doc("Enable Flow Control for a consumer."), - ] = False, + ] = None, deliver_policy: Annotated[ Optional["api.DeliverPolicy"], Doc("Deliver Policy to be used for subscription."), diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index c17556fe85..5adf4af55b 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -5,7 +5,7 @@ DEFAULT_SUB_PENDING_BYTES_LIMIT, DEFAULT_SUB_PENDING_MSGS_LIMIT, ) -from nats.js.api import ConsumerConfig +from nats.js.api import ConsumerConfig, DeliverPolicy from nats.js.client import ( DEFAULT_JS_SUB_PENDING_BYTES_LIMIT, DEFAULT_JS_SUB_PENDING_MSGS_LIMIT, @@ -46,7 +46,7 @@ def create_subscriber( config: Optional["api.ConsumerConfig"], ordered_consumer: bool, idle_heartbeat: Optional[float], - flow_control: bool, + flow_control: Optional[bool], deliver_policy: Optional["api.DeliverPolicy"], headers_only: Optional[bool], # pull args @@ -79,18 +79,39 @@ def create_subscriber( "AsyncAPIKeyValueWatchSubscriber", "AsyncAPIObjStoreWatchSubscriber", ]: - if pull_sub is not None and stream is None: - raise SetupError("Pull subscriber can be used only with a stream") - - if not subject and not config: - raise SetupError("You must provide either `subject` or `config` option.") + _validate_input_for_misconfigure( + subject=subject, + queue=queue, + pending_msgs_limit=pending_msgs_limit, + pending_bytes_limit=pending_bytes_limit, + max_msgs=max_msgs, + durable=durable, + config=config, + ordered_consumer=ordered_consumer, + idle_heartbeat=idle_heartbeat, + flow_control=flow_control, + deliver_policy=deliver_policy, + headers_only=headers_only, + pull_sub=pull_sub, + kv_watch=kv_watch, + obj_watch=obj_watch, + ack_first=ack_first, + max_workers=max_workers, + stream=stream, + ) config = config or ConsumerConfig(filter_subjects=[]) + if config.durable_name is None: + config.durable_name = durable + if config.idle_heartbeat is None: + config.idle_heartbeat = idle_heartbeat + if config.headers_only is None: + config.headers_only = headers_only + if config.deliver_policy is DeliverPolicy.ALL: + config.deliver_policy = deliver_policy or DeliverPolicy.ALL if stream: - # TODO: pull & queue warning - # TODO: push & durable warning - + # Both JS Subscribers extra_options: AnyDict = { "pending_msgs_limit": pending_msgs_limit or DEFAULT_JS_SUB_PENDING_MSGS_LIMIT, @@ -101,9 +122,11 @@ def create_subscriber( } if pull_sub is not None: + # JS Pull Subscriber extra_options.update({"inbox_prefix": inbox_prefix}) else: + # JS Push Subscriber extra_options.update( { "ordered_consumer": ordered_consumer, @@ -116,6 +139,7 @@ def create_subscriber( ) else: + # Core Subscriber extra_options = { "pending_msgs_limit": pending_msgs_limit or DEFAULT_SUB_PENDING_MSGS_LIMIT, "pending_bytes_limit": pending_bytes_limit @@ -124,13 +148,6 @@ def create_subscriber( } if obj_watch is not None: - if max_workers > 1: - warnings.warn( - "`max_workers` has no effect for ObjectValue subscriber.", - RuntimeWarning, - stacklevel=3, - ) - return AsyncAPIObjStoreWatchSubscriber( subject=subject, config=config, @@ -143,13 +160,6 @@ def create_subscriber( ) if kv_watch is not None: - if max_workers > 1: - warnings.warn( - "`max_workers` has no effect for KeyValue subscriber.", - RuntimeWarning, - stacklevel=3, - ) - return AsyncAPIKeyValueWatchSubscriber( subject=subject, config=config, @@ -306,3 +316,182 @@ def create_subscriber( description_=description_, include_in_schema=include_in_schema, ) + + +def _validate_input_for_misconfigure( + subject: str, + queue: str, # default "" + pending_msgs_limit: Optional[int], + pending_bytes_limit: Optional[int], + max_msgs: int, # default 0 + durable: Optional[str], + config: Optional["api.ConsumerConfig"], + ordered_consumer: bool, # default False + idle_heartbeat: Optional[float], + flow_control: Optional[bool], + deliver_policy: Optional["api.DeliverPolicy"], + headers_only: Optional[bool], + pull_sub: Optional["PullSub"], + kv_watch: Optional["KvWatch"], + obj_watch: Optional["ObjWatch"], + ack_first: bool, # default False + max_workers: int, # default 1 + stream: Optional["JStream"], +) -> None: + if not subject and not config: + raise SetupError("You must provide either the `subject` or `config` option.") + + if stream and kv_watch: + raise SetupError( + "You can't use both the `stream` and `kv_watch` options simultaneously." + ) + + if stream and obj_watch: + raise SetupError( + "You can't use both the `stream` and `obj_watch` options simultaneously." + ) + + if kv_watch and obj_watch: + raise SetupError( + "You can't use both the `kv_watch` and `obj_watch` options simultaneously." + ) + + if pull_sub and not stream: + raise SetupError( + "The pull subscriber can only be used with the `stream` option." + ) + + if max_msgs > 0 and any((stream, kv_watch, obj_watch)): + warnings.warn( + "The `max_msgs` option can be used only with a NATS Core Subscriber.", + RuntimeWarning, + stacklevel=4, + ) + + if not stream: + if obj_watch or kv_watch: + # Obj/Kv Subscriber + if pending_msgs_limit is not None: + warnings.warn( + message="The `pending_msgs_limit` option can be used only with JetStream (Pull/Push) or Core Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if pending_bytes_limit is not None: + warnings.warn( + message="The `pending_bytes_limit` option can be used only with JetStream (Pull/Push) or Core Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if queue: + warnings.warn( + message="The `queue` option can be used only with JetStream Push or Core Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if max_workers > 1: + warnings.warn( + message="The `max_workers` option can be used only with JetStream (Pull/Push) or Core Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + # Core/Obj/Kv Subscriber + if durable: + warnings.warn( + message="The `durable` option can be used only with JetStream (Pull/Push) Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if config is not None: + warnings.warn( + message="The `config` option can be used only with JetStream (Pull/Push) Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if ordered_consumer: + warnings.warn( + message="The `ordered_consumer` option can be used only with JetStream (Pull/Push) Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if idle_heartbeat is not None: + warnings.warn( + message="The `idle_heartbeat` option can be used only with JetStream (Pull/Push) Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if flow_control: + warnings.warn( + message="The `flow_control` option can be used only with JetStream Push Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if deliver_policy: + warnings.warn( + message="The `deliver_policy` option can be used only with JetStream (Pull/Push) Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if headers_only: + warnings.warn( + message="The `headers_only` option can be used only with JetStream (Pull/Push) Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if ack_first: + warnings.warn( + message="The `ack_first` option can be used only with JetStream Push Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + else: + # JetStream Subscribers + if pull_sub: + if queue: + warnings.warn( + message="The `queue` option has no effect with JetStream Pull Subscription. You probably wanted to use the `durable` option instead.", + category=RuntimeWarning, + stacklevel=4, + ) + + if ordered_consumer: + warnings.warn( + "The `ordered_consumer` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", + RuntimeWarning, + stacklevel=4, + ) + + if ack_first: + warnings.warn( + message="The `ack_first` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + if flow_control: + warnings.warn( + message="The `flow_control` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) + + else: + # JS PushSub + if durable is not None: + warnings.warn( + message="The JetStream Push consumer with the `durable` option can't be scaled horizontally across multiple instances. You probably wanted to use the `queue` option instead. Also, we strongly recommend using the Jetstream PullSubsriber with the `durable` option as the default.", + category=RuntimeWarning, + stacklevel=4, + ) From 69f19854afc6538bb11a4ac6e1daebdc936357ac Mon Sep 17 00:00:00 2001 From: Vladislav Tumko <56307628+vectorvp@users.noreply.github.com> Date: Wed, 13 Nov 2024 00:04:55 +0400 Subject: [PATCH 47/48] refactor: add ack handling for brokers (#1897) * refactor: add ack handling for brokers * fix: warnings arguments and is_manual flag * refactor: complete NATS * docs: generate API References * fix: update ack_policy * refactor: new tests * refactor: move ack_policy reject outside of return functions * tests: fix NATS tests * refactor: validate warnings * chore: remove loguru usage * chore: remove deprecated type option * chore: correct warnings stacklevel --------- Co-authored-by: vectorvp Co-authored-by: Pastukhov Nikita --- .secrets.baseline | 2 +- docs/docs/SUMMARY.md | 36 +++-- .../publisher/state/EmptyProducerState.md} | 2 +- .../publisher/state/ProducerState.md} | 2 +- .../publisher/state/RealProducer.md} | 2 +- .../publisher/state/EmptyProducerState.md} | 2 +- .../kafka/publisher/state/ProducerState.md | 11 ++ .../kafka/publisher/state/RealProducer.md | 11 ++ .../SpecificationPushStreamSubscriber.md} | 2 +- .../rabbit/helpers/state/ConnectedState.md | 11 ++ .../rabbit/helpers/state/ConnectionState.md | 11 ++ .../helpers/state/EmptyConnectionState.md | 11 ++ .../rabbit/publisher/producer/LockState.md | 11 ++ .../rabbit/publisher/producer/LockUnset.md | 11 ++ .../rabbit/publisher/producer/RealLock.md | 11 ++ .../redis/helpers/state/ConnectedState.md | 11 ++ .../redis/helpers/state/ConnectionState.md | 11 ++ .../helpers/state/EmptyConnectionState.md | 11 ++ .../SpecificationChannelSubscriber.md} | 2 +- .../SpecificationListBatchSubscriber.md | 11 ++ ...iber.md => SpecificationListSubscriber.md} | 2 +- .../SpecificationStreamBatchSubscriber.md | 11 ++ ...er.md => SpecificationStreamSubscriber.md} | 2 +- .../usecase/StreamBatchSubscriber.md | 11 ++ .../base/proto/SpecificationEndpoint.md | 11 ++ faststream/_internal/broker/pub_base.py | 1 - faststream/_internal/cli/main.py | 3 - faststream/confluent/broker/registrator.py | 9 +- faststream/confluent/fastapi/fastapi.py | 6 +- faststream/confluent/router.py | 3 +- faststream/confluent/subscriber/factory.py | 14 +- faststream/kafka/broker/registrator.py | 9 +- faststream/kafka/fastapi/fastapi.py | 8 +- faststream/kafka/router.py | 3 +- faststream/kafka/subscriber/factory.py | 14 +- faststream/nats/broker/registrator.py | 3 +- faststream/nats/fastapi/fastapi.py | 2 +- faststream/nats/parser.py | 7 +- faststream/nats/publisher/producer.py | 5 +- faststream/nats/response.py | 13 ++ faststream/nats/router.py | 3 +- faststream/nats/subscriber/factory.py | 130 ++++++++++-------- faststream/nats/subscriber/specified.py | 2 +- faststream/nats/subscriber/usecase.py | 7 +- faststream/nats/testing.py | 11 +- faststream/rabbit/broker/registrator.py | 3 +- faststream/rabbit/fastapi/fastapi.py | 2 +- faststream/rabbit/router.py | 3 +- faststream/rabbit/subscriber/factory.py | 6 +- faststream/rabbit/subscriber/usecase.py | 7 +- faststream/redis/broker/registrator.py | 3 +- faststream/redis/fastapi/fastapi.py | 2 +- faststream/redis/router.py | 3 +- faststream/redis/subscriber/factory.py | 74 +++++++--- faststream/redis/subscriber/specified.py | 12 +- faststream/redis/subscriber/usecase.py | 13 +- tests/brokers/base/consume.py | 11 +- tests/brokers/base/fastapi.py | 29 ++-- tests/brokers/base/middlewares.py | 40 ++++-- tests/brokers/base/parser.py | 18 ++- tests/brokers/base/publish.py | 30 ++-- tests/brokers/base/requests.py | 4 +- tests/brokers/base/router.py | 42 ++++-- tests/brokers/confluent/test_consume.py | 21 ++- tests/brokers/confluent/test_fastapi.py | 6 +- tests/brokers/confluent/test_publish.py | 3 +- tests/brokers/confluent/test_test_client.py | 3 +- tests/brokers/kafka/test_consume.py | 24 ++-- tests/brokers/kafka/test_fastapi.py | 6 +- tests/brokers/kafka/test_publish.py | 3 +- tests/brokers/kafka/test_test_client.py | 3 +- tests/brokers/nats/test_consume.py | 68 ++++----- tests/brokers/nats/test_fastapi.py | 9 +- tests/brokers/nats/test_publish.py | 4 +- tests/brokers/nats/test_router.py | 3 +- tests/brokers/nats/test_test_client.py | 3 +- tests/brokers/rabbit/test_consume.py | 33 +++-- tests/brokers/rabbit/test_fastapi.py | 3 +- tests/brokers/rabbit/test_publish.py | 7 +- tests/brokers/rabbit/test_router.py | 9 +- tests/brokers/rabbit/test_test_client.py | 3 +- tests/brokers/redis/test_consume.py | 43 ++++-- tests/brokers/redis/test_fastapi.py | 18 ++- tests/brokers/redis/test_publish.py | 13 +- tests/brokers/redis/test_router.py | 9 +- tests/brokers/redis/test_test_client.py | 3 +- tests/opentelemetry/basic.py | 30 ++-- .../opentelemetry/confluent/test_confluent.py | 6 +- tests/opentelemetry/kafka/test_kafka.py | 6 +- tests/opentelemetry/nats/test_nats.py | 3 +- tests/opentelemetry/redis/test_redis.py | 6 +- tests/prometheus/basic.py | 14 +- tests/prometheus/confluent/test_confluent.py | 3 +- tests/prometheus/kafka/test_kafka.py | 3 +- tests/prometheus/nats/test_nats.py | 3 +- tests/prometheus/redis/test_redis.py | 3 +- tests/utils/context/test_path.py | 3 +- 97 files changed, 800 insertions(+), 351 deletions(-) rename docs/docs/en/api/faststream/{redis/subscriber/usecase/BatchStreamSubscriber.md => confluent/publisher/state/EmptyProducerState.md} (63%) rename docs/docs/en/api/faststream/{redis/subscriber/specified/AsyncAPIListSubscriber.md => confluent/publisher/state/ProducerState.md} (62%) rename docs/docs/en/api/faststream/{specification/base/proto/EndpointProto.md => confluent/publisher/state/RealProducer.md} (67%) rename docs/docs/en/api/faststream/{redis/subscriber/specified/AsyncAPIStreamSubscriber.md => kafka/publisher/state/EmptyProducerState.md} (62%) create mode 100644 docs/docs/en/api/faststream/kafka/publisher/state/ProducerState.md create mode 100644 docs/docs/en/api/faststream/kafka/publisher/state/RealProducer.md rename docs/docs/en/api/faststream/{redis/subscriber/specified/AsyncAPIChannelSubscriber.md => nats/subscriber/specified/SpecificationPushStreamSubscriber.md} (59%) create mode 100644 docs/docs/en/api/faststream/rabbit/helpers/state/ConnectedState.md create mode 100644 docs/docs/en/api/faststream/rabbit/helpers/state/ConnectionState.md create mode 100644 docs/docs/en/api/faststream/rabbit/helpers/state/EmptyConnectionState.md create mode 100644 docs/docs/en/api/faststream/rabbit/publisher/producer/LockState.md create mode 100644 docs/docs/en/api/faststream/rabbit/publisher/producer/LockUnset.md create mode 100644 docs/docs/en/api/faststream/rabbit/publisher/producer/RealLock.md create mode 100644 docs/docs/en/api/faststream/redis/helpers/state/ConnectedState.md create mode 100644 docs/docs/en/api/faststream/redis/helpers/state/ConnectionState.md create mode 100644 docs/docs/en/api/faststream/redis/helpers/state/EmptyConnectionState.md rename docs/docs/en/api/faststream/{nats/subscriber/specified/SpecificationStreamSubscriber.md => redis/subscriber/specified/SpecificationChannelSubscriber.md} (60%) create mode 100644 docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationListBatchSubscriber.md rename docs/docs/en/api/faststream/redis/subscriber/specified/{AsyncAPIListBatchSubscriber.md => SpecificationListSubscriber.md} (64%) create mode 100644 docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationStreamBatchSubscriber.md rename docs/docs/en/api/faststream/redis/subscriber/specified/{AsyncAPIStreamBatchSubscriber.md => SpecificationStreamSubscriber.md} (64%) create mode 100644 docs/docs/en/api/faststream/redis/subscriber/usecase/StreamBatchSubscriber.md create mode 100644 docs/docs/en/api/faststream/specification/base/proto/SpecificationEndpoint.md diff --git a/.secrets.baseline b/.secrets.baseline index 65fa5ef883..c2189a1f8f 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -178,5 +178,5 @@ } ] }, - "generated_at": "2024-11-07T20:55:07Z" + "generated_at": "2024-11-08T12:39:15Z" } diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index 5c5b4db5b4..d8cc349c03 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -316,6 +316,10 @@ search: - [SpecificationBatchPublisher](api/faststream/confluent/publisher/specified/SpecificationBatchPublisher.md) - [SpecificationDefaultPublisher](api/faststream/confluent/publisher/specified/SpecificationDefaultPublisher.md) - [SpecificationPublisher](api/faststream/confluent/publisher/specified/SpecificationPublisher.md) + - state + - [EmptyProducerState](api/faststream/confluent/publisher/state/EmptyProducerState.md) + - [ProducerState](api/faststream/confluent/publisher/state/ProducerState.md) + - [RealProducer](api/faststream/confluent/publisher/state/RealProducer.md) - usecase - [BatchPublisher](api/faststream/confluent/publisher/usecase/BatchPublisher.md) - [DefaultPublisher](api/faststream/confluent/publisher/usecase/DefaultPublisher.md) @@ -426,6 +430,10 @@ search: - [SpecificationBatchPublisher](api/faststream/kafka/publisher/specified/SpecificationBatchPublisher.md) - [SpecificationDefaultPublisher](api/faststream/kafka/publisher/specified/SpecificationDefaultPublisher.md) - [SpecificationPublisher](api/faststream/kafka/publisher/specified/SpecificationPublisher.md) + - state + - [EmptyProducerState](api/faststream/kafka/publisher/state/EmptyProducerState.md) + - [ProducerState](api/faststream/kafka/publisher/state/ProducerState.md) + - [RealProducer](api/faststream/kafka/publisher/state/RealProducer.md) - usecase - [BatchPublisher](api/faststream/kafka/publisher/usecase/BatchPublisher.md) - [DefaultPublisher](api/faststream/kafka/publisher/usecase/DefaultPublisher.md) @@ -628,7 +636,7 @@ search: - [SpecificationKeyValueWatchSubscriber](api/faststream/nats/subscriber/specified/SpecificationKeyValueWatchSubscriber.md) - [SpecificationObjStoreWatchSubscriber](api/faststream/nats/subscriber/specified/SpecificationObjStoreWatchSubscriber.md) - [SpecificationPullStreamSubscriber](api/faststream/nats/subscriber/specified/SpecificationPullStreamSubscriber.md) - - [SpecificationStreamSubscriber](api/faststream/nats/subscriber/specified/SpecificationStreamSubscriber.md) + - [SpecificationPushStreamSubscriber](api/faststream/nats/subscriber/specified/SpecificationPushStreamSubscriber.md) - [SpecificationSubscriber](api/faststream/nats/subscriber/specified/SpecificationSubscriber.md) - state - [ConnectedSubscriberState](api/faststream/nats/subscriber/state/ConnectedSubscriberState.md) @@ -716,6 +724,10 @@ search: - helpers - declarer - [RabbitDeclarer](api/faststream/rabbit/helpers/declarer/RabbitDeclarer.md) + - state + - [ConnectedState](api/faststream/rabbit/helpers/state/ConnectedState.md) + - [ConnectionState](api/faststream/rabbit/helpers/state/ConnectionState.md) + - [EmptyConnectionState](api/faststream/rabbit/helpers/state/EmptyConnectionState.md) - message - [RabbitMessage](api/faststream/rabbit/message/RabbitMessage.md) - opentelemetry @@ -742,6 +754,9 @@ search: - [PublishOptions](api/faststream/rabbit/publisher/options/PublishOptions.md) - producer - [AioPikaFastProducer](api/faststream/rabbit/publisher/producer/AioPikaFastProducer.md) + - [LockState](api/faststream/rabbit/publisher/producer/LockState.md) + - [LockUnset](api/faststream/rabbit/publisher/producer/LockUnset.md) + - [RealLock](api/faststream/rabbit/publisher/producer/RealLock.md) - specified - [SpecificationPublisher](api/faststream/rabbit/publisher/specified/SpecificationPublisher.md) - usecase @@ -809,6 +824,11 @@ search: - [RedisRouter](api/faststream/redis/fastapi/RedisRouter.md) - fastapi - [RedisRouter](api/faststream/redis/fastapi/fastapi/RedisRouter.md) + - helpers + - state + - [ConnectedState](api/faststream/redis/helpers/state/ConnectedState.md) + - [ConnectionState](api/faststream/redis/helpers/state/ConnectionState.md) + - [EmptyConnectionState](api/faststream/redis/helpers/state/EmptyConnectionState.md) - message - [BatchListMessage](api/faststream/redis/message/BatchListMessage.md) - [BatchStreamMessage](api/faststream/redis/message/BatchStreamMessage.md) @@ -893,18 +913,18 @@ search: - factory - [create_subscriber](api/faststream/redis/subscriber/factory/create_subscriber.md) - specified - - [AsyncAPIChannelSubscriber](api/faststream/redis/subscriber/specified/AsyncAPIChannelSubscriber.md) - - [AsyncAPIListBatchSubscriber](api/faststream/redis/subscriber/specified/AsyncAPIListBatchSubscriber.md) - - [AsyncAPIListSubscriber](api/faststream/redis/subscriber/specified/AsyncAPIListSubscriber.md) - - [AsyncAPIStreamBatchSubscriber](api/faststream/redis/subscriber/specified/AsyncAPIStreamBatchSubscriber.md) - - [AsyncAPIStreamSubscriber](api/faststream/redis/subscriber/specified/AsyncAPIStreamSubscriber.md) + - [SpecificationChannelSubscriber](api/faststream/redis/subscriber/specified/SpecificationChannelSubscriber.md) + - [SpecificationListBatchSubscriber](api/faststream/redis/subscriber/specified/SpecificationListBatchSubscriber.md) + - [SpecificationListSubscriber](api/faststream/redis/subscriber/specified/SpecificationListSubscriber.md) + - [SpecificationStreamBatchSubscriber](api/faststream/redis/subscriber/specified/SpecificationStreamBatchSubscriber.md) + - [SpecificationStreamSubscriber](api/faststream/redis/subscriber/specified/SpecificationStreamSubscriber.md) - [SpecificationSubscriber](api/faststream/redis/subscriber/specified/SpecificationSubscriber.md) - usecase - [BatchListSubscriber](api/faststream/redis/subscriber/usecase/BatchListSubscriber.md) - - [BatchStreamSubscriber](api/faststream/redis/subscriber/usecase/BatchStreamSubscriber.md) - [ChannelSubscriber](api/faststream/redis/subscriber/usecase/ChannelSubscriber.md) - [ListSubscriber](api/faststream/redis/subscriber/usecase/ListSubscriber.md) - [LogicSubscriber](api/faststream/redis/subscriber/usecase/LogicSubscriber.md) + - [StreamBatchSubscriber](api/faststream/redis/subscriber/usecase/StreamBatchSubscriber.md) - [StreamSubscriber](api/faststream/redis/subscriber/usecase/StreamSubscriber.md) - testing - [ChannelVisitor](api/faststream/redis/testing/ChannelVisitor.md) @@ -1156,7 +1176,7 @@ search: - info - [BaseInfo](api/faststream/specification/base/info/BaseInfo.md) - proto - - [EndpointProto](api/faststream/specification/base/proto/EndpointProto.md) + - [SpecificationEndpoint](api/faststream/specification/base/proto/SpecificationEndpoint.md) - schema - [BaseSchema](api/faststream/specification/base/schema/BaseSchema.md) - specification diff --git a/docs/docs/en/api/faststream/redis/subscriber/usecase/BatchStreamSubscriber.md b/docs/docs/en/api/faststream/confluent/publisher/state/EmptyProducerState.md similarity index 63% rename from docs/docs/en/api/faststream/redis/subscriber/usecase/BatchStreamSubscriber.md rename to docs/docs/en/api/faststream/confluent/publisher/state/EmptyProducerState.md index 0f8e4f2e1b..a72476a6d3 100644 --- a/docs/docs/en/api/faststream/redis/subscriber/usecase/BatchStreamSubscriber.md +++ b/docs/docs/en/api/faststream/confluent/publisher/state/EmptyProducerState.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.redis.subscriber.usecase.BatchStreamSubscriber +::: faststream.confluent.publisher.state.EmptyProducerState diff --git a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIListSubscriber.md b/docs/docs/en/api/faststream/confluent/publisher/state/ProducerState.md similarity index 62% rename from docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIListSubscriber.md rename to docs/docs/en/api/faststream/confluent/publisher/state/ProducerState.md index 3f22c7ce0a..5a5a35dddd 100644 --- a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIListSubscriber.md +++ b/docs/docs/en/api/faststream/confluent/publisher/state/ProducerState.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.redis.subscriber.specified.AsyncAPIListSubscriber +::: faststream.confluent.publisher.state.ProducerState diff --git a/docs/docs/en/api/faststream/specification/base/proto/EndpointProto.md b/docs/docs/en/api/faststream/confluent/publisher/state/RealProducer.md similarity index 67% rename from docs/docs/en/api/faststream/specification/base/proto/EndpointProto.md rename to docs/docs/en/api/faststream/confluent/publisher/state/RealProducer.md index 81046fdcfd..52143d1596 100644 --- a/docs/docs/en/api/faststream/specification/base/proto/EndpointProto.md +++ b/docs/docs/en/api/faststream/confluent/publisher/state/RealProducer.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.specification.base.proto.EndpointProto +::: faststream.confluent.publisher.state.RealProducer diff --git a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIStreamSubscriber.md b/docs/docs/en/api/faststream/kafka/publisher/state/EmptyProducerState.md similarity index 62% rename from docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIStreamSubscriber.md rename to docs/docs/en/api/faststream/kafka/publisher/state/EmptyProducerState.md index 7b1af12b55..0152ee7c2f 100644 --- a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIStreamSubscriber.md +++ b/docs/docs/en/api/faststream/kafka/publisher/state/EmptyProducerState.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.redis.subscriber.specified.AsyncAPIStreamSubscriber +::: faststream.kafka.publisher.state.EmptyProducerState diff --git a/docs/docs/en/api/faststream/kafka/publisher/state/ProducerState.md b/docs/docs/en/api/faststream/kafka/publisher/state/ProducerState.md new file mode 100644 index 0000000000..c937179471 --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/publisher/state/ProducerState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.publisher.state.ProducerState diff --git a/docs/docs/en/api/faststream/kafka/publisher/state/RealProducer.md b/docs/docs/en/api/faststream/kafka/publisher/state/RealProducer.md new file mode 100644 index 0000000000..a576226b3c --- /dev/null +++ b/docs/docs/en/api/faststream/kafka/publisher/state/RealProducer.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.kafka.publisher.state.RealProducer diff --git a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIChannelSubscriber.md b/docs/docs/en/api/faststream/nats/subscriber/specified/SpecificationPushStreamSubscriber.md similarity index 59% rename from docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIChannelSubscriber.md rename to docs/docs/en/api/faststream/nats/subscriber/specified/SpecificationPushStreamSubscriber.md index 32d5362469..ef20892652 100644 --- a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIChannelSubscriber.md +++ b/docs/docs/en/api/faststream/nats/subscriber/specified/SpecificationPushStreamSubscriber.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.redis.subscriber.specified.AsyncAPIChannelSubscriber +::: faststream.nats.subscriber.specified.SpecificationPushStreamSubscriber diff --git a/docs/docs/en/api/faststream/rabbit/helpers/state/ConnectedState.md b/docs/docs/en/api/faststream/rabbit/helpers/state/ConnectedState.md new file mode 100644 index 0000000000..db97303aa3 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/helpers/state/ConnectedState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.helpers.state.ConnectedState diff --git a/docs/docs/en/api/faststream/rabbit/helpers/state/ConnectionState.md b/docs/docs/en/api/faststream/rabbit/helpers/state/ConnectionState.md new file mode 100644 index 0000000000..36b3d4d4d1 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/helpers/state/ConnectionState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.helpers.state.ConnectionState diff --git a/docs/docs/en/api/faststream/rabbit/helpers/state/EmptyConnectionState.md b/docs/docs/en/api/faststream/rabbit/helpers/state/EmptyConnectionState.md new file mode 100644 index 0000000000..7b0af42897 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/helpers/state/EmptyConnectionState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.helpers.state.EmptyConnectionState diff --git a/docs/docs/en/api/faststream/rabbit/publisher/producer/LockState.md b/docs/docs/en/api/faststream/rabbit/publisher/producer/LockState.md new file mode 100644 index 0000000000..4d7b37ba46 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/publisher/producer/LockState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.publisher.producer.LockState diff --git a/docs/docs/en/api/faststream/rabbit/publisher/producer/LockUnset.md b/docs/docs/en/api/faststream/rabbit/publisher/producer/LockUnset.md new file mode 100644 index 0000000000..95df1a10e7 --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/publisher/producer/LockUnset.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.publisher.producer.LockUnset diff --git a/docs/docs/en/api/faststream/rabbit/publisher/producer/RealLock.md b/docs/docs/en/api/faststream/rabbit/publisher/producer/RealLock.md new file mode 100644 index 0000000000..570a279a0a --- /dev/null +++ b/docs/docs/en/api/faststream/rabbit/publisher/producer/RealLock.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.rabbit.publisher.producer.RealLock diff --git a/docs/docs/en/api/faststream/redis/helpers/state/ConnectedState.md b/docs/docs/en/api/faststream/redis/helpers/state/ConnectedState.md new file mode 100644 index 0000000000..793fdb055e --- /dev/null +++ b/docs/docs/en/api/faststream/redis/helpers/state/ConnectedState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.helpers.state.ConnectedState diff --git a/docs/docs/en/api/faststream/redis/helpers/state/ConnectionState.md b/docs/docs/en/api/faststream/redis/helpers/state/ConnectionState.md new file mode 100644 index 0000000000..0a27d849dc --- /dev/null +++ b/docs/docs/en/api/faststream/redis/helpers/state/ConnectionState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.helpers.state.ConnectionState diff --git a/docs/docs/en/api/faststream/redis/helpers/state/EmptyConnectionState.md b/docs/docs/en/api/faststream/redis/helpers/state/EmptyConnectionState.md new file mode 100644 index 0000000000..70273722e0 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/helpers/state/EmptyConnectionState.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.helpers.state.EmptyConnectionState diff --git a/docs/docs/en/api/faststream/nats/subscriber/specified/SpecificationStreamSubscriber.md b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationChannelSubscriber.md similarity index 60% rename from docs/docs/en/api/faststream/nats/subscriber/specified/SpecificationStreamSubscriber.md rename to docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationChannelSubscriber.md index 4b9dba3b61..538babd05f 100644 --- a/docs/docs/en/api/faststream/nats/subscriber/specified/SpecificationStreamSubscriber.md +++ b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationChannelSubscriber.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.nats.subscriber.specified.SpecificationStreamSubscriber +::: faststream.redis.subscriber.specified.SpecificationChannelSubscriber diff --git a/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationListBatchSubscriber.md b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationListBatchSubscriber.md new file mode 100644 index 0000000000..60e7fa385d --- /dev/null +++ b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationListBatchSubscriber.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.subscriber.specified.SpecificationListBatchSubscriber diff --git a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIListBatchSubscriber.md b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationListSubscriber.md similarity index 64% rename from docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIListBatchSubscriber.md rename to docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationListSubscriber.md index 5cd13a9eeb..988ffccb3c 100644 --- a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIListBatchSubscriber.md +++ b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationListSubscriber.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.redis.subscriber.specified.AsyncAPIListBatchSubscriber +::: faststream.redis.subscriber.specified.SpecificationListSubscriber diff --git a/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationStreamBatchSubscriber.md b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationStreamBatchSubscriber.md new file mode 100644 index 0000000000..76a6aff457 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationStreamBatchSubscriber.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.subscriber.specified.SpecificationStreamBatchSubscriber diff --git a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIStreamBatchSubscriber.md b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationStreamSubscriber.md similarity index 64% rename from docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIStreamBatchSubscriber.md rename to docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationStreamSubscriber.md index f4857d8585..f1bfe8a520 100644 --- a/docs/docs/en/api/faststream/redis/subscriber/specified/AsyncAPIStreamBatchSubscriber.md +++ b/docs/docs/en/api/faststream/redis/subscriber/specified/SpecificationStreamSubscriber.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: faststream.redis.subscriber.specified.AsyncAPIStreamBatchSubscriber +::: faststream.redis.subscriber.specified.SpecificationStreamSubscriber diff --git a/docs/docs/en/api/faststream/redis/subscriber/usecase/StreamBatchSubscriber.md b/docs/docs/en/api/faststream/redis/subscriber/usecase/StreamBatchSubscriber.md new file mode 100644 index 0000000000..3500cc21e2 --- /dev/null +++ b/docs/docs/en/api/faststream/redis/subscriber/usecase/StreamBatchSubscriber.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.redis.subscriber.usecase.StreamBatchSubscriber diff --git a/docs/docs/en/api/faststream/specification/base/proto/SpecificationEndpoint.md b/docs/docs/en/api/faststream/specification/base/proto/SpecificationEndpoint.md new file mode 100644 index 0000000000..a6a2658fc4 --- /dev/null +++ b/docs/docs/en/api/faststream/specification/base/proto/SpecificationEndpoint.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: faststream.specification.base.proto.SpecificationEndpoint diff --git a/faststream/_internal/broker/pub_base.py b/faststream/_internal/broker/pub_base.py index 8d97050cc8..31cfb476fd 100644 --- a/faststream/_internal/broker/pub_base.py +++ b/faststream/_internal/broker/pub_base.py @@ -42,7 +42,6 @@ async def _basic_publish( return await publish(cmd) - @abstractmethod async def publish_batch( self, *messages: "SendableMessage", diff --git a/faststream/_internal/cli/main.py b/faststream/_internal/cli/main.py index 732db2c2e6..48b113c8c3 100644 --- a/faststream/_internal/cli/main.py +++ b/faststream/_internal/cli/main.py @@ -104,7 +104,6 @@ def run( False, "-f", "--factory", - is_flag=True, help="Treat APP as an application factory.", ), ) -> None: @@ -240,13 +239,11 @@ def publish( ), rpc: bool = typer.Option( False, - is_flag=True, help="Enable RPC mode and system output.", ), is_factory: bool = typer.Option( False, "--factory", - is_flag=True, help="Treat APP as an application factory.", ), ) -> None: diff --git a/faststream/confluent/broker/registrator.py b/faststream/confluent/broker/registrator.py index bf49f8b68a..3bcc604719 100644 --- a/faststream/confluent/broker/registrator.py +++ b/faststream/confluent/broker/registrator.py @@ -13,6 +13,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream._internal.constants import EMPTY from faststream.confluent.publisher.factory import create_publisher from faststream.confluent.subscriber.factory import create_subscriber from faststream.exceptions import SetupError @@ -298,7 +299,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -565,7 +566,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -832,7 +833,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -1102,7 +1103,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/confluent/fastapi/fastapi.py b/faststream/confluent/fastapi/fastapi.py index ac24669769..5bdc96cf6c 100644 --- a/faststream/confluent/fastapi/fastapi.py +++ b/faststream/confluent/fastapi/fastapi.py @@ -837,7 +837,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -1607,7 +1607,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -2000,7 +2000,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/confluent/router.py b/faststream/confluent/router.py index c4d36fd7e0..a1039fc72f 100644 --- a/faststream/confluent/router.py +++ b/faststream/confluent/router.py @@ -16,6 +16,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream._internal.constants import EMPTY from faststream.confluent.broker.registrator import KafkaRegistrator from faststream.middlewares import AckPolicy @@ -384,7 +385,7 @@ def __init__( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/confluent/subscriber/factory.py b/faststream/confluent/subscriber/factory.py index 744a47f744..ed6ae75690 100644 --- a/faststream/confluent/subscriber/factory.py +++ b/faststream/confluent/subscriber/factory.py @@ -1,3 +1,4 @@ +import warnings from collections.abc import Iterable, Sequence from typing import ( TYPE_CHECKING, @@ -7,10 +8,12 @@ overload, ) +from faststream._internal.constants import EMPTY from faststream.confluent.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from confluent_kafka import Message as ConfluentMsg @@ -19,7 +22,6 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware from faststream.confluent.schemas import TopicPartition - from faststream.middlewares import AckPolicy @overload @@ -121,6 +123,16 @@ def create_subscriber( "SpecificationDefaultSubscriber", "SpecificationBatchSubscriber", ]: + if ack_policy is not EMPTY and not is_manual: + warnings.warn( + "You can't use acknowledgement policy with `is_manual=False` subscriber", + RuntimeWarning, + stacklevel=3, + ) + + if ack_policy is EMPTY: + ack_policy = AckPolicy.REJECT_ON_ERROR + if batch: return SpecificationBatchSubscriber( *topics, diff --git a/faststream/kafka/broker/registrator.py b/faststream/kafka/broker/registrator.py index bd2c4bd735..3523c2bed7 100644 --- a/faststream/kafka/broker/registrator.py +++ b/faststream/kafka/broker/registrator.py @@ -16,6 +16,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream._internal.constants import EMPTY from faststream.kafka.publisher.factory import create_publisher from faststream.kafka.subscriber.factory import create_subscriber from faststream.middlewares import AckPolicy @@ -400,7 +401,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -766,7 +767,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -1132,7 +1133,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -1501,7 +1502,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/kafka/fastapi/fastapi.py b/faststream/kafka/fastapi/fastapi.py index 3cd73426db..5601c05f6c 100644 --- a/faststream/kafka/fastapi/fastapi.py +++ b/faststream/kafka/fastapi/fastapi.py @@ -948,7 +948,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -1434,7 +1434,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -1920,7 +1920,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( @@ -2409,7 +2409,7 @@ def subscriber( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/kafka/router.py b/faststream/kafka/router.py index 416d23df20..b4fecbe3e0 100644 --- a/faststream/kafka/router.py +++ b/faststream/kafka/router.py @@ -17,6 +17,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream._internal.constants import EMPTY from faststream.kafka.broker.registrator import KafkaRegistrator from faststream.middlewares import AckPolicy @@ -487,7 +488,7 @@ def __init__( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/kafka/subscriber/factory.py b/faststream/kafka/subscriber/factory.py index 7542a11cd6..d623fa4f7a 100644 --- a/faststream/kafka/subscriber/factory.py +++ b/faststream/kafka/subscriber/factory.py @@ -1,3 +1,4 @@ +import warnings from collections.abc import Iterable from typing import ( TYPE_CHECKING, @@ -7,11 +8,13 @@ overload, ) +from faststream._internal.constants import EMPTY from faststream.exceptions import SetupError from faststream.kafka.subscriber.specified import ( SpecificationBatchSubscriber, SpecificationDefaultSubscriber, ) +from faststream.middlewares import AckPolicy if TYPE_CHECKING: from aiokafka import ConsumerRecord, TopicPartition @@ -20,7 +23,6 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware - from faststream.middlewares import AckPolicy @overload @@ -130,6 +132,13 @@ def create_subscriber( "SpecificationDefaultSubscriber", "SpecificationBatchSubscriber", ]: + if ack_policy is not EMPTY and not is_manual: + warnings.warn( + "You can't use acknowledgement policy with `is_manual=False` subscriber", + RuntimeWarning, + stacklevel=3, + ) + if is_manual and not group_id: msg = "You must use `group_id` with manual commit mode." raise SetupError(msg) @@ -149,6 +158,9 @@ def create_subscriber( msg = "You can't provide both `partitions` and `pattern`." raise SetupError(msg) + if ack_policy is EMPTY: + ack_policy = AckPolicy.REJECT_ON_ERROR + if batch: return SpecificationBatchSubscriber( *topics, diff --git a/faststream/nats/broker/registrator.py b/faststream/nats/broker/registrator.py index 1ee9c27fb2..365c114dcb 100644 --- a/faststream/nats/broker/registrator.py +++ b/faststream/nats/broker/registrator.py @@ -5,6 +5,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream._internal.constants import EMPTY from faststream.middlewares import AckPolicy from faststream.nats.helpers import StreamBuilder from faststream.nats.publisher.factory import create_publisher @@ -164,7 +165,7 @@ def subscriber( # type: ignore[override] ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/nats/fastapi/fastapi.py b/faststream/nats/fastapi/fastapi.py index f48d729df6..a62318c82b 100644 --- a/faststream/nats/fastapi/fastapi.py +++ b/faststream/nats/fastapi/fastapi.py @@ -680,7 +680,7 @@ def subscriber( # type: ignore[override] ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/nats/parser.py b/faststream/nats/parser.py index 3cb2c695a8..d5ddcfe316 100644 --- a/faststream/nats/parser.py +++ b/faststream/nats/parser.py @@ -4,7 +4,6 @@ StreamMessage, decode_message, ) -from faststream.middlewares import AckPolicy from faststream.nats.message import ( NatsBatchMessage, NatsKvMessage, @@ -55,9 +54,8 @@ async def decode_message( class NatsParser(NatsBaseParser): """A class to parse NATS core messages.""" - def __init__(self, *, pattern: str, ack_policy: AckPolicy) -> None: + def __init__(self, *, pattern: str) -> None: super().__init__(pattern=pattern) - self.ack_policy = ack_policy async def parse_message( self, @@ -70,8 +68,7 @@ async def parse_message( headers = message.header or {} - if self.ack_policy is not AckPolicy.DO_NOTHING: - message._ackd = True # prevent message from acking + message._ackd = True # prevent Core message from acknowledgement return NatsMessage( raw_message=message, diff --git a/faststream/nats/publisher/producer.py b/faststream/nats/publisher/producer.py index 35cf40028e..aba0f78349 100644 --- a/faststream/nats/publisher/producer.py +++ b/faststream/nats/publisher/producer.py @@ -5,7 +5,6 @@ import nats from typing_extensions import override -from faststream import AckPolicy from faststream._internal.publisher.proto import ProducerProto from faststream._internal.subscriber.utils import resolve_custom_func from faststream.exceptions import FeatureNotSupportedException @@ -40,7 +39,7 @@ def __init__( parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - default = NatsParser(pattern="", ack_policy=AckPolicy.REJECT_ON_ERROR) + default = NatsParser(pattern="") self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) @@ -111,7 +110,7 @@ def __init__( parser: Optional["CustomCallable"], decoder: Optional["CustomCallable"], ) -> None: - default = NatsParser(pattern="", ack_policy=AckPolicy.REJECT_ON_ERROR) + default = NatsParser(pattern="") # core parser to serializer responses self._parser = resolve_custom_func(parser, default.parse_message) self._decoder = resolve_custom_func(decoder, default.decode_message) diff --git a/faststream/nats/response.py b/faststream/nats/response.py index 40289436f5..f8121bf883 100644 --- a/faststream/nats/response.py +++ b/faststream/nats/response.py @@ -91,3 +91,16 @@ def from_cmd( reply_to=cmd.reply_to, _publish_type=cmd.publish_type, ) + + def __repr__(self) -> str: + body = [f"body='{self.body}'", f"subject='{self.destination}'"] + if self.stream: + body.append(f"stream={self.stream}") + if self.reply_to: + body.append(f"reply_to='{self.reply_to}'") + body.extend(( + f"headers={self.headers}", + f"correlation_id='{self.correlation_id}'", + f"publish_type={self.publish_type}", + )) + return f"{self.__class__.__name__}({', '.join(body)})" diff --git a/faststream/nats/router.py b/faststream/nats/router.py index 5f0b338462..be895eb8af 100644 --- a/faststream/nats/router.py +++ b/faststream/nats/router.py @@ -16,6 +16,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream._internal.constants import EMPTY from faststream.middlewares import AckPolicy from faststream.nats.broker.registrator import NatsRegistrator @@ -254,7 +255,7 @@ def __init__( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index 4844ff1d94..35062e4eed 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -12,7 +12,9 @@ DEFAULT_JS_SUB_PENDING_MSGS_LIMIT, ) +from faststream._internal.constants import EMPTY from faststream.exceptions import SetupError +from faststream.middlewares import AckPolicy from faststream.nats.subscriber.specified import ( SpecificationBatchPullStreamSubscriber, SpecificationConcurrentCoreSubscriber, @@ -22,7 +24,7 @@ SpecificationKeyValueWatchSubscriber, SpecificationObjStoreWatchSubscriber, SpecificationPullStreamSubscriber, - SpecificationStreamSubscriber, + SpecificationPushStreamSubscriber, ) if TYPE_CHECKING: @@ -31,7 +33,6 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware - from faststream.middlewares import AckPolicy from faststream.nats.schemas import JStream, KvWatch, ObjWatch, PullSub @@ -72,7 +73,7 @@ def create_subscriber( ) -> Union[ "SpecificationCoreSubscriber", "SpecificationConcurrentCoreSubscriber", - "SpecificationStreamSubscriber", + "SpecificationPushStreamSubscriber", "SpecificationConcurrentPushStreamSubscriber", "SpecificationPullStreamSubscriber", "SpecificationConcurrentPullStreamSubscriber", @@ -94,6 +95,7 @@ def create_subscriber( deliver_policy=deliver_policy, headers_only=headers_only, pull_sub=pull_sub, + ack_policy=ack_policy, kv_watch=kv_watch, obj_watch=obj_watch, ack_first=ack_first, @@ -101,6 +103,9 @@ def create_subscriber( stream=stream, ) + if ack_policy is EMPTY: + ack_policy = AckPolicy.REJECT_ON_ERROR + config = config or ConsumerConfig(filter_subjects=[]) if config.durable_name is None: config.durable_name = durable @@ -182,7 +187,6 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - ack_policy=ack_policy, no_reply=no_reply, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, @@ -199,7 +203,6 @@ def create_subscriber( # basic args extra_options=extra_options, # Subscriber args - ack_policy=ack_policy, no_reply=no_reply, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, @@ -287,8 +290,7 @@ def create_subscriber( include_in_schema=include_in_schema, ) - - return SpecificationStreamSubscriber( + return SpecificationPushStreamSubscriber( stream=stream, subject=subject, queue=queue, @@ -307,7 +309,7 @@ def create_subscriber( ) -def _validate_input_for_misconfigure( +def _validate_input_for_misconfigure( # noqa: PLR0915 subject: str, queue: str, # default "" pending_msgs_limit: Optional[int], @@ -323,32 +325,54 @@ def _validate_input_for_misconfigure( pull_sub: Optional["PullSub"], kv_watch: Optional["KvWatch"], obj_watch: Optional["ObjWatch"], + ack_policy: "AckPolicy", # default EMPTY ack_first: bool, # default False max_workers: int, # default 1 stream: Optional["JStream"], ) -> None: if not subject and not config: - raise SetupError("You must provide either the `subject` or `config` option.") + msg = "You must provide either the `subject` or `config` option." + raise SetupError(msg) if stream and kv_watch: - raise SetupError( - "You can't use both the `stream` and `kv_watch` options simultaneously." - ) + msg = "You can't use both the `stream` and `kv_watch` options simultaneously." + raise SetupError(msg) if stream and obj_watch: - raise SetupError( - "You can't use both the `stream` and `obj_watch` options simultaneously." - ) + msg = "You can't use both the `stream` and `obj_watch` options simultaneously." + raise SetupError(msg) if kv_watch and obj_watch: - raise SetupError( + msg = ( "You can't use both the `kv_watch` and `obj_watch` options simultaneously." ) + raise SetupError(msg) if pull_sub and not stream: - raise SetupError( - "The pull subscriber can only be used with the `stream` option." - ) + msg = "The pull subscriber can only be used with the `stream` option." + raise SetupError(msg) + + if ack_policy is not EMPTY: + if obj_watch is not None: + warnings.warn( + "You can't use acknowledgement policy with ObjectStorage watch subscriber.", + RuntimeWarning, + stacklevel=4, + ) + + elif kv_watch is not None: + warnings.warn( + "You can't use acknowledgement policy with KeyValue watch subscriber.", + RuntimeWarning, + stacklevel=4, + ) + + elif stream is None: + warnings.warn( + "You can't use acknowledgement policy with core subscriber. Use JetStream instead.", + RuntimeWarning, + stacklevel=4, + ) if max_msgs > 0 and any((stream, kv_watch, obj_watch)): warnings.warn( @@ -445,42 +469,40 @@ def _validate_input_for_misconfigure( stacklevel=4, ) - else: - # JetStream Subscribers - if pull_sub: - if queue: - warnings.warn( - message="The `queue` option has no effect with JetStream Pull Subscription. You probably wanted to use the `durable` option instead.", - category=RuntimeWarning, - stacklevel=4, - ) + # JetStream Subscribers + elif pull_sub: + if queue: + warnings.warn( + message="The `queue` option has no effect with JetStream Pull Subscription. You probably wanted to use the `durable` option instead.", + category=RuntimeWarning, + stacklevel=4, + ) - if ordered_consumer: - warnings.warn( - "The `ordered_consumer` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", - RuntimeWarning, - stacklevel=4, - ) + if ordered_consumer: + warnings.warn( + "The `ordered_consumer` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", + RuntimeWarning, + stacklevel=4, + ) - if ack_first: - warnings.warn( - message="The `ack_first` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", - category=RuntimeWarning, - stacklevel=4, - ) + if ack_first: + warnings.warn( + message="The `ack_first` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) - if flow_control: - warnings.warn( - message="The `flow_control` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", - category=RuntimeWarning, - stacklevel=4, - ) + if flow_control: + warnings.warn( + message="The `flow_control` option has no effect with JetStream Pull Subscription. It can only be used with JetStream Push Subscription.", + category=RuntimeWarning, + stacklevel=4, + ) - else: - # JS PushSub - if durable is not None: - warnings.warn( - message="The JetStream Push consumer with the `durable` option can't be scaled horizontally across multiple instances. You probably wanted to use the `queue` option instead. Also, we strongly recommend using the Jetstream PullSubsriber with the `durable` option as the default.", - category=RuntimeWarning, - stacklevel=4, - ) + # JS PushSub + elif durable is not None: + warnings.warn( + message="The JetStream Push consumer with the `durable` option can't be scaled horizontally across multiple instances. You probably wanted to use the `queue` option instead. Also, we strongly recommend using the Jetstream PullSubsriber with the `durable` option as the default.", + category=RuntimeWarning, + stacklevel=4, + ) diff --git a/faststream/nats/subscriber/specified.py b/faststream/nats/subscriber/specified.py index ee3bf32bdb..8862f47ab3 100644 --- a/faststream/nats/subscriber/specified.py +++ b/faststream/nats/subscriber/specified.py @@ -66,7 +66,7 @@ class SpecificationConcurrentCoreSubscriber( """One-message core concurrent consumer with Specification methods.""" -class SpecificationStreamSubscriber( +class SpecificationPushStreamSubscriber( SpecificationSubscriber, PushStreamSubscription, ): diff --git a/faststream/nats/subscriber/usecase.py b/faststream/nats/subscriber/usecase.py index 7dc7cd1cc1..e2c6b207e1 100644 --- a/faststream/nats/subscriber/usecase.py +++ b/faststream/nats/subscriber/usecase.py @@ -295,7 +295,6 @@ def __init__( queue: str, extra_options: Optional["AnyDict"], # Subscriber args - ack_policy: "AckPolicy", no_reply: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], @@ -304,7 +303,7 @@ def __init__( description_: Optional[str], include_in_schema: bool, ) -> None: - parser_ = NatsParser(pattern=subject, ack_policy=ack_policy) + parser_ = NatsParser(pattern=subject) self.queue = queue @@ -316,7 +315,7 @@ def __init__( default_parser=parser_.parse_message, default_decoder=parser_.decode_message, # Propagated args - ack_policy=ack_policy, + ack_policy=AckPolicy.DO_NOTHING, no_reply=no_reply, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, @@ -404,7 +403,6 @@ def __init__( queue: str, extra_options: Optional["AnyDict"], # Subscriber args - ack_policy: "AckPolicy", no_reply: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[Msg]"], @@ -421,7 +419,6 @@ def __init__( queue=queue, extra_options=extra_options, # Propagated args - ack_policy=ack_policy, no_reply=no_reply, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index f417d9e63e..b3c57d7541 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -12,7 +12,6 @@ from nats.aio.msg import Msg from typing_extensions import override -from faststream import AckPolicy from faststream._internal.subscriber.utils import resolve_custom_func from faststream._internal.testing.broker import TestBroker from faststream.exceptions import SubscriberNotFound @@ -73,15 +72,21 @@ async def _fake_connect( *args: Any, **kwargs: Any, ) -> AsyncMock: - broker._connection_state = ConnectedState(AsyncMock(), AsyncMock()) + if not broker._connection_state: + broker._connection_state = ConnectedState(AsyncMock(), AsyncMock()) return AsyncMock() + def _fake_start(self, broker: NatsBroker, *args: Any, **kwargs: Any) -> None: + if not broker._connection_state: + broker._connection_state = ConnectedState(AsyncMock(), AsyncMock()) + return super()._fake_start(broker, *args, **kwargs) + class FakeProducer(NatsFastProducer): def __init__(self, broker: NatsBroker) -> None: self.broker = broker - default = NatsParser(pattern="", ack_policy=AckPolicy.REJECT_ON_ERROR) + default = NatsParser(pattern="") self._parser = resolve_custom_func(broker._parser, default.parse_message) self._decoder = resolve_custom_func(broker._decoder, default.decode_message) diff --git a/faststream/rabbit/broker/registrator.py b/faststream/rabbit/broker/registrator.py index e3e861124c..37ca0066f7 100644 --- a/faststream/rabbit/broker/registrator.py +++ b/faststream/rabbit/broker/registrator.py @@ -4,6 +4,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream._internal.constants import EMPTY from faststream.middlewares import AckPolicy from faststream.rabbit.publisher.factory import create_publisher from faststream.rabbit.publisher.specified import SpecificationPublisher @@ -61,7 +62,7 @@ def subscriber( # type: ignore[override] ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, # broker arguments dependencies: Annotated[ Iterable["Dependant"], diff --git a/faststream/rabbit/fastapi/fastapi.py b/faststream/rabbit/fastapi/fastapi.py index 6411dbc949..6e32718447 100644 --- a/faststream/rabbit/fastapi/fastapi.py +++ b/faststream/rabbit/fastapi/fastapi.py @@ -515,7 +515,7 @@ def subscriber( # type: ignore[override] ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/rabbit/router.py b/faststream/rabbit/router.py index 3edbf447b7..8eaec725ef 100644 --- a/faststream/rabbit/router.py +++ b/faststream/rabbit/router.py @@ -8,6 +8,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream._internal.constants import EMPTY from faststream.middlewares import AckPolicy from faststream.rabbit.broker.registrator import RabbitRegistrator @@ -233,7 +234,7 @@ def __init__( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/rabbit/subscriber/factory.py b/faststream/rabbit/subscriber/factory.py index 4554f9c9c5..823844aeae 100644 --- a/faststream/rabbit/subscriber/factory.py +++ b/faststream/rabbit/subscriber/factory.py @@ -1,6 +1,8 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, Optional +from faststream._internal.constants import EMPTY +from faststream.middlewares import AckPolicy from faststream.rabbit.subscriber.specified import SpecificationSubscriber if TYPE_CHECKING: @@ -9,7 +11,6 @@ from faststream._internal.basic_types import AnyDict from faststream._internal.types import BrokerMiddleware - from faststream.middlewares import AckPolicy from faststream.rabbit.schemas import RabbitExchange, RabbitQueue @@ -28,6 +29,9 @@ def create_subscriber( description_: Optional[str], include_in_schema: bool, ) -> SpecificationSubscriber: + if ack_policy is EMPTY: + ack_policy = AckPolicy.REJECT_ON_ERROR + return SpecificationSubscriber( queue=queue, exchange=exchange, diff --git a/faststream/rabbit/subscriber/usecase.py b/faststream/rabbit/subscriber/usecase.py index d659977ddf..e91333ed25 100644 --- a/faststream/rabbit/subscriber/usecase.py +++ b/faststream/rabbit/subscriber/usecase.py @@ -1,3 +1,5 @@ +import asyncio +import contextlib from collections.abc import Iterable, Sequence from typing import ( TYPE_CHECKING, @@ -177,7 +179,10 @@ async def get_one( sleep_interval = timeout / 10 raw_message: Optional[IncomingMessage] = None - with anyio.move_on_after(timeout): + with ( + contextlib.suppress(asyncio.exceptions.CancelledError), + anyio.move_on_after(timeout), + ): while ( # noqa: ASYNC110 raw_message := await self._queue_obj.get( fail=False, diff --git a/faststream/redis/broker/registrator.py b/faststream/redis/broker/registrator.py index 598105ef9b..10cf4afe98 100644 --- a/faststream/redis/broker/registrator.py +++ b/faststream/redis/broker/registrator.py @@ -4,6 +4,7 @@ from typing_extensions import Doc, override from faststream._internal.broker.abc_broker import ABCBroker +from faststream._internal.constants import EMPTY from faststream.middlewares import AckPolicy from faststream.redis.message import UnifyRedisDict from faststream.redis.publisher.factory import create_publisher @@ -69,7 +70,7 @@ def subscriber( # type: ignore[override] ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/redis/fastapi/fastapi.py b/faststream/redis/fastapi/fastapi.py index 01a5432f5e..c64968aac8 100644 --- a/faststream/redis/fastapi/fastapi.py +++ b/faststream/redis/fastapi/fastapi.py @@ -465,7 +465,7 @@ def subscriber( # type: ignore[override] ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/redis/router.py b/faststream/redis/router.py index 32d937dcce..fb155829a0 100644 --- a/faststream/redis/router.py +++ b/faststream/redis/router.py @@ -8,6 +8,7 @@ BrokerRouter, SubscriberRoute, ) +from faststream._internal.constants import EMPTY from faststream.middlewares import AckPolicy from faststream.redis.broker.registrator import RedisRegistrator from faststream.redis.message import BaseMessage @@ -151,7 +152,7 @@ def __init__( ack_policy: Annotated[ AckPolicy, Doc("Whether to disable **FastStream** auto acknowledgement logic or not."), - ] = AckPolicy.REJECT_ON_ERROR, + ] = EMPTY, no_reply: Annotated[ bool, Doc( diff --git a/faststream/redis/subscriber/factory.py b/faststream/redis/subscriber/factory.py index e46d3d6646..378b561348 100644 --- a/faststream/redis/subscriber/factory.py +++ b/faststream/redis/subscriber/factory.py @@ -1,32 +1,34 @@ +import warnings from collections.abc import Iterable from typing import TYPE_CHECKING, Optional, Union from typing_extensions import TypeAlias +from faststream._internal.constants import EMPTY from faststream.exceptions import SetupError +from faststream.middlewares import AckPolicy from faststream.redis.schemas import INCORRECT_SETUP_MSG, ListSub, PubSub, StreamSub from faststream.redis.schemas.proto import validate_options from faststream.redis.subscriber.specified import ( - AsyncAPIChannelSubscriber, - AsyncAPIListBatchSubscriber, - AsyncAPIListSubscriber, - AsyncAPIStreamBatchSubscriber, - AsyncAPIStreamSubscriber, + SpecificationChannelSubscriber, + SpecificationListBatchSubscriber, + SpecificationListSubscriber, + SpecificationStreamBatchSubscriber, + SpecificationStreamSubscriber, ) if TYPE_CHECKING: from fast_depends.dependencies import Dependant from faststream._internal.types import BrokerMiddleware - from faststream.middlewares import AckPolicy from faststream.redis.message import UnifyRedisDict SubsciberType: TypeAlias = Union[ - "AsyncAPIChannelSubscriber", - "AsyncAPIStreamBatchSubscriber", - "AsyncAPIStreamSubscriber", - "AsyncAPIListBatchSubscriber", - "AsyncAPIListSubscriber", + "SpecificationChannelSubscriber", + "SpecificationStreamBatchSubscriber", + "SpecificationStreamSubscriber", + "SpecificationListBatchSubscriber", + "SpecificationListSubscriber", ] @@ -45,13 +47,20 @@ def create_subscriber( description_: Optional[str] = None, include_in_schema: bool = True, ) -> SubsciberType: - validate_options(channel=channel, list=list, stream=stream) + _validate_input_for_misconfigure( + channel=channel, + list=list, + stream=stream, + ack_policy=ack_policy, + ) + + if ack_policy is EMPTY: + ack_policy = AckPolicy.REJECT_ON_ERROR if (channel_sub := PubSub.validate(channel)) is not None: - return AsyncAPIChannelSubscriber( + return SpecificationChannelSubscriber( channel=channel_sub, # basic args - ack_policy=ack_policy, no_reply=no_reply, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, @@ -63,7 +72,7 @@ def create_subscriber( if (stream_sub := StreamSub.validate(stream)) is not None: if stream_sub.batch: - return AsyncAPIStreamBatchSubscriber( + return SpecificationStreamBatchSubscriber( stream=stream_sub, # basic args ack_policy=ack_policy, @@ -75,7 +84,8 @@ def create_subscriber( description_=description_, include_in_schema=include_in_schema, ) - return AsyncAPIStreamSubscriber( + + return SpecificationStreamSubscriber( stream=stream_sub, # basic args ack_policy=ack_policy, @@ -90,10 +100,9 @@ def create_subscriber( if (list_sub := ListSub.validate(list)) is not None: if list_sub.batch: - return AsyncAPIListBatchSubscriber( + return SpecificationListBatchSubscriber( list=list_sub, # basic args - ack_policy=ack_policy, no_reply=no_reply, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, @@ -102,10 +111,10 @@ def create_subscriber( description_=description_, include_in_schema=include_in_schema, ) - return AsyncAPIListSubscriber( + + return SpecificationListSubscriber( list=list_sub, # basic args - ack_policy=ack_policy, no_reply=no_reply, broker_dependencies=broker_dependencies, broker_middlewares=broker_middlewares, @@ -116,3 +125,28 @@ def create_subscriber( ) raise SetupError(INCORRECT_SETUP_MSG) + + +def _validate_input_for_misconfigure( + *, + channel: Union["PubSub", str, None], + list: Union["ListSub", str, None], + stream: Union["StreamSub", str, None], + ack_policy: AckPolicy, +) -> None: + validate_options(channel=channel, list=list, stream=stream) + + if ack_policy is not EMPTY: + if channel: + warnings.warn( + "You can't use acknowledgement policy with PubSub subscriber.", + RuntimeWarning, + stacklevel=4, + ) + + if list: + warnings.warn( + "You can't use acknowledgement policy with List subscriber.", + RuntimeWarning, + stacklevel=4, + ) diff --git a/faststream/redis/subscriber/specified.py b/faststream/redis/subscriber/specified.py index 3c62aa3168..800e5b1f02 100644 --- a/faststream/redis/subscriber/specified.py +++ b/faststream/redis/subscriber/specified.py @@ -2,10 +2,10 @@ from faststream.redis.schemas.proto import RedisSpecificationProtocol from faststream.redis.subscriber.usecase import ( BatchListSubscriber, - BatchStreamSubscriber, ChannelSubscriber, ListSubscriber, LogicSubscriber, + StreamBatchSubscriber, StreamSubscriber, ) from faststream.specification.asyncapi.utils import resolve_payloads @@ -40,7 +40,7 @@ def get_schema(self) -> dict[str, Channel]: } -class AsyncAPIChannelSubscriber(ChannelSubscriber, SpecificationSubscriber): +class SpecificationChannelSubscriber(ChannelSubscriber, SpecificationSubscriber): def get_name(self) -> str: return f"{self.channel.name}:{self.call_name}" @@ -68,11 +68,11 @@ def channel_binding(self) -> "redis.ChannelBinding": ) -class AsyncAPIStreamSubscriber(StreamSubscriber, _StreamSubscriberMixin): +class SpecificationStreamSubscriber(StreamSubscriber, _StreamSubscriberMixin): pass -class AsyncAPIStreamBatchSubscriber(BatchStreamSubscriber, _StreamSubscriberMixin): +class SpecificationStreamBatchSubscriber(StreamBatchSubscriber, _StreamSubscriberMixin): pass @@ -90,9 +90,9 @@ def channel_binding(self) -> "redis.ChannelBinding": ) -class AsyncAPIListSubscriber(ListSubscriber, _ListSubscriberMixin): +class SpecificationListSubscriber(ListSubscriber, _ListSubscriberMixin): pass -class AsyncAPIListBatchSubscriber(BatchListSubscriber, _ListSubscriberMixin): +class SpecificationListBatchSubscriber(BatchListSubscriber, _ListSubscriberMixin): pass diff --git a/faststream/redis/subscriber/usecase.py b/faststream/redis/subscriber/usecase.py index 40a3c3a4f6..1f89ca54b4 100644 --- a/faststream/redis/subscriber/usecase.py +++ b/faststream/redis/subscriber/usecase.py @@ -21,6 +21,7 @@ from faststream._internal.subscriber.usecase import SubscriberUsecase from faststream._internal.subscriber.utils import process_msg +from faststream.middlewares import AckPolicy from faststream.redis.message import ( BatchListMessage, BatchStreamMessage, @@ -54,7 +55,6 @@ CustomCallable, ) from faststream.message import StreamMessage as BrokerStreamMessage - from faststream.middlewares import AckPolicy TopicName: TypeAlias = bytes @@ -205,7 +205,6 @@ def __init__( *, channel: "PubSub", # Subscriber args - ack_policy: "AckPolicy", no_reply: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], @@ -219,7 +218,7 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - ack_policy=ack_policy, + ack_policy=AckPolicy.DO_NOTHING, no_reply=no_reply, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, @@ -436,7 +435,6 @@ def __init__( *, list: ListSub, # Subscriber args - ack_policy: "AckPolicy", no_reply: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], @@ -451,7 +449,7 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - ack_policy=ack_policy, + ack_policy=AckPolicy.DO_NOTHING, no_reply=no_reply, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, @@ -485,7 +483,6 @@ def __init__( *, list: ListSub, # Subscriber args - ack_policy: "AckPolicy", no_reply: bool, broker_dependencies: Iterable["Dependant"], broker_middlewares: Iterable["BrokerMiddleware[UnifyRedisDict]"], @@ -500,7 +497,7 @@ def __init__( default_parser=parser.parse_message, default_decoder=parser.decode_message, # Propagated options - ack_policy=ack_policy, + ack_policy=AckPolicy.DO_NOTHING, no_reply=no_reply, broker_middlewares=broker_middlewares, broker_dependencies=broker_dependencies, @@ -788,7 +785,7 @@ async def _get_msgs( await self.consume(msg) # type: ignore[arg-type] -class BatchStreamSubscriber(_StreamHandlerMixin): +class StreamBatchSubscriber(_StreamHandlerMixin): def __init__( self, *, diff --git a/tests/brokers/base/consume.py b/tests/brokers/base/consume.py index 62d68072ca..4fdb0e118d 100644 --- a/tests/brokers/base/consume.py +++ b/tests/brokers/base/consume.py @@ -16,8 +16,8 @@ class BrokerConsumeTestcase(BaseTestcaseConfig): async def test_consume( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() consume_broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) @@ -200,9 +200,10 @@ async def handler2(m) -> None: async def test_consume_validate_false( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker( apply_types=True, serializer=None, @@ -240,8 +241,9 @@ async def handler( async def test_dynamic_sub( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() async def subscriber(m) -> None: @@ -334,9 +336,10 @@ async def test_get_one_timeout( async def test_stop_consume_exc( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) diff --git a/tests/brokers/base/fastapi.py b/tests/brokers/base/fastapi.py index e1e9c15954..d9b4582742 100644 --- a/tests/brokers/base/fastapi.py +++ b/tests/brokers/base/fastapi.py @@ -24,9 +24,9 @@ class FastAPITestcase(BaseTestcaseConfig): router_class: type[StreamRouter[BrokerUsecase]] broker_router_class: type[BrokerRouter[Any]] - async def test_base_real( - self, mock: Mock, queue: str, event: asyncio.Event - ) -> None: + async def test_base_real(self, mock: Mock, queue: str) -> None: + event = asyncio.Event() + router = self.router_class() args, kwargs = self.get_subscriber_params(queue) @@ -50,8 +50,12 @@ async def hello(msg): mock.assert_called_with("hi") async def test_background( - self, mock: Mock, queue: str, event: asyncio.Event + self, + mock: Mock, + queue: str, ) -> None: + event = asyncio.Event() + router = self.router_class() def task(msg): @@ -77,7 +81,9 @@ async def hello(msg, tasks: BackgroundTasks) -> None: assert event.is_set() mock.assert_called_with("hi") - async def test_context(self, mock: Mock, queue: str, event: asyncio.Event) -> None: + async def test_context(self, mock: Mock, queue: str) -> None: + event = asyncio.Event() + router = self.router_class() context = router.context @@ -108,7 +114,9 @@ async def hello(msg=Context(context_key)): assert event.is_set() mock.assert_called_with(True) - async def test_initial_context(self, queue: str, event: asyncio.Event) -> None: + async def test_initial_context(self, queue: str) -> None: + event = asyncio.Event() + router = self.router_class() context = router.context @@ -136,10 +144,10 @@ async def hello(msg: int, data=Context(queue, initial=set)) -> None: assert context.get(queue) == {1, 2} context.reset_global(queue) - async def test_double_real( - self, mock: Mock, queue: str, event: asyncio.Event - ) -> None: + async def test_double_real(self, mock: Mock, queue: str) -> None: + event = asyncio.Event() event2 = asyncio.Event() + router = self.router_class() args, kwargs = self.get_subscriber_params(queue) @@ -176,8 +184,9 @@ async def test_base_publisher_real( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() args, kwargs = self.get_subscriber_params(queue) diff --git a/tests/brokers/base/middlewares.py b/tests/brokers/base/middlewares.py index dbde16432f..47e07c6398 100644 --- a/tests/brokers/base/middlewares.py +++ b/tests/brokers/base/middlewares.py @@ -16,10 +16,11 @@ class LocalMiddlewareTestcase(BaseTestcaseConfig): async def test_subscriber_middleware( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + async def mid(call_next, msg): mock.start(await msg.decode()) result = await call_next(msg) @@ -54,10 +55,11 @@ async def handler(m) -> str: async def test_publisher_middleware( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + async def mid(call_next, msg, **kwargs): mock.enter() result = await call_next(msg, **kwargs) @@ -194,7 +196,9 @@ async def handler2(m) -> str: mock.end.assert_called_once() assert mock.call_count == 2 - async def test_error_traceback(self, queue: str, mock: Mock, event) -> None: + async def test_error_traceback(self, queue: str, mock: Mock) -> None: + event = asyncio.Event() + async def mid(call_next, msg): try: result = await call_next(msg) @@ -232,10 +236,11 @@ async def handler2(m): class MiddlewareTestcase(LocalMiddlewareTestcase): async def test_global_middleware( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + class mid(BaseMiddleware): # noqa: N801 async def on_receive(self): mock.start(self.msg) @@ -272,10 +277,11 @@ async def handler(m) -> str: async def test_add_global_middleware( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + class mid(BaseMiddleware): # noqa: N801 async def on_receive(self): mock.start(self.msg) @@ -328,8 +334,9 @@ async def test_patch_publish( self, queue: str, mock: Mock, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + class Mid(BaseMiddleware): async def on_publish(self, msg: PublishCommand) -> PublishCommand: msg.body *= 2 @@ -366,10 +373,11 @@ async def handler_resp(m) -> None: async def test_global_publisher_middleware( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + class Mid(BaseMiddleware): async def on_publish(self, msg: PublishCommand) -> PublishCommand: msg.body *= 2 @@ -413,10 +421,11 @@ async def handler(m): class ExceptionMiddlewareTestcase(BaseTestcaseConfig): async def test_exception_middleware_default_msg( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = ExceptionMiddleware() @mid.add_handler(ValueError, publish=True) @@ -455,10 +464,11 @@ async def subscriber2(msg=Context("message")) -> None: async def test_exception_middleware_skip_msg( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = ExceptionMiddleware() @mid.add_handler(ValueError, publish=True) @@ -495,10 +505,11 @@ async def subscriber2(msg=Context("message")) -> None: async def test_exception_middleware_do_not_catch_skip_msg( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = ExceptionMiddleware() @mid.add_handler(Exception) @@ -529,10 +540,11 @@ async def subscriber(m): async def test_exception_middleware_reraise( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = ExceptionMiddleware() @mid.add_handler(ValueError, publish=True) @@ -569,10 +581,11 @@ async def subscriber2(msg=Context("message")) -> None: async def test_exception_middleware_different_handler( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = ExceptionMiddleware() @mid.add_handler(ZeroDivisionError, publish=True) @@ -649,10 +662,11 @@ async def value_error_handler(exc) -> str: async def test_exception_middleware_decoder_error( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + async def decoder( msg, original_decoder, diff --git a/tests/brokers/base/parser.py b/tests/brokers/base/parser.py index 143dc24b75..859c508c53 100644 --- a/tests/brokers/base/parser.py +++ b/tests/brokers/base/parser.py @@ -12,8 +12,9 @@ async def test_local_parser( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() async def custom_parser(msg, original): @@ -45,8 +46,9 @@ async def test_local_sync_decoder( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() def custom_decoder(msg): @@ -77,8 +79,9 @@ async def test_global_sync_decoder( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + def custom_decoder(msg): mock(msg.body) return msg @@ -107,10 +110,11 @@ async def handle(m) -> None: async def test_local_parser_no_share_between_subscribers( self, - event: asyncio.Event, mock: Mock, queue: str, ) -> None: + event = asyncio.Event() + event2 = asyncio.Event() broker = self.get_broker() @@ -151,8 +155,9 @@ async def test_local_parser_no_share_between_handlers( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) @@ -196,8 +201,9 @@ async def test_global_parser( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + async def custom_parser(msg, original): msg = await original(msg) mock(msg.body) diff --git a/tests/brokers/base/publish.py b/tests/brokers/base/publish.py index 75154bd9f2..feac1efac4 100644 --- a/tests/brokers/base/publish.py +++ b/tests/brokers/base/publish.py @@ -141,9 +141,10 @@ async def test_serialize( message, message_type, expected_message, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue) @@ -171,9 +172,10 @@ async def handler(m: message_type) -> None: async def test_response( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue) @@ -215,9 +217,10 @@ async def m_next(msg=Context("message")) -> None: async def test_unwrap_dict( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue) @@ -250,8 +253,9 @@ async def test_unwrap_list( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue) @@ -278,9 +282,10 @@ async def m(a: int, b: int, *args: tuple[int, ...]) -> None: async def test_base_publisher( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue) @@ -314,9 +319,10 @@ async def resp(msg) -> None: async def test_publisher_object( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) publisher = pub_broker.publisher(queue + "resp") @@ -352,9 +358,10 @@ async def resp(msg) -> None: async def test_publish_manual( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) publisher = pub_broker.publisher(queue + "resp") @@ -491,9 +498,10 @@ async def resp() -> None: async def test_reply_to( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue + "reply") @@ -529,9 +537,10 @@ async def handler(m): async def test_no_reply( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + class Mid(BaseMiddleware): async def after_processed(self, *args: Any, **kwargs: Any): event.set() @@ -573,9 +582,10 @@ async def handler(m): async def test_publisher_after_start( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue) diff --git a/tests/brokers/base/requests.py b/tests/brokers/base/requests.py index 9cb8296fa9..16414f47d3 100644 --- a/tests/brokers/base/requests.py +++ b/tests/brokers/base/requests.py @@ -1,3 +1,5 @@ +import asyncio + import anyio import pytest @@ -24,7 +26,7 @@ async def handler(msg) -> str: async with self.patch_broker(broker): await broker.start() - with pytest.raises(TimeoutError): + with pytest.raises((TimeoutError, asyncio.TimeoutError)): await broker.request( None, queue, diff --git a/tests/brokers/base/router.py b/tests/brokers/base/router.py index 7b8628172d..382f54dc0c 100644 --- a/tests/brokers/base/router.py +++ b/tests/brokers/base/router.py @@ -25,8 +25,9 @@ async def test_empty_prefix( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) @@ -53,8 +54,9 @@ async def test_not_empty_prefix( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() router.prefix = "test_" @@ -83,8 +85,9 @@ async def test_include_with_prefix( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) @@ -111,8 +114,9 @@ async def test_empty_prefix_publisher( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) @@ -146,8 +150,9 @@ async def test_not_empty_prefix_publisher( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() router.prefix = "test_" @@ -183,8 +188,9 @@ async def test_manual_publisher( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() router.prefix = "test_" @@ -219,10 +225,11 @@ def response(m) -> None: async def test_delayed_handlers( self, - event: asyncio.Event, router: BrokerRouter, queue: str, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() def response(m) -> None: @@ -251,11 +258,12 @@ def response(m) -> None: async def test_delayed_publishers( self, - event: asyncio.Event, router: BrokerRouter, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() def response(m): @@ -303,9 +311,10 @@ async def test_nested_routers_sub( self, router: BrokerRouter, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() core_router = type(router)(prefix="test1_") @@ -340,8 +349,9 @@ async def test_nested_routers_pub( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() core_router = type(router)(prefix="test1_") @@ -488,9 +498,10 @@ async def test_router_parser( self, router: BrokerRouter, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() async def parser(msg, original): @@ -532,9 +543,10 @@ async def test_router_parser_override( self, router: BrokerRouter, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() async def global_parser(msg, original): # pragma: no cover @@ -588,8 +600,9 @@ async def test_publisher_mock( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() pub = router.publisher(queue + "resp") @@ -621,8 +634,9 @@ async def test_subscriber_mock( self, router: BrokerRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) diff --git a/tests/brokers/confluent/test_consume.py b/tests/brokers/confluent/test_consume.py index 9bb954ee2f..2e886bc1f6 100644 --- a/tests/brokers/confluent/test_consume.py +++ b/tests/brokers/confluent/test_consume.py @@ -50,9 +50,10 @@ async def handler(msg) -> None: async def test_consume_batch_headers( self, mock, - event: asyncio.Event, queue: str, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue, batch=True) @@ -88,8 +89,9 @@ def subscriber(m, msg: KafkaMessage) -> None: async def test_consume_ack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params( @@ -131,8 +133,9 @@ async def handler(msg: KafkaMessage) -> None: async def test_consume_ack_manual( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params( @@ -170,8 +173,9 @@ async def handler(msg: KafkaMessage) -> None: async def test_consume_ack_raise( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params( @@ -209,8 +213,9 @@ async def handler(msg: KafkaMessage): async def test_nack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params( @@ -248,8 +253,9 @@ async def handler(msg: KafkaMessage) -> None: async def test_consume_no_ack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params( @@ -289,8 +295,9 @@ async def handler(msg: KafkaMessage) -> None: async def test_consume_with_no_auto_commit( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params( diff --git a/tests/brokers/confluent/test_fastapi.py b/tests/brokers/confluent/test_fastapi.py index 6d2944ce94..d47612c91e 100644 --- a/tests/brokers/confluent/test_fastapi.py +++ b/tests/brokers/confluent/test_fastapi.py @@ -21,8 +21,9 @@ async def test_batch_real( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() args, kwargs = self.get_subscriber_params(queue, batch=True) @@ -57,8 +58,9 @@ async def test_batch_testclient( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() args, kwargs = self.get_subscriber_params(queue, batch=True) diff --git a/tests/brokers/confluent/test_publish.py b/tests/brokers/confluent/test_publish.py index f4c7b2e251..0d6fc9f4e1 100644 --- a/tests/brokers/confluent/test_publish.py +++ b/tests/brokers/confluent/test_publish.py @@ -110,9 +110,10 @@ async def pub(m): async def test_response( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) args, kwargs = self.get_subscriber_params(queue) diff --git a/tests/brokers/confluent/test_test_client.py b/tests/brokers/confluent/test_test_client.py index e5d915404c..73077ec147 100644 --- a/tests/brokers/confluent/test_test_client.py +++ b/tests/brokers/confluent/test_test_client.py @@ -52,8 +52,9 @@ async def m(msg: KafkaMessage) -> None: async def test_with_real_testclient( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() args, kwargs = self.get_subscriber_params(queue) diff --git a/tests/brokers/kafka/test_consume.py b/tests/brokers/kafka/test_consume.py index cb0f32db43..f725f90371 100644 --- a/tests/brokers/kafka/test_consume.py +++ b/tests/brokers/kafka/test_consume.py @@ -22,8 +22,9 @@ def get_broker(self, apply_types: bool = False, **kwargs: Any) -> KafkaBroker: async def test_consume_by_pattern( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(queue) @@ -79,9 +80,10 @@ async def handler(msg) -> None: async def test_consume_batch_headers( self, mock, - event: asyncio.Event, queue: str, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, batch=True) @@ -115,8 +117,9 @@ def subscriber(m, msg: KafkaMessage) -> None: async def test_consume_ack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, group_id="test", auto_commit=False) @@ -151,8 +154,9 @@ async def handler(msg: KafkaMessage) -> None: async def test_manual_partition_consume( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() tp1 = TopicPartition(queue, partition=0) @@ -179,8 +183,9 @@ async def handler_tp1(msg) -> None: async def test_consume_ack_manual( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, group_id="test", auto_commit=False) @@ -217,8 +222,9 @@ async def handler(msg: KafkaMessage) -> None: async def test_consume_ack_raise( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, group_id="test", auto_commit=False) @@ -255,8 +261,9 @@ async def handler(msg: KafkaMessage): async def test_nack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, group_id="test", auto_commit=False) @@ -293,8 +300,9 @@ async def handler(msg: KafkaMessage) -> None: async def test_consume_no_ack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber( diff --git a/tests/brokers/kafka/test_fastapi.py b/tests/brokers/kafka/test_fastapi.py index 442e9517f2..899deaffce 100644 --- a/tests/brokers/kafka/test_fastapi.py +++ b/tests/brokers/kafka/test_fastapi.py @@ -18,8 +18,9 @@ async def test_batch_real( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(queue, batch=True) @@ -52,8 +53,9 @@ async def test_batch_testclient( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(queue, batch=True) diff --git a/tests/brokers/kafka/test_publish.py b/tests/brokers/kafka/test_publish.py index 19946156fa..80cb7b017b 100644 --- a/tests/brokers/kafka/test_publish.py +++ b/tests/brokers/kafka/test_publish.py @@ -100,9 +100,10 @@ async def pub(m): async def test_response( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) @pub_broker.subscriber(queue) diff --git a/tests/brokers/kafka/test_test_client.py b/tests/brokers/kafka/test_test_client.py index b27444656b..b490604453 100644 --- a/tests/brokers/kafka/test_test_client.py +++ b/tests/brokers/kafka/test_test_client.py @@ -94,8 +94,9 @@ async def m(msg: KafkaMessage) -> None: async def test_with_real_testclient( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() @broker.subscriber(queue) diff --git a/tests/brokers/nats/test_consume.py b/tests/brokers/nats/test_consume.py index c7359c2ba5..8a6cd3917e 100644 --- a/tests/brokers/nats/test_consume.py +++ b/tests/brokers/nats/test_consume.py @@ -23,8 +23,9 @@ async def test_consume_js( self, queue: str, stream: JStream, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(queue, stream=stream) @@ -54,8 +55,9 @@ async def test_consume_with_filter( self, queue, mock: Mock, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber( @@ -83,9 +85,10 @@ async def test_consume_pull( self, queue: str, stream: JStream, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber( @@ -115,9 +118,10 @@ async def test_consume_batch( self, queue: str, stream: JStream, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber( @@ -146,9 +150,10 @@ def subscriber(m) -> None: async def test_consume_ack( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, stream=stream) @@ -173,35 +178,38 @@ async def handler(msg: NatsMessage) -> None: async def test_core_consume_no_ack( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, ack_policy=AckPolicy.DO_NOTHING) async def handler(msg: NatsMessage) -> None: - if not msg.raw_message._ackd: - event.set() + event.set() async with self.patch_broker(consume_broker) as br: await br.start() - await asyncio.wait( - ( - asyncio.create_task(br.publish("hello", queue)), - asyncio.create_task(event.wait()), - ), - timeout=3, - ) + with patch.object(Msg, "ack", spy_decorator(Msg.ack)) as m: + await asyncio.wait( + ( + asyncio.create_task(br.publish("hello", queue)), + asyncio.create_task(event.wait()), + ), + timeout=3, + ) + assert not m.mock.called assert event.is_set() async def test_consume_ack_manual( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, stream=stream) @@ -227,9 +235,10 @@ async def handler(msg: NatsMessage) -> None: async def test_consume_ack_raise( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, stream=stream) @@ -255,9 +264,10 @@ async def handler(msg: NatsMessage): async def test_nack( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, stream=stream) @@ -283,8 +293,9 @@ async def handler(msg: NatsMessage) -> None: async def test_consume_no_ack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, ack_policy=AckPolicy.DO_NOTHING) @@ -310,9 +321,10 @@ async def test_consume_batch_headers( self, queue: str, stream: JStream, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber( @@ -348,9 +360,10 @@ def subscriber(m, msg: NatsMessage) -> None: async def test_consume_kv( self, queue: str, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, kv_watch=queue + "1") @@ -382,9 +395,10 @@ async def handler(m) -> None: async def test_consume_os( self, queue: str, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue, obj_watch=True) @@ -415,7 +429,6 @@ async def handler(filename: str) -> None: async def test_get_one_js( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: broker = self.get_broker(apply_types=True) @@ -462,7 +475,6 @@ async def test_get_one_timeout_js( async def test_get_one_pull( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: broker = self.get_broker(apply_types=True) @@ -498,7 +510,6 @@ async def publish() -> None: async def test_get_one_pull_timeout( self, queue: str, - event: asyncio.Event, stream: JStream, mock: Mock, ) -> None: @@ -518,7 +529,6 @@ async def test_get_one_pull_timeout( async def test_get_one_batch( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: broker = self.get_broker(apply_types=True) @@ -554,7 +564,6 @@ async def publish() -> None: async def test_get_one_batch_timeout( self, queue: str, - event: asyncio.Event, stream: JStream, mock: Mock, ) -> None: @@ -574,7 +583,6 @@ async def test_get_one_batch_timeout( async def test_get_one_with_filter( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: broker = self.get_broker(apply_types=True) @@ -609,7 +617,6 @@ async def publish() -> None: async def test_get_one_kv( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: broker = self.get_broker(apply_types=True) @@ -642,7 +649,6 @@ async def publish() -> None: async def test_get_one_kv_timeout( self, queue: str, - event: asyncio.Event, stream: JStream, mock: Mock, ) -> None: @@ -658,7 +664,6 @@ async def test_get_one_kv_timeout( async def test_get_one_os( self, queue: str, - event: asyncio.Event, stream: JStream, ) -> None: broker = self.get_broker(apply_types=True) @@ -692,7 +697,6 @@ async def publish() -> None: async def test_get_one_os_timeout( self, queue: str, - event: asyncio.Event, stream: JStream, mock: Mock, ) -> None: diff --git a/tests/brokers/nats/test_fastapi.py b/tests/brokers/nats/test_fastapi.py index d5a421a8a4..bbcdac2113 100644 --- a/tests/brokers/nats/test_fastapi.py +++ b/tests/brokers/nats/test_fastapi.py @@ -17,9 +17,10 @@ class TestRouter(FastAPITestcase): async def test_path( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(queue + ".{name}") @@ -46,9 +47,10 @@ async def test_consume_batch( self, queue: str, stream: JStream, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber( @@ -85,9 +87,10 @@ async def test_consume_batch( self, queue: str, stream: JStream, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber( diff --git a/tests/brokers/nats/test_publish.py b/tests/brokers/nats/test_publish.py index 3b92b0673e..c7367ac389 100644 --- a/tests/brokers/nats/test_publish.py +++ b/tests/brokers/nats/test_publish.py @@ -19,9 +19,10 @@ def get_broker(self, apply_types: bool = False, **kwargs) -> NatsBroker: async def test_response( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) @pub_broker.subscriber(queue) @@ -58,7 +59,6 @@ async def handle_next(msg=Context("message")) -> None: async def test_response_for_rpc( self, queue: str, - event: asyncio.Event, ) -> None: pub_broker = self.get_broker(apply_types=True) diff --git a/tests/brokers/nats/test_router.py b/tests/brokers/nats/test_router.py index c424aa4da1..8af70bcfa0 100644 --- a/tests/brokers/nats/test_router.py +++ b/tests/brokers/nats/test_router.py @@ -128,10 +128,11 @@ async def h( async def test_delayed_handlers_with_queue( self, - event, router: NatsRouter, queue: str, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() def response(m) -> None: diff --git a/tests/brokers/nats/test_test_client.py b/tests/brokers/nats/test_test_client.py index cc4a3106e7..7ca172e6ce 100644 --- a/tests/brokers/nats/test_test_client.py +++ b/tests/brokers/nats/test_test_client.py @@ -55,8 +55,9 @@ async def m(msg) -> None: ... async def test_with_real_testclient( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() @broker.subscriber(queue) diff --git a/tests/brokers/rabbit/test_consume.py b/tests/brokers/rabbit/test_consume.py index 4f51bb4899..19317948fa 100644 --- a/tests/brokers/rabbit/test_consume.py +++ b/tests/brokers/rabbit/test_consume.py @@ -23,8 +23,9 @@ async def test_consume_from_exchange( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -50,8 +51,9 @@ async def test_consume_with_get_old( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber( @@ -88,8 +90,9 @@ async def test_consume_ack( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -122,8 +125,9 @@ async def test_consume_manual_ack( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -156,8 +160,9 @@ async def test_consume_exception_ack( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -192,8 +197,9 @@ async def test_consume_manual_nack( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -227,8 +233,9 @@ async def test_consume_exception_nack( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -263,8 +270,9 @@ async def test_consume_manual_reject( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -298,8 +306,9 @@ async def test_consume_exception_reject( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue=queue, exchange=exchange) @@ -333,8 +342,9 @@ async def handler(msg: RabbitMessage) -> None: async def test_consume_skip_message( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber(queue) @@ -382,8 +392,9 @@ async def test_consume_no_ack( self, queue: str, exchange: RabbitExchange, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber( diff --git a/tests/brokers/rabbit/test_fastapi.py b/tests/brokers/rabbit/test_fastapi.py index df20f2fdeb..3bd99eae62 100644 --- a/tests/brokers/rabbit/test_fastapi.py +++ b/tests/brokers/rabbit/test_fastapi.py @@ -18,9 +18,10 @@ class TestRouter(FastAPITestcase): async def test_path( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber( diff --git a/tests/brokers/rabbit/test_publish.py b/tests/brokers/rabbit/test_publish.py index 4e9b7b3121..f5000d9104 100644 --- a/tests/brokers/rabbit/test_publish.py +++ b/tests/brokers/rabbit/test_publish.py @@ -23,9 +23,10 @@ def get_broker(self, apply_types: bool = False, **kwargs: Any) -> RabbitBroker: async def test_reply_config( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() reply_queue = queue + "reply" @@ -68,9 +69,10 @@ async def handler(m): async def test_response( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) @pub_broker.subscriber(queue) @@ -110,7 +112,6 @@ async def handle_next(msg=Context("message")) -> None: async def test_response_for_rpc( self, queue: str, - event: asyncio.Event, ) -> None: pub_broker = self.get_broker(apply_types=True) diff --git a/tests/brokers/rabbit/test_router.py b/tests/brokers/rabbit/test_router.py index d037375900..0fb2b8babf 100644 --- a/tests/brokers/rabbit/test_router.py +++ b/tests/brokers/rabbit/test_router.py @@ -112,8 +112,9 @@ async def test_queue_obj( self, router: RabbitRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() router.prefix = "test/" @@ -145,8 +146,9 @@ async def test_queue_obj_with_routing_key( self, router: RabbitRouter, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() router.prefix = "test/" @@ -177,10 +179,11 @@ def subscriber(m) -> None: async def test_delayed_handlers_with_queue( self, - event: asyncio.Event, router: RabbitRouter, queue: str, ) -> None: + event = asyncio.Event() + def response(m) -> None: event.set() diff --git a/tests/brokers/rabbit/test_test_client.py b/tests/brokers/rabbit/test_test_client.py index cf76669716..0784da9bf3 100644 --- a/tests/brokers/rabbit/test_test_client.py +++ b/tests/brokers/rabbit/test_test_client.py @@ -31,8 +31,9 @@ def patch_broker(self, broker: RabbitBroker, **kwargs: Any) -> RabbitBroker: async def test_with_real_testclient( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() @broker.subscriber(queue) diff --git a/tests/brokers/redis/test_consume.py b/tests/brokers/redis/test_consume.py index 899a941813..7c7a1e4152 100644 --- a/tests/brokers/redis/test_consume.py +++ b/tests/brokers/redis/test_consume.py @@ -18,10 +18,11 @@ def get_broker(self, apply_types: bool = False, **kwargs: Any) -> RedisBroker: async def test_consume_native( self, - event: asyncio.Event, mock: MagicMock, queue: str, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(queue) @@ -44,9 +45,10 @@ async def handler(msg) -> None: async def test_pattern_with_path( self, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber("test.{name}") @@ -69,9 +71,10 @@ async def handler(msg) -> None: async def test_pattern_without_path( self, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(PubSub("test.*", pattern=True)) @@ -104,10 +107,11 @@ def patch_broker(self, broker): async def test_consume_list( self, - event: asyncio.Event, queue: str, mock: MagicMock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(list=queue) @@ -130,10 +134,11 @@ async def handler(msg) -> None: async def test_consume_list_native( self, - event: asyncio.Event, queue: str, mock: MagicMock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(list=queue) @@ -158,9 +163,10 @@ async def handler(msg) -> None: async def test_consume_list_batch_with_one( self, queue: str, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber( @@ -187,9 +193,10 @@ async def handler(msg) -> None: async def test_consume_list_batch_headers( self, queue: str, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber( @@ -314,7 +321,6 @@ async def handler(msg) -> None: async def test_get_one( self, queue: str, - event: asyncio.Event, ) -> None: broker = self.get_broker(apply_types=True) subscriber = broker.subscriber(list=queue) @@ -369,10 +375,11 @@ def patch_broker(self, broker): @pytest.mark.slow() async def test_consume_stream( self, - event: asyncio.Event, mock: MagicMock, queue, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(stream=StreamSub(queue, polling_interval=10)) @@ -396,10 +403,11 @@ async def handler(msg) -> None: @pytest.mark.slow() async def test_consume_stream_native( self, - event: asyncio.Event, mock: MagicMock, queue, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber(stream=StreamSub(queue, polling_interval=10)) @@ -425,10 +433,11 @@ async def handler(msg) -> None: @pytest.mark.slow() async def test_consume_stream_batch( self, - event: asyncio.Event, mock: MagicMock, queue, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber( @@ -455,9 +464,10 @@ async def handler(msg) -> None: async def test_consume_stream_batch_headers( self, queue: str, - event: asyncio.Event, mock, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber( @@ -525,10 +535,11 @@ async def handler(msg: list[Data]) -> None: @pytest.mark.slow() async def test_consume_stream_batch_native( self, - event: asyncio.Event, mock: MagicMock, queue, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker() @consume_broker.subscriber( @@ -582,8 +593,9 @@ async def handler(msg: RedisMessage) -> None: ... async def test_consume_nack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber( @@ -612,8 +624,9 @@ async def handler(msg: RedisMessage) -> None: async def test_consume_ack( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + consume_broker = self.get_broker(apply_types=True) @consume_broker.subscriber( diff --git a/tests/brokers/redis/test_fastapi.py b/tests/brokers/redis/test_fastapi.py index 41de233318..9c66a73230 100644 --- a/tests/brokers/redis/test_fastapi.py +++ b/tests/brokers/redis/test_fastapi.py @@ -18,9 +18,10 @@ class TestRouter(FastAPITestcase): async def test_path( self, queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber("in.{name}") @@ -57,8 +58,9 @@ async def test_batch_real( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(list=ListSub(queue, batch=True, max_records=1)) @@ -82,10 +84,11 @@ async def hello(msg: list[str]): @pytest.mark.slow() async def test_consume_stream( self, - event: asyncio.Event, mock: Mock, queue, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(stream=StreamSub(queue, polling_interval=10)) @@ -110,10 +113,11 @@ async def handler(msg) -> None: @pytest.mark.slow() async def test_consume_stream_batch( self, - event: asyncio.Event, mock: Mock, queue, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(stream=StreamSub(queue, polling_interval=10, batch=True)) @@ -147,8 +151,9 @@ async def test_batch_testclient( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(list=ListSub(queue, batch=True, max_records=1)) @@ -172,8 +177,9 @@ async def test_stream_batch_testclient( self, mock: Mock, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + router = self.router_class() @router.subscriber(stream=StreamSub(queue, batch=True)) diff --git a/tests/brokers/redis/test_publish.py b/tests/brokers/redis/test_publish.py index 25bf9023d0..8967aa0778 100644 --- a/tests/brokers/redis/test_publish.py +++ b/tests/brokers/redis/test_publish.py @@ -20,9 +20,10 @@ def get_broker(self, apply_types: bool = False, **kwargs: Any) -> RedisBroker: async def test_list_publisher( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() @pub_broker.subscriber(list=queue) @@ -79,9 +80,10 @@ async def handler(msg) -> None: async def test_batch_list_publisher( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() batch_list = ListSub(queue + "resp", batch=True) @@ -113,9 +115,10 @@ async def resp(msg) -> None: async def test_publisher_with_maxlen( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() stream = StreamSub(queue + "resp", maxlen=1) @@ -150,9 +153,10 @@ async def resp(msg) -> None: async def test_response( self, queue: str, - event: asyncio.Event, mock: MagicMock, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker(apply_types=True) @pub_broker.subscriber(list=queue) @@ -188,7 +192,6 @@ async def resp(msg=Context("message")) -> None: async def test_response_for_rpc( self, queue: str, - event: asyncio.Event, ) -> None: pub_broker = self.get_broker(apply_types=True) diff --git a/tests/brokers/redis/test_router.py b/tests/brokers/redis/test_router.py index 5b6af70eee..ef53e47a37 100644 --- a/tests/brokers/redis/test_router.py +++ b/tests/brokers/redis/test_router.py @@ -118,9 +118,10 @@ async def h( async def test_delayed_channel_handlers( self, - event: asyncio.Event, queue: str, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() def response(m) -> None: @@ -145,9 +146,10 @@ def response(m) -> None: async def test_delayed_list_handlers( self, - event: asyncio.Event, queue: str, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() def response(m) -> None: @@ -172,9 +174,10 @@ def response(m) -> None: async def test_delayed_stream_handlers( self, - event: asyncio.Event, queue: str, ) -> None: + event = asyncio.Event() + pub_broker = self.get_broker() def response(m) -> None: diff --git a/tests/brokers/redis/test_test_client.py b/tests/brokers/redis/test_test_client.py index 2eb6ecaf3c..c9bcbe5527 100644 --- a/tests/brokers/redis/test_test_client.py +++ b/tests/brokers/redis/test_test_client.py @@ -23,8 +23,9 @@ def patch_broker(self, broker: RedisBroker, **kwargs: Any) -> TestRedisBroker: async def test_with_real_testclient( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + broker = self.get_broker() @broker.subscriber(queue) diff --git a/tests/opentelemetry/basic.py b/tests/opentelemetry/basic.py index 30d4ba0895..9a8f4dd176 100644 --- a/tests/opentelemetry/basic.py +++ b/tests/opentelemetry/basic.py @@ -168,12 +168,13 @@ def assert_metrics( async def test_subscriber_create_publish_process_span( self, - event: asyncio.Event, queue: str, mock: Mock, tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class(tracer_provider=tracer_provider) broker = self.get_broker(middlewares=(mid,)) @@ -207,12 +208,13 @@ async def handler(m) -> None: async def test_chain_subscriber_publisher( self, - event: asyncio.Event, queue: str, mock: Mock, tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class(tracer_provider=tracer_provider) broker = self.get_broker(middlewares=(mid,)) @@ -267,12 +269,13 @@ async def handler2(m) -> None: async def test_no_trace_context_create_process_span( self, - event: asyncio.Event, queue: str, mock: Mock, tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class(tracer_provider=tracer_provider) broker = self.get_broker(middlewares=(mid,)) @@ -306,12 +309,13 @@ async def handler(m) -> None: async def test_metrics( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, metric_reader: InMemoryMetricReader, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class(meter_provider=meter_provider) broker = self.get_broker(middlewares=(mid,)) @@ -342,12 +346,13 @@ async def handler(m) -> None: async def test_error_metrics( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, metric_reader: InMemoryMetricReader, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class(meter_provider=meter_provider) broker = self.get_broker(middlewares=(mid,)) expected_value_type = "ValueError" @@ -382,12 +387,13 @@ async def handler(m) -> None: async def test_span_in_context( self, - event: asyncio.Event, queue: str, mock: Mock, tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class(tracer_provider=tracer_provider) broker = self.get_broker(middlewares=(mid,), apply_types=True) @@ -415,10 +421,11 @@ async def handler(m, span: CurrentSpan) -> None: async def test_get_baggage( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class() broker = self.get_broker(middlewares=(mid,), apply_types=True) expected_baggage = {"foo": "bar"} @@ -456,10 +463,11 @@ async def handler1(m, baggage: CurrentBaggage) -> None: async def test_clear_baggage( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class() broker = self.get_broker(middlewares=(mid,), apply_types=True) @@ -505,10 +513,11 @@ async def handler2(m, baggage: CurrentBaggage) -> None: async def test_modify_baggage( self, - event: asyncio.Event, queue: str, mock: Mock, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class() broker = self.get_broker(middlewares=(mid,), apply_types=True) expected_baggage = {"baz": "bar", "bar": "baz"} @@ -556,9 +565,10 @@ async def handler2(m, baggage: CurrentBaggage) -> None: async def test_get_baggage_from_headers( self, - event: asyncio.Event, queue: str, ): + event = asyncio.Event() + mid = self.telemetry_middleware_class() broker = self.get_broker(middlewares=(mid,), apply_types=True) diff --git a/tests/opentelemetry/confluent/test_confluent.py b/tests/opentelemetry/confluent/test_confluent.py index e3f6a697bb..088e1d551c 100644 --- a/tests/opentelemetry/confluent/test_confluent.py +++ b/tests/opentelemetry/confluent/test_confluent.py @@ -64,7 +64,6 @@ def assert_span( async def test_batch( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, @@ -72,6 +71,8 @@ async def test_batch( tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class( meter_provider=meter_provider, tracer_provider=tracer_provider, @@ -198,7 +199,6 @@ async def handler(msg, baggage: CurrentBaggage) -> None: async def test_single_publish_with_batch_consume( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, @@ -206,6 +206,8 @@ async def test_single_publish_with_batch_consume( tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class( meter_provider=meter_provider, tracer_provider=tracer_provider, diff --git a/tests/opentelemetry/kafka/test_kafka.py b/tests/opentelemetry/kafka/test_kafka.py index 3f05cda5b1..79e93b82df 100644 --- a/tests/opentelemetry/kafka/test_kafka.py +++ b/tests/opentelemetry/kafka/test_kafka.py @@ -65,7 +65,6 @@ def assert_span( async def test_batch( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, @@ -73,6 +72,8 @@ async def test_batch( tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class( meter_provider=meter_provider, tracer_provider=tracer_provider, @@ -199,7 +200,6 @@ async def handler(msg, baggage: CurrentBaggage) -> None: async def test_single_publish_with_batch_consume( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, @@ -207,6 +207,8 @@ async def test_single_publish_with_batch_consume( tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class( meter_provider=meter_provider, tracer_provider=tracer_provider, diff --git a/tests/opentelemetry/nats/test_nats.py b/tests/opentelemetry/nats/test_nats.py index 8dd8238e15..efa5153a50 100644 --- a/tests/opentelemetry/nats/test_nats.py +++ b/tests/opentelemetry/nats/test_nats.py @@ -32,7 +32,6 @@ def get_broker(self, apply_types: bool = False, **kwargs: Any) -> NatsBroker: async def test_batch( self, - event: asyncio.Event, queue: str, mock: Mock, stream: JStream, @@ -41,6 +40,8 @@ async def test_batch( tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class( meter_provider=meter_provider, tracer_provider=tracer_provider, diff --git a/tests/opentelemetry/redis/test_redis.py b/tests/opentelemetry/redis/test_redis.py index cbe1a107b8..bdfc49ceb1 100644 --- a/tests/opentelemetry/redis/test_redis.py +++ b/tests/opentelemetry/redis/test_redis.py @@ -32,7 +32,6 @@ def get_broker(self, apply_types: bool = False, **kwargs: Any) -> RedisBroker: async def test_batch( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, @@ -40,6 +39,8 @@ async def test_batch( tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class( meter_provider=meter_provider, tracer_provider=tracer_provider, @@ -151,7 +152,6 @@ async def handler(msg, baggage: CurrentBaggage) -> None: async def test_single_publish_with_batch_consume( self, - event: asyncio.Event, queue: str, mock: Mock, meter_provider: MeterProvider, @@ -159,6 +159,8 @@ async def test_single_publish_with_batch_consume( tracer_provider: TracerProvider, trace_exporter: InMemorySpanExporter, ) -> None: + event = asyncio.Event() + mid = self.telemetry_middleware_class( meter_provider=meter_provider, tracer_provider=tracer_provider, diff --git a/tests/prometheus/basic.py b/tests/prometheus/basic.py index 2c4d8a2028..6a5f0e303e 100644 --- a/tests/prometheus/basic.py +++ b/tests/prometheus/basic.py @@ -47,22 +47,27 @@ def settings_provider_factory(self): id="acked status with reject message exception", ), pytest.param( - AckStatus.ACKED, Exception, id="acked status with not handler exception" + AckStatus.ACKED, + Exception, + id="acked status with not handler exception", ), pytest.param(AckStatus.ACKED, None, id="acked status without exception"), pytest.param(AckStatus.NACKED, None, id="nacked status without exception"), pytest.param( - AckStatus.REJECTED, None, id="rejected status without exception" + AckStatus.REJECTED, + None, + id="rejected status without exception", ), ), ) async def test_metrics( self, - event: asyncio.Event, queue: str, status: AckStatus, exception_class: Optional[type[Exception]], ): + event = asyncio.Event() + middleware = self.get_middleware(registry=CollectorRegistry()) metrics_manager_mock = Mock() middleware._metrics_manager = metrics_manager_mock @@ -210,8 +215,9 @@ class LocalRPCPrometheusTestcase: async def test_rpc_request( self, queue: str, - event: asyncio.Event, ) -> None: + event = asyncio.Event() + middleware = self.get_middleware(registry=CollectorRegistry()) metrics_manager_mock = Mock() middleware._metrics_manager = metrics_manager_mock diff --git a/tests/prometheus/confluent/test_confluent.py b/tests/prometheus/confluent/test_confluent.py index 0b0deabf88..84714bd280 100644 --- a/tests/prometheus/confluent/test_confluent.py +++ b/tests/prometheus/confluent/test_confluent.py @@ -23,9 +23,10 @@ def get_middleware(self, **kwargs): async def test_metrics_batch( self, - event: asyncio.Event, queue: str, ): + event = asyncio.Event() + middleware = self.get_middleware(registry=CollectorRegistry()) metrics_manager_mock = Mock() middleware._metrics_manager = metrics_manager_mock diff --git a/tests/prometheus/kafka/test_kafka.py b/tests/prometheus/kafka/test_kafka.py index 85e9e7a0eb..7ba5ba6f82 100644 --- a/tests/prometheus/kafka/test_kafka.py +++ b/tests/prometheus/kafka/test_kafka.py @@ -22,9 +22,10 @@ def get_middleware(self, **kwargs): async def test_metrics_batch( self, - event: asyncio.Event, queue: str, ): + event = asyncio.Event() + middleware = self.get_middleware(registry=CollectorRegistry()) metrics_manager_mock = Mock() middleware._metrics_manager = metrics_manager_mock diff --git a/tests/prometheus/nats/test_nats.py b/tests/prometheus/nats/test_nats.py index 4af2685a7d..117b696922 100644 --- a/tests/prometheus/nats/test_nats.py +++ b/tests/prometheus/nats/test_nats.py @@ -27,10 +27,11 @@ def get_middleware(self, **kwargs): async def test_metrics_batch( self, - event: asyncio.Event, queue: str, stream: JStream, ): + event = asyncio.Event() + middleware = self.get_middleware(registry=CollectorRegistry()) metrics_manager_mock = Mock() middleware._metrics_manager = metrics_manager_mock diff --git a/tests/prometheus/redis/test_redis.py b/tests/prometheus/redis/test_redis.py index ce7f81f49f..ee7f62cfd2 100644 --- a/tests/prometheus/redis/test_redis.py +++ b/tests/prometheus/redis/test_redis.py @@ -22,9 +22,10 @@ def get_middleware(self, **kwargs): async def test_metrics_batch( self, - event: asyncio.Event, queue: str, ): + event = asyncio.Event() + middleware = self.get_middleware(registry=CollectorRegistry()) metrics_manager_mock = Mock() middleware._metrics_manager = metrics_manager_mock diff --git a/tests/utils/context/test_path.py b/tests/utils/context/test_path.py index f4bb59ed5d..ff135cb1a3 100644 --- a/tests/utils/context/test_path.py +++ b/tests/utils/context/test_path.py @@ -70,9 +70,10 @@ async def h( @require_nats async def test_nats_kv_path( queue: str, - event: asyncio.Event, mock: Mock, ) -> None: + event = asyncio.Event() + from faststream.nats import NatsBroker broker = NatsBroker() From 736baba25ba3bdce4ef264c590f9d7e86ba5fa80 Mon Sep 17 00:00:00 2001 From: Nikita Pastukhov Date: Wed, 13 Nov 2024 19:45:50 +0300 Subject: [PATCH 48/48] feat: lazy decoder --- CITATION.cff | 2 +- examples/e10_middlewares.py | 2 +- faststream/_internal/application.py | 3 +- faststream/_internal/broker/abc_broker.py | 7 +- faststream/_internal/broker/broker.py | 7 +- faststream/_internal/cli/docs/app.py | 4 +- faststream/_internal/cli/main.py | 2 +- faststream/_internal/state/broker.py | 20 +- faststream/_internal/subscriber/call_item.py | 5 +- .../_internal/subscriber/call_wrapper/call.py | 6 +- faststream/_internal/subscriber/mixins.py | 5 +- faststream/_internal/subscriber/utils.py | 2 +- faststream/_internal/types.py | 9 +- faststream/confluent/subscriber/factory.py | 20 +- faststream/kafka/subscriber/factory.py | 68 ++-- faststream/message/message.py | 29 +- faststream/nats/annotations.py | 4 +- faststream/nats/subscriber/factory.py | 2 +- faststream/nats/subscriber/specified.py | 2 +- .../nats/subscriber/usecases/__init__.py | 26 ++ faststream/nats/subscriber/usecases/basic.py | 247 +++++++++++++++ .../subscriber/usecases/core_subscriber.py | 186 +++++++++++ .../usecases/key_value_subscriber.py | 188 +++++++++++ .../usecases/object_storage_subscriber.py | 192 +++++++++++ .../nats/subscriber/usecases/stream_basic.py | 142 +++++++++ .../usecases/stream_pull_subscriber.py | 298 ++++++++++++++++++ .../usecases/stream_push_subscriber.py | 106 +++++++ faststream/nats/testing.py | 2 +- faststream/opentelemetry/baggage.py | 3 +- faststream/rabbit/subscriber/factory.py | 6 + faststream/redis/message.py | 14 +- faststream/redis/opentelemetry/provider.py | 3 +- faststream/redis/prometheus/provider.py | 5 +- tests/brokers/base/fastapi.py | 8 +- tests/brokers/confluent/test_requests.py | 2 +- tests/brokers/kafka/test_requests.py | 2 +- tests/brokers/nats/test_consume.py | 8 +- tests/brokers/nats/test_requests.py | 2 +- tests/brokers/rabbit/test_requests.py | 2 +- tests/brokers/redis/test_requests.py | 2 +- tests/prometheus/redis/test_provider.py | 55 +++- 41 files changed, 1587 insertions(+), 111 deletions(-) create mode 100644 faststream/nats/subscriber/usecases/__init__.py create mode 100644 faststream/nats/subscriber/usecases/basic.py create mode 100644 faststream/nats/subscriber/usecases/core_subscriber.py create mode 100644 faststream/nats/subscriber/usecases/key_value_subscriber.py create mode 100644 faststream/nats/subscriber/usecases/object_storage_subscriber.py create mode 100644 faststream/nats/subscriber/usecases/stream_basic.py create mode 100644 faststream/nats/subscriber/usecases/stream_pull_subscriber.py create mode 100644 faststream/nats/subscriber/usecases/stream_push_subscriber.py diff --git a/CITATION.cff b/CITATION.cff index cfc7b23a6a..9e01da744e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -10,7 +10,7 @@ type: software authors: - given-names: Nikita family-names: Pastukhov - email: diementros@yandex.com + email: nikita@pastukhov-dev.ru - given-names: Davor family-names: Runje email: davor@airt.ai diff --git a/examples/e10_middlewares.py b/examples/e10_middlewares.py index 03a0519d79..31a2a257c9 100644 --- a/examples/e10_middlewares.py +++ b/examples/e10_middlewares.py @@ -25,7 +25,7 @@ async def subscriber_middleware( msg: RabbitMessage, ) -> Any: print(f"call handler middleware with body: {msg}") - msg._decoded_body = "fake message" + msg.body = b"fake message" result = await call_next(msg) print("handler middleware out") return result diff --git a/faststream/_internal/application.py b/faststream/_internal/application.py index d74f1c4a76..85e0ba43d0 100644 --- a/faststream/_internal/application.py +++ b/faststream/_internal/application.py @@ -17,6 +17,7 @@ from faststream._internal.context import ContextRepo from faststream._internal.log import logger from faststream._internal.state import DIState +from faststream._internal.state.broker import OuterBrokerState from faststream._internal.utils import apply_types from faststream._internal.utils.functions import ( drop_response_type, @@ -112,7 +113,7 @@ def _init_setupable_( # noqa: PLW3201 self._setup() def _setup(self) -> None: - self.broker._setup(di_state=self._state) + self.broker._setup(OuterBrokerState(di_state=self._state)) async def _start_broker(self) -> None: await self.broker.start() diff --git a/faststream/_internal/broker/abc_broker.py b/faststream/_internal/broker/abc_broker.py index 0fc795231e..f92b8c2358 100644 --- a/faststream/_internal/broker/abc_broker.py +++ b/faststream/_internal/broker/abc_broker.py @@ -90,8 +90,9 @@ def setup_publisher( """Setup the Publisher to prepare it to starting.""" publisher._setup(**kwargs, state=self._state) - def _setup(self, state: "Pointer[BrokerState]") -> None: - self._state.set(state) + def _setup(self, state: Optional["BrokerState"]) -> None: + if state is not None: + self._state.set(state) def include_router( self, @@ -103,7 +104,7 @@ def include_router( include_in_schema: Optional[bool] = None, ) -> None: """Includes a router in the current object.""" - router._setup(self._state) + router._setup(self._state.get()) for h in router._subscribers: h.add_prefix(f"{self.prefix}{prefix}") diff --git a/faststream/_internal/broker/broker.py b/faststream/_internal/broker/broker.py index 9914040cb3..b3621d34ee 100644 --- a/faststream/_internal/broker/broker.py +++ b/faststream/_internal/broker/broker.py @@ -22,6 +22,7 @@ SetupAble, ) from faststream._internal.state.broker import ( + BrokerState, InitialBrokerState, ) from faststream._internal.state.producer import ProducerUnset @@ -240,7 +241,7 @@ async def _connect(self) -> ConnectionType: """Connect to a resource.""" raise NotImplementedError - def _setup(self, di_state: Optional[DIState] = None) -> None: + def _setup(self, state: Optional["BrokerState"] = None) -> None: """Prepare all Broker entities to startup. Method should be idempotent due could be called twice @@ -249,7 +250,9 @@ def _setup(self, di_state: Optional[DIState] = None) -> None: current_di_state = broker_state.di_state broker_serializer = current_di_state.serializer - if di_state is not None: + if state is not None: + di_state = state.di_state + if broker_serializer is EMPTY: broker_serializer = di_state.serializer diff --git a/faststream/_internal/cli/docs/app.py b/faststream/_internal/cli/docs/app.py index 3e56a99536..d85f53de9c 100644 --- a/faststream/_internal/cli/docs/app.py +++ b/faststream/_internal/cli/docs/app.py @@ -134,7 +134,7 @@ def gen( _, asyncapi_obj = import_from_string(asyncapi, is_factory=is_factory) - assert isinstance(asyncapi_obj, Specification) + assert isinstance(asyncapi_obj, Specification) # nosec B101 raw_schema = asyncapi_obj.schema @@ -169,7 +169,7 @@ def _parse_and_serve( if ":" in docs: _, docs_obj = import_from_string(docs, is_factory=is_factory) - assert isinstance(docs_obj, Specification) + assert isinstance(docs_obj, Specification) # nosec B101 raw_schema = docs_obj diff --git a/faststream/_internal/cli/main.py b/faststream/_internal/cli/main.py index 48b113c8c3..95fb8037c6 100644 --- a/faststream/_internal/cli/main.py +++ b/faststream/_internal/cli/main.py @@ -262,7 +262,7 @@ def publish( try: _, app_obj = import_from_string(app, is_factory=is_factory) - assert isinstance(app_obj, FastStream), app_obj + assert isinstance(app_obj, FastStream), app_obj # nosec B101 if not app_obj.broker: msg = "Broker instance not found in the app." diff --git a/faststream/_internal/state/broker.py b/faststream/_internal/state/broker.py index 920daeedec..374b0e8c3b 100644 --- a/faststream/_internal/state/broker.py +++ b/faststream/_internal/state/broker.py @@ -26,15 +26,11 @@ def _setup_logger_state(self) -> None: ... def __bool__(self) -> bool: ... -class EmptyBrokerState(BrokerState): +class _EmptyBrokerState(BrokerState): def __init__(self, error_msg: str) -> None: self.error_msg = error_msg self.producer = ProducerUnset() - @property - def di_state(self) -> "DIState": - raise IncorrectState(self.error_msg) - @property def logger_state(self) -> "DIState": raise IncorrectState(self.error_msg) @@ -53,6 +49,20 @@ def __bool__(self) -> bool: return False +class EmptyBrokerState(_EmptyBrokerState): + @property + def di_state(self) -> "DIState": + raise IncorrectState(self.error_msg) + + +class OuterBrokerState(_EmptyBrokerState): + def __init__(self, *, di_state: "DIState") -> None: + self.di_state = di_state + + def __bool__(self) -> bool: + return True + + class InitialBrokerState(BrokerState): def __init__( self, diff --git a/faststream/_internal/subscriber/call_item.py b/faststream/_internal/subscriber/call_item.py index ddcddacd01..48814e9ea0 100644 --- a/faststream/_internal/subscriber/call_item.py +++ b/faststream/_internal/subscriber/call_item.py @@ -136,9 +136,8 @@ async def is_suitable( cache.get(parser) or await parser(msg), ) - message._decoded_body = cache[decoder] = cache.get(decoder) or await decoder( - message, - ) + # NOTE: final decoder will be set for success filter + message.set_decoder(decoder) if await self.filter(message): return message diff --git a/faststream/_internal/subscriber/call_wrapper/call.py b/faststream/_internal/subscriber/call_wrapper/call.py index e7ad845024..14d081b52f 100644 --- a/faststream/_internal/subscriber/call_wrapper/call.py +++ b/faststream/_internal/subscriber/call_wrapper/call.py @@ -85,7 +85,7 @@ def __call__( """Calls the object as a function.""" return self._original_call(*args, **kwargs) - def call_wrapped( + async def call_wrapped( self, message: "StreamMessage[MsgType]", ) -> Awaitable[Any]: @@ -93,8 +93,8 @@ def call_wrapped( assert self._wrapped_call, "You should use `set_wrapped` first" # nosec B101 if self.is_test: assert self.mock # nosec B101 - self.mock(message._decoded_body) - return self._wrapped_call(message) + self.mock(await message.decode()) + return await self._wrapped_call(message) async def wait_call(self, timeout: Optional[float] = None) -> None: """Waits for a call with an optional timeout.""" diff --git a/faststream/_internal/subscriber/mixins.py b/faststream/_internal/subscriber/mixins.py index 27c8f60f47..412f8f2c79 100644 --- a/faststream/_internal/subscriber/mixins.py +++ b/faststream/_internal/subscriber/mixins.py @@ -63,10 +63,7 @@ async def _serve_consume_queue( async for msg in self.receive_stream: tg.start_soon(self._consume_msg, msg) - async def _consume_msg( - self, - msg: "Msg", - ) -> None: + async def _consume_msg(self, msg: "Msg") -> None: """Proxy method to call `self.consume` with semaphore block.""" async with self.limiter: await self.consume(msg) diff --git a/faststream/_internal/subscriber/utils.py b/faststream/_internal/subscriber/utils.py index 5f3d6719c2..213a52c414 100644 --- a/faststream/_internal/subscriber/utils.py +++ b/faststream/_internal/subscriber/utils.py @@ -73,7 +73,7 @@ async def process_msg( parsed_msg = await parser(msg) parsed_msg._source_type = source_type - parsed_msg._decoded_body = await decoder(parsed_msg) + parsed_msg.set_decoder(decoder) return await return_msg(parsed_msg) msg = "unreachable" diff --git a/faststream/_internal/types.py b/faststream/_internal/types.py index fad8c62f95..ea1ecd3dbf 100644 --- a/faststream/_internal/types.py +++ b/faststream/_internal/types.py @@ -32,14 +32,11 @@ [Any], Any, ] -AsyncCallable: TypeAlias = Callable[ - [Any], - Awaitable[Any], -] +AsyncCallable: TypeAlias = AsyncFuncAny AsyncCustomCallable: TypeAlias = Union[ - AsyncCallable, + AsyncFuncAny, Callable[ - [Any, AsyncCallable], + [Any, AsyncFuncAny], Awaitable[Any], ], ] diff --git a/faststream/confluent/subscriber/factory.py b/faststream/confluent/subscriber/factory.py index ed6ae75690..b6edea0456 100644 --- a/faststream/confluent/subscriber/factory.py +++ b/faststream/confluent/subscriber/factory.py @@ -123,12 +123,7 @@ def create_subscriber( "SpecificationDefaultSubscriber", "SpecificationBatchSubscriber", ]: - if ack_policy is not EMPTY and not is_manual: - warnings.warn( - "You can't use acknowledgement policy with `is_manual=False` subscriber", - RuntimeWarning, - stacklevel=3, - ) + _validate_input_for_misconfigure(ack_policy=ack_policy, is_manual=is_manual) if ack_policy is EMPTY: ack_policy = AckPolicy.REJECT_ON_ERROR @@ -165,3 +160,16 @@ def create_subscriber( description_=description_, include_in_schema=include_in_schema, ) + + +def _validate_input_for_misconfigure( + *, + ack_policy: "AckPolicy", + is_manual: bool, +) -> None: + if ack_policy is not EMPTY and not is_manual: + warnings.warn( + "You can't use acknowledgement policy with `is_manual=False` subscriber", + RuntimeWarning, + stacklevel=4, + ) diff --git a/faststream/kafka/subscriber/factory.py b/faststream/kafka/subscriber/factory.py index d623fa4f7a..bb559873cc 100644 --- a/faststream/kafka/subscriber/factory.py +++ b/faststream/kafka/subscriber/factory.py @@ -132,31 +132,14 @@ def create_subscriber( "SpecificationDefaultSubscriber", "SpecificationBatchSubscriber", ]: - if ack_policy is not EMPTY and not is_manual: - warnings.warn( - "You can't use acknowledgement policy with `is_manual=False` subscriber", - RuntimeWarning, - stacklevel=3, - ) - - if is_manual and not group_id: - msg = "You must use `group_id` with manual commit mode." - raise SetupError(msg) - - if not topics and not partitions and not pattern: - msg = "You should provide either `topics` or `partitions` or `pattern`." - raise SetupError( - msg, - ) - if topics and partitions: - msg = "You can't provide both `topics` and `partitions`." - raise SetupError(msg) - if topics and pattern: - msg = "You can't provide both `topics` and `pattern`." - raise SetupError(msg) - if partitions and pattern: - msg = "You can't provide both `partitions` and `pattern`." - raise SetupError(msg) + _validate_input_for_misconfigure( + *topics, + pattern=pattern, + partitions=partitions, + ack_policy=ack_policy, + is_manual=is_manual, + group_id=group_id, + ) if ack_policy is EMPTY: ack_policy = AckPolicy.REJECT_ON_ERROR @@ -197,3 +180,38 @@ def create_subscriber( description_=description_, include_in_schema=include_in_schema, ) + + +def _validate_input_for_misconfigure( + *topics: str, + partitions: Iterable["TopicPartition"], + pattern: Optional[str], + ack_policy: "AckPolicy", + is_manual: bool, + group_id: Optional[str], +) -> None: + if ack_policy is not EMPTY and not is_manual: + warnings.warn( + "You can't use acknowledgement policy with `is_manual=False` subscriber", + RuntimeWarning, + stacklevel=4, + ) + + if is_manual and not group_id: + msg = "You must use `group_id` with manual commit mode." + raise SetupError(msg) + + if not topics and not partitions and not pattern: + msg = "You should provide either `topics` or `partitions` or `pattern`." + raise SetupError( + msg, + ) + if topics and partitions: + msg = "You can't provide both `topics` and `partitions`." + raise SetupError(msg) + if topics and pattern: + msg = "You can't provide both `topics` and `pattern`." + raise SetupError(msg) + if partitions and pattern: + msg = "You can't provide both `partitions` and `pattern`." + raise SetupError(msg) diff --git a/faststream/message/message.py b/faststream/message/message.py index cdba65b5bb..a7db6f895d 100644 --- a/faststream/message/message.py +++ b/faststream/message/message.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from faststream._internal.basic_types import AnyDict, DecodedMessage + from faststream._internal.types import AsyncCallable # prevent circular imports MsgType = TypeVar("MsgType") @@ -53,11 +54,21 @@ def __init__( self.correlation_id = correlation_id or str(uuid4()) self.message_id = message_id or self.correlation_id - # Setup later - self._decoded_body: Optional[DecodedMessage] = None self.committed: Optional[AckStatus] = None self.processed = False + # Setup later + self.__decoder: Optional[AsyncCallable] = None + self.__decoded_caches: dict[ + Any, Any + ] = {} # Cache values between filters and tests + + def set_decoder(self, decoder: "AsyncCallable") -> None: + self.__decoder = decoder + + def clear_cache(self) -> None: + self.__decoded_caches.clear() + def __repr__(self) -> str: inner = ", ".join( filter( @@ -79,9 +90,17 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({inner})" async def decode(self) -> Optional["DecodedMessage"]: - """Serialize the message by lazy decoder.""" - # TODO: make it lazy after `decoded_body` removed - return self._decoded_body + """Serialize the message by lazy decoder. + + Returns a cache after first usage. To prevent such behavior, please call + `message.clear_cache()` after `message.body` changes. + """ + assert self.__decoder, "You should call `set_decoder()` method first." # nosec B101 + + if (result := self.__decoded_caches.get(self.__decoder)) is None: + result = self.__decoded_caches[self.__decoder] = await self.__decoder(self) + + return result async def ack(self) -> None: if self.committed is None: diff --git a/faststream/nats/annotations.py b/faststream/nats/annotations.py index 5aa74aa8d0..aead2fe490 100644 --- a/faststream/nats/annotations.py +++ b/faststream/nats/annotations.py @@ -8,7 +8,9 @@ from faststream.annotations import ContextRepo, Logger from faststream.nats.broker import NatsBroker as _Broker from faststream.nats.message import NatsMessage as _Message -from faststream.nats.subscriber.usecase import OBJECT_STORAGE_CONTEXT_KEY +from faststream.nats.subscriber.usecases.object_storage_subscriber import ( + OBJECT_STORAGE_CONTEXT_KEY, +) __all__ = ( "Client", diff --git a/faststream/nats/subscriber/factory.py b/faststream/nats/subscriber/factory.py index 35062e4eed..c17e6e808d 100644 --- a/faststream/nats/subscriber/factory.py +++ b/faststream/nats/subscriber/factory.py @@ -349,7 +349,7 @@ def _validate_input_for_misconfigure( # noqa: PLR0915 raise SetupError(msg) if pull_sub and not stream: - msg = "The pull subscriber can only be used with the `stream` option." + msg = "JetStream Pull Subscriber can only be used with the `stream` option." raise SetupError(msg) if ack_policy is not EMPTY: diff --git a/faststream/nats/subscriber/specified.py b/faststream/nats/subscriber/specified.py index 8862f47ab3..2c3387ded3 100644 --- a/faststream/nats/subscriber/specified.py +++ b/faststream/nats/subscriber/specified.py @@ -2,7 +2,7 @@ from typing_extensions import override -from faststream.nats.subscriber.usecase import ( +from faststream.nats.subscriber.usecases import ( BatchPullStreamSubscriber, ConcurrentCoreSubscriber, ConcurrentPullStreamSubscriber, diff --git a/faststream/nats/subscriber/usecases/__init__.py b/faststream/nats/subscriber/usecases/__init__.py new file mode 100644 index 0000000000..040a9f9680 --- /dev/null +++ b/faststream/nats/subscriber/usecases/__init__.py @@ -0,0 +1,26 @@ +from .basic import LogicSubscriber +from .core_subscriber import ConcurrentCoreSubscriber, CoreSubscriber +from .key_value_subscriber import KeyValueWatchSubscriber +from .object_storage_subscriber import ObjStoreWatchSubscriber +from .stream_pull_subscriber import ( + BatchPullStreamSubscriber, + ConcurrentPullStreamSubscriber, + PullStreamSubscriber, +) +from .stream_push_subscriber import ( + ConcurrentPushStreamSubscriber, + PushStreamSubscription, +) + +__all__ = ( + "BatchPullStreamSubscriber", + "ConcurrentCoreSubscriber", + "ConcurrentPullStreamSubscriber", + "ConcurrentPushStreamSubscriber", + "CoreSubscriber", + "KeyValueWatchSubscriber", + "LogicSubscriber", + "ObjStoreWatchSubscriber", + "PullStreamSubscriber", + "PushStreamSubscription", +) diff --git a/faststream/nats/subscriber/usecases/basic.py b/faststream/nats/subscriber/usecases/basic.py new file mode 100644 index 0000000000..3b5f30e1fc --- /dev/null +++ b/faststream/nats/subscriber/usecases/basic.py @@ -0,0 +1,247 @@ +from abc import abstractmethod +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Optional, +) + +from typing_extensions import override + +from faststream._internal.subscriber.usecase import SubscriberUsecase +from faststream._internal.types import MsgType +from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer +from faststream.nats.publisher.fake import NatsFakePublisher +from faststream.nats.schemas.js_stream import compile_nats_wildcard +from faststream.nats.subscriber.adapters import ( + Unsubscriptable, +) +from faststream.nats.subscriber.state import ( + ConnectedSubscriberState, + EmptySubscriberState, + SubscriberState, +) + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + from nats.js.api import ConsumerConfig + + from faststream._internal.basic_types import ( + AnyDict, + ) + from faststream._internal.publisher.proto import BasePublisherProto, ProducerProto + from faststream._internal.state import ( + BrokerState as BasicState, + Pointer, + ) + from faststream._internal.types import ( + AsyncCallable, + BrokerMiddleware, + CustomCallable, + ) + from faststream.message import StreamMessage + from faststream.middlewares import AckPolicy + from faststream.nats.broker.state import BrokerState + from faststream.nats.helpers import KVBucketDeclarer, OSBucketDeclarer + + +class LogicSubscriber(SubscriberUsecase[MsgType], Generic[MsgType]): + """Basic class for all NATS Subscriber types (KeyValue, ObjectStorage, Core & JetStream).""" + + subscription: Optional[Unsubscriptable] + _fetch_sub: Optional[Unsubscriptable] + producer: Optional["ProducerProto"] + + def __init__( + self, + *, + subject: str, + config: "ConsumerConfig", + extra_options: Optional["AnyDict"], + # Subscriber args + default_parser: "AsyncCallable", + default_decoder: "AsyncCallable", + ack_policy: "AckPolicy", + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + self.subject = subject + self.config = config + + self.extra_options = extra_options or {} + + super().__init__( + default_parser=default_parser, + default_decoder=default_decoder, + # Propagated args + ack_policy=ack_policy, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) + + self._fetch_sub = None + self.subscription = None + self.producer = None + + self._connection_state: SubscriberState = EmptySubscriberState() + + @override + def _setup( # type: ignore[override] + self, + *, + connection_state: "BrokerState", + os_declarer: "OSBucketDeclarer", + kv_declarer: "KVBucketDeclarer", + # basic args + extra_context: "AnyDict", + # broker options + broker_parser: Optional["CustomCallable"], + broker_decoder: Optional["CustomCallable"], + # dependant args + state: "Pointer[BasicState]", + ) -> None: + self._connection_state = ConnectedSubscriberState( + parent_state=connection_state, + os_declarer=os_declarer, + kv_declarer=kv_declarer, + ) + + super()._setup( + extra_context=extra_context, + broker_parser=broker_parser, + broker_decoder=broker_decoder, + state=state, + ) + + @property + def clear_subject(self) -> str: + """Compile `test.{name}` to `test.*` subject.""" + _, path = compile_nats_wildcard(self.subject) + return path + + async def start(self) -> None: + """Create NATS subscription and start consume tasks.""" + await super().start() + + if self.calls: + await self._create_subscription() + + async def close(self) -> None: + """Clean up handler subscription, cancel consume task in graceful mode.""" + await super().close() + + if self.subscription is not None: + await self.subscription.unsubscribe() + self.subscription = None + + if self._fetch_sub is not None: + await self._fetch_sub.unsubscribe() + self.subscription = None + + @abstractmethod + async def _create_subscription(self) -> None: + """Create NATS subscription object to consume messages.""" + raise NotImplementedError + + @staticmethod + def build_log_context( + message: Optional["StreamMessage[MsgType]"], + subject: str, + *, + queue: str = "", + stream: str = "", + ) -> dict[str, str]: + """Static method to build log context out of `self.consume` scope.""" + return { + "subject": subject, + "queue": queue, + "stream": stream, + "message_id": getattr(message, "message_id", ""), + } + + def add_prefix(self, prefix: str) -> None: + """Include Subscriber in router.""" + if self.subject: + self.subject = f"{prefix}{self.subject}" + else: + self.config.filter_subjects = [ + f"{prefix}{subject}" for subject in (self.config.filter_subjects or ()) + ] + + @property + def _resolved_subject_string(self) -> str: + return self.subject or ", ".join(self.config.filter_subjects or ()) + + +class DefaultSubscriber(LogicSubscriber[MsgType]): + """Basic class for Core & JetStream Subscribers.""" + + def __init__( + self, + *, + subject: str, + config: "ConsumerConfig", + # default args + extra_options: Optional["AnyDict"], + # Subscriber args + default_parser: "AsyncCallable", + default_decoder: "AsyncCallable", + ack_policy: "AckPolicy", + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[MsgType]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + super().__init__( + subject=subject, + config=config, + extra_options=extra_options, + # subscriber args + default_parser=default_parser, + default_decoder=default_decoder, + # Propagated args + ack_policy=ack_policy, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + def _make_response_publisher( + self, + message: "StreamMessage[Any]", + ) -> Iterable["BasePublisherProto"]: + """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" + return ( + NatsFakePublisher( + producer=self._state.get().producer, + subject=message.reply_to, + ), + ) + + def get_log_context( + self, + message: Optional["StreamMessage[MsgType]"], + ) -> dict[str, str]: + """Log context factory using in `self.consume` scope.""" + return self.build_log_context( + message=message, + subject=self.subject, + ) diff --git a/faststream/nats/subscriber/usecases/core_subscriber.py b/faststream/nats/subscriber/usecases/core_subscriber.py new file mode 100644 index 0000000000..3cff6547d2 --- /dev/null +++ b/faststream/nats/subscriber/usecases/core_subscriber.py @@ -0,0 +1,186 @@ +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, + Annotated, + Optional, +) + +from nats.errors import TimeoutError +from typing_extensions import Doc, override + +from faststream._internal.subscriber.mixins import ConcurrentMixin +from faststream._internal.subscriber.utils import process_msg +from faststream.middlewares import AckPolicy +from faststream.nats.parser import NatsParser + +from .basic import DefaultSubscriber + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + from nats.aio.msg import Msg + from nats.aio.subscription import Subscription + from nats.js.api import ConsumerConfig + + from faststream._internal.basic_types import AnyDict + from faststream._internal.types import BrokerMiddleware + from faststream.message import StreamMessage + from faststream.nats.message import NatsMessage + + +class CoreSubscriber(DefaultSubscriber["Msg"]): + subscription: Optional["Subscription"] + _fetch_sub: Optional["Subscription"] + + def __init__( + self, + *, + # default args + subject: str, + config: "ConsumerConfig", + queue: str, + extra_options: Optional["AnyDict"], + # Subscriber args + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[Msg]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + parser_ = NatsParser(pattern=subject) + + self.queue = queue + + super().__init__( + subject=subject, + config=config, + extra_options=extra_options, + # subscriber args + default_parser=parser_.parse_message, + default_decoder=parser_.decode_message, + # Propagated args + ack_policy=AckPolicy.DO_NOTHING, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def get_one( + self, + *, + timeout: float = 5.0, + ) -> "Optional[NatsMessage]": + assert ( # nosec B101 + not self.calls + ), "You can't use `get_one` method if subscriber has registered handlers." + + if self._fetch_sub is None: + fetch_sub = self._fetch_sub = await self._connection_state.client.subscribe( + subject=self.clear_subject, + queue=self.queue, + **self.extra_options, + ) + else: + fetch_sub = self._fetch_sub + + try: + raw_message = await fetch_sub.next_msg(timeout=timeout) + except TimeoutError: + return None + + context = self._state.get().di_state.context + + msg: NatsMessage = await process_msg( # type: ignore[assignment] + msg=raw_message, + middlewares=( + m(raw_message, context=context) for m in self._broker_middlewares + ), + parser=self._parser, + decoder=self._decoder, + ) + return msg + + @override + async def _create_subscription(self) -> None: + """Create NATS subscription and start consume task.""" + if self.subscription: + return + + self.subscription = await self._connection_state.client.subscribe( + subject=self.clear_subject, + queue=self.queue, + cb=self.consume, + **self.extra_options, + ) + + def get_log_context( + self, + message: Annotated[ + Optional["StreamMessage[Msg]"], + Doc("Message which we are building context for"), + ], + ) -> dict[str, str]: + """Log context factory using in `self.consume` scope.""" + return self.build_log_context( + message=message, + subject=self.subject, + queue=self.queue, + ) + + +class ConcurrentCoreSubscriber(ConcurrentMixin, CoreSubscriber): + def __init__( + self, + *, + max_workers: int, + # default args + subject: str, + config: "ConsumerConfig", + queue: str, + extra_options: Optional["AnyDict"], + # Subscriber args + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[Msg]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + super().__init__( + max_workers=max_workers, + # basic args + subject=subject, + config=config, + queue=queue, + extra_options=extra_options, + # Propagated args + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def _create_subscription(self) -> None: + """Create NATS subscription and start consume task.""" + if self.subscription: + return + + self.start_consume_task() + + self.subscription = await self._connection_state.client.subscribe( + subject=self.clear_subject, + queue=self.queue, + cb=self._put_msg, + **self.extra_options, + ) diff --git a/faststream/nats/subscriber/usecases/key_value_subscriber.py b/faststream/nats/subscriber/usecases/key_value_subscriber.py new file mode 100644 index 0000000000..cf4a2a3f4e --- /dev/null +++ b/faststream/nats/subscriber/usecases/key_value_subscriber.py @@ -0,0 +1,188 @@ +from collections.abc import Iterable +from contextlib import suppress +from typing import ( + TYPE_CHECKING, + Annotated, + Optional, + cast, +) + +import anyio +from nats.errors import ConnectionClosedError, TimeoutError +from typing_extensions import Doc, override + +from faststream._internal.subscriber.mixins import TasksMixin +from faststream._internal.subscriber.utils import process_msg +from faststream.middlewares import AckPolicy +from faststream.nats.parser import ( + KvParser, +) +from faststream.nats.subscriber.adapters import ( + UnsubscribeAdapter, +) + +from .basic import LogicSubscriber + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + from nats.js.api import ConsumerConfig + from nats.js.kv import KeyValue + + from faststream._internal.publisher.proto import BasePublisherProto + from faststream._internal.types import ( + BrokerMiddleware, + ) + from faststream.message import StreamMessage + from faststream.nats.message import NatsKvMessage + from faststream.nats.schemas import KvWatch + + +class KeyValueWatchSubscriber( + TasksMixin, + LogicSubscriber["KeyValue.Entry"], +): + subscription: Optional["UnsubscribeAdapter[KeyValue.KeyWatcher]"] + _fetch_sub: Optional[UnsubscribeAdapter["KeyValue.KeyWatcher"]] + + def __init__( + self, + *, + subject: str, + config: "ConsumerConfig", + kv_watch: "KvWatch", + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[KeyValue.Entry]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + parser = KvParser(pattern=subject) + self.kv_watch = kv_watch + + super().__init__( + subject=subject, + config=config, + extra_options=None, + ack_policy=AckPolicy.DO_NOTHING, + no_reply=True, + default_parser=parser.parse_message, + default_decoder=parser.decode_message, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def get_one( + self, + *, + timeout: float = 5, + ) -> Optional["NatsKvMessage"]: + assert ( # nosec B101 + not self.calls + ), "You can't use `get_one` method if subscriber has registered handlers." + + if not self._fetch_sub: + bucket = await self._connection_state.kv_declarer.create_key_value( + bucket=self.kv_watch.name, + declare=self.kv_watch.declare, + ) + + fetch_sub = self._fetch_sub = UnsubscribeAdapter["KeyValue.KeyWatcher"]( + await bucket.watch( + keys=self.clear_subject, + headers_only=self.kv_watch.headers_only, + include_history=self.kv_watch.include_history, + ignore_deletes=self.kv_watch.ignore_deletes, + meta_only=self.kv_watch.meta_only, + ), + ) + else: + fetch_sub = self._fetch_sub + + raw_message = None + sleep_interval = timeout / 10 + with anyio.move_on_after(timeout): + while ( # noqa: ASYNC110 + # type: ignore[no-untyped-call] + raw_message := await fetch_sub.obj.updates(timeout) + ) is None: + await anyio.sleep(sleep_interval) + + context = self._state.get().di_state.context + + msg: NatsKvMessage = await process_msg( + msg=raw_message, + middlewares=( + m(raw_message, context=context) for m in self._broker_middlewares + ), + parser=self._parser, + decoder=self._decoder, + ) + return msg + + @override + async def _create_subscription(self) -> None: + if self.subscription: + return + + bucket = await self._connection_state.kv_declarer.create_key_value( + bucket=self.kv_watch.name, + declare=self.kv_watch.declare, + ) + + self.subscription = UnsubscribeAdapter["KeyValue.KeyWatcher"]( + await bucket.watch( + keys=self.clear_subject, + headers_only=self.kv_watch.headers_only, + include_history=self.kv_watch.include_history, + ignore_deletes=self.kv_watch.ignore_deletes, + meta_only=self.kv_watch.meta_only, + ), + ) + + self.add_task(self.__consume_watch()) + + async def __consume_watch(self) -> None: + assert self.subscription, "You should call `create_subscription` at first." # nosec B101 + + key_watcher = self.subscription.obj + + while self.running: + with suppress(ConnectionClosedError, TimeoutError): + message = cast( + Optional["KeyValue.Entry"], + # type: ignore[no-untyped-call] + await key_watcher.updates(self.kv_watch.timeout), + ) + + if message: + await self.consume(message) + + def _make_response_publisher( + self, + message: Annotated[ + "StreamMessage[KeyValue.Entry]", + Doc("Message requiring reply"), + ], + ) -> Iterable["BasePublisherProto"]: + """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" + return () + + def get_log_context( + self, + message: Annotated[ + Optional["StreamMessage[KeyValue.Entry]"], + Doc("Message which we are building context for"), + ], + ) -> dict[str, str]: + """Log context factory using in `self.consume` scope.""" + return self.build_log_context( + message=message, + subject=self.subject, + stream=self.kv_watch.name, + ) diff --git a/faststream/nats/subscriber/usecases/object_storage_subscriber.py b/faststream/nats/subscriber/usecases/object_storage_subscriber.py new file mode 100644 index 0000000000..a1d5bace48 --- /dev/null +++ b/faststream/nats/subscriber/usecases/object_storage_subscriber.py @@ -0,0 +1,192 @@ +from collections.abc import Iterable +from contextlib import suppress +from typing import ( + TYPE_CHECKING, + Annotated, + Optional, + cast, +) + +import anyio +from nats.errors import TimeoutError +from nats.js.api import ConsumerConfig, ObjectInfo +from typing_extensions import Doc, override + +from faststream._internal.subscriber.mixins import TasksMixin +from faststream._internal.subscriber.utils import process_msg +from faststream.middlewares import AckPolicy +from faststream.nats.parser import ( + ObjParser, +) +from faststream.nats.subscriber.adapters import ( + UnsubscribeAdapter, +) + +from .basic import LogicSubscriber + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + from nats.aio.msg import Msg + from nats.js.object_store import ObjectStore + + from faststream._internal.publisher.proto import BasePublisherProto + from faststream._internal.types import ( + BrokerMiddleware, + ) + from faststream.message import StreamMessage + from faststream.nats.message import NatsObjMessage + from faststream.nats.schemas import ObjWatch + + +OBJECT_STORAGE_CONTEXT_KEY = "__object_storage" + + +class ObjStoreWatchSubscriber( + TasksMixin, + LogicSubscriber[ObjectInfo], +): + subscription: Optional["UnsubscribeAdapter[ObjectStore.ObjectWatcher]"] + _fetch_sub: Optional[UnsubscribeAdapter["ObjectStore.ObjectWatcher"]] + + def __init__( + self, + *, + subject: str, + config: "ConsumerConfig", + obj_watch: "ObjWatch", + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[list[Msg]]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + parser = ObjParser(pattern="") + + self.obj_watch = obj_watch + self.obj_watch_conn = None + + super().__init__( + subject=subject, + config=config, + extra_options=None, + ack_policy=AckPolicy.DO_NOTHING, + no_reply=True, + default_parser=parser.parse_message, + default_decoder=parser.decode_message, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def get_one( + self, + *, + timeout: float = 5, + ) -> Optional["NatsObjMessage"]: + assert ( # nosec B101 + not self.calls + ), "You can't use `get_one` method if subscriber has registered handlers." + + if not self._fetch_sub: + self.bucket = await self._connection_state.os_declarer.create_object_store( + bucket=self.subject, + declare=self.obj_watch.declare, + ) + + obj_watch = await self.bucket.watch( + ignore_deletes=self.obj_watch.ignore_deletes, + include_history=self.obj_watch.include_history, + meta_only=self.obj_watch.meta_only, + ) + fetch_sub = self._fetch_sub = UnsubscribeAdapter[ + "ObjectStore.ObjectWatcher" + ](obj_watch) + else: + fetch_sub = self._fetch_sub + + raw_message = None + sleep_interval = timeout / 10 + with anyio.move_on_after(timeout): + while ( # noqa: ASYNC110 + # type: ignore[no-untyped-call] + raw_message := await fetch_sub.obj.updates(timeout) + ) is None: + await anyio.sleep(sleep_interval) + + context = self._state.get().di_state.context + + msg: NatsObjMessage = await process_msg( + msg=raw_message, + middlewares=( + m(raw_message, context=context) for m in self._broker_middlewares + ), + parser=self._parser, + decoder=self._decoder, + ) + return msg + + @override + async def _create_subscription(self) -> None: + if self.subscription: + return + + self.bucket = await self._connection_state.os_declarer.create_object_store( + bucket=self.subject, + declare=self.obj_watch.declare, + ) + + self.add_task(self.__consume_watch()) + + async def __consume_watch(self) -> None: + assert self.bucket, "You should call `create_subscription` at first." # nosec B101 + + # Should be created inside task to avoid nats-py lock + obj_watch = await self.bucket.watch( + ignore_deletes=self.obj_watch.ignore_deletes, + include_history=self.obj_watch.include_history, + meta_only=self.obj_watch.meta_only, + ) + + self.subscription = UnsubscribeAdapter["ObjectStore.ObjectWatcher"](obj_watch) + + context = self._state.get().di_state.context + + while self.running: + with suppress(TimeoutError): + message = cast( + Optional["ObjectInfo"], + # type: ignore[no-untyped-call] + await obj_watch.updates(self.obj_watch.timeout), + ) + + if message: + with context.scope(OBJECT_STORAGE_CONTEXT_KEY, self.bucket): + await self.consume(message) + + def _make_response_publisher( + self, + message: Annotated[ + "StreamMessage[ObjectInfo]", + Doc("Message requiring reply"), + ], + ) -> Iterable["BasePublisherProto"]: + """Create Publisher objects to use it as one of `publishers` in `self.consume` scope.""" + return () + + def get_log_context( + self, + message: Annotated[ + Optional["StreamMessage[ObjectInfo]"], + Doc("Message which we are building context for"), + ], + ) -> dict[str, str]: + """Log context factory using in `self.consume` scope.""" + return self.build_log_context( + message=message, + subject=self.subject, + ) diff --git a/faststream/nats/subscriber/usecases/stream_basic.py b/faststream/nats/subscriber/usecases/stream_basic.py new file mode 100644 index 0000000000..c053f2ce5e --- /dev/null +++ b/faststream/nats/subscriber/usecases/stream_basic.py @@ -0,0 +1,142 @@ +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, + Annotated, + Optional, +) + +from nats.errors import ConnectionClosedError, TimeoutError +from typing_extensions import Doc, override + +from faststream._internal.subscriber.utils import process_msg +from faststream.nats.parser import ( + JsParser, +) + +from .basic import DefaultSubscriber + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + from nats.aio.msg import Msg + from nats.js import JetStreamContext + from nats.js.api import ConsumerConfig + + from faststream._internal.basic_types import ( + AnyDict, + ) + from faststream._internal.types import ( + BrokerMiddleware, + ) + from faststream.message import StreamMessage + from faststream.middlewares import AckPolicy + from faststream.nats.message import NatsMessage + from faststream.nats.schemas import JStream + + +class StreamSubscriber(DefaultSubscriber["Msg"]): + _fetch_sub: Optional["JetStreamContext.PullSubscription"] + + def __init__( + self, + *, + stream: "JStream", + # default args + subject: str, + config: "ConsumerConfig", + queue: str, + extra_options: Optional["AnyDict"], + # Subscriber args + ack_policy: "AckPolicy", + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[Msg]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + parser_ = JsParser(pattern=subject) + + self.queue = queue + self.stream = stream + + super().__init__( + subject=subject, + config=config, + extra_options=extra_options, + # subscriber args + default_parser=parser_.parse_message, + default_decoder=parser_.decode_message, + # Propagated args + ack_policy=ack_policy, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + def get_log_context( + self, + message: Annotated[ + Optional["StreamMessage[Msg]"], + Doc("Message which we are building context for"), + ], + ) -> dict[str, str]: + """Log context factory using in `self.consume` scope.""" + return self.build_log_context( + message=message, + subject=self._resolved_subject_string, + queue=self.queue, + stream=self.stream.name, + ) + + @override + async def get_one( + self, + *, + timeout: float = 5, + ) -> Optional["NatsMessage"]: + assert ( # nosec B101 + not self.calls + ), "You can't use `get_one` method if subscriber has registered handlers." + + if not self._fetch_sub: + extra_options = { + "pending_bytes_limit": self.extra_options["pending_bytes_limit"], + "pending_msgs_limit": self.extra_options["pending_msgs_limit"], + "durable": self.extra_options["durable"], + "stream": self.extra_options["stream"], + } + if inbox_prefix := self.extra_options.get("inbox_prefix"): + extra_options["inbox_prefix"] = inbox_prefix + + self._fetch_sub = await self._connection_state.js.pull_subscribe( + subject=self.clear_subject, + config=self.config, + **extra_options, + ) + + try: + raw_message = ( + await self._fetch_sub.fetch( + batch=1, + timeout=timeout, + ) + )[0] + except (TimeoutError, ConnectionClosedError): + return None + + context = self._state.get().di_state.context + + msg: NatsMessage = await process_msg( # type: ignore[assignment] + msg=raw_message, + middlewares=( + m(raw_message, context=context) for m in self._broker_middlewares + ), + parser=self._parser, + decoder=self._decoder, + ) + return msg diff --git a/faststream/nats/subscriber/usecases/stream_pull_subscriber.py b/faststream/nats/subscriber/usecases/stream_pull_subscriber.py new file mode 100644 index 0000000000..44d82e89dd --- /dev/null +++ b/faststream/nats/subscriber/usecases/stream_pull_subscriber.py @@ -0,0 +1,298 @@ +from collections.abc import Awaitable, Iterable +from contextlib import suppress +from typing import ( + TYPE_CHECKING, + Callable, + Optional, + cast, +) + +import anyio +from nats.errors import ConnectionClosedError, TimeoutError +from typing_extensions import override + +from faststream._internal.subscriber.mixins import ConcurrentMixin, TasksMixin +from faststream._internal.subscriber.utils import process_msg +from faststream.nats.message import NatsMessage +from faststream.nats.parser import ( + BatchParser, +) + +from .basic import DefaultSubscriber +from .stream_basic import StreamSubscriber + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + from nats.aio.msg import Msg + from nats.js import JetStreamContext + from nats.js.api import ConsumerConfig + + from faststream._internal.basic_types import ( + AnyDict, + SendableMessage, + ) + from faststream._internal.types import ( + BrokerMiddleware, + ) + from faststream.middlewares import AckPolicy + from faststream.nats.schemas import JStream, PullSub + + +class PullStreamSubscriber( + TasksMixin, + StreamSubscriber, +): + subscription: Optional["JetStreamContext.PullSubscription"] + + def __init__( + self, + *, + pull_sub: "PullSub", + stream: "JStream", + # default args + subject: str, + config: "ConsumerConfig", + extra_options: Optional["AnyDict"], + # Subscriber args + ack_policy: "AckPolicy", + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[Msg]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + self.pull_sub = pull_sub + + super().__init__( + # basic args + stream=stream, + subject=subject, + config=config, + extra_options=extra_options, + queue="", + # Propagated args + ack_policy=ack_policy, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def _create_subscription(self) -> None: + """Create NATS subscription and start consume task.""" + if self.subscription: + return + + self.subscription = await self._connection_state.js.pull_subscribe( + subject=self.clear_subject, + config=self.config, + **self.extra_options, + ) + self.add_task(self._consume_pull(cb=self.consume)) + + async def _consume_pull( + self, + cb: Callable[["Msg"], Awaitable["SendableMessage"]], + ) -> None: + """Endless task consuming messages using NATS Pull subscriber.""" + assert self.subscription # nosec B101 + + while self.running: # pragma: no branch + messages = [] + with suppress(TimeoutError, ConnectionClosedError): + messages = await self.subscription.fetch( + batch=self.pull_sub.batch_size, + timeout=self.pull_sub.timeout, + ) + + if messages: + async with anyio.create_task_group() as tg: + for msg in messages: + tg.start_soon(cb, msg) + + +class ConcurrentPullStreamSubscriber( + ConcurrentMixin, + PullStreamSubscriber, +): + def __init__( + self, + *, + max_workers: int, + # default args + pull_sub: "PullSub", + stream: "JStream", + subject: str, + config: "ConsumerConfig", + extra_options: Optional["AnyDict"], + # Subscriber args + ack_policy: "AckPolicy", + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[Msg]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + super().__init__( + max_workers=max_workers, + # basic args + pull_sub=pull_sub, + stream=stream, + subject=subject, + config=config, + extra_options=extra_options, + # Propagated args + ack_policy=ack_policy, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def _create_subscription(self) -> None: + """Create NATS subscription and start consume task.""" + if self.subscription: + return + + self.start_consume_task() + + self.subscription = await self._connection_state.js.pull_subscribe( + subject=self.clear_subject, + config=self.config, + **self.extra_options, + ) + self.add_task(self._consume_pull(cb=self._put_msg)) + + +class BatchPullStreamSubscriber( + TasksMixin, + DefaultSubscriber[list["Msg"]], +): + """Batch-message consumer class.""" + + subscription: Optional["JetStreamContext.PullSubscription"] + _fetch_sub: Optional["JetStreamContext.PullSubscription"] + + def __init__( + self, + *, + # default args + subject: str, + config: "ConsumerConfig", + stream: "JStream", + pull_sub: "PullSub", + extra_options: Optional["AnyDict"], + # Subscriber args + ack_policy: "AckPolicy", + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[list[Msg]]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + parser = BatchParser(pattern=subject) + + self.stream = stream + self.pull_sub = pull_sub + + super().__init__( + subject=subject, + config=config, + extra_options=extra_options, + # subscriber args + default_parser=parser.parse_batch, + default_decoder=parser.decode_batch, + # Propagated args + ack_policy=ack_policy, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def get_one( + self, + *, + timeout: float = 5, + ) -> Optional["NatsMessage"]: + assert ( # nosec B101 + not self.calls + ), "You can't use `get_one` method if subscriber has registered handlers." + + if not self._fetch_sub: + fetch_sub = ( + self._fetch_sub + ) = await self._connection_state.js.pull_subscribe( + subject=self.clear_subject, + config=self.config, + **self.extra_options, + ) + else: + fetch_sub = self._fetch_sub + + try: + raw_message = await fetch_sub.fetch( + batch=1, + timeout=timeout, + ) + except TimeoutError: + return None + + context = self._state.get().di_state.context + + return cast( + NatsMessage, + await process_msg( + msg=raw_message, + middlewares=( + m(raw_message, context=context) for m in self._broker_middlewares + ), + parser=self._parser, + decoder=self._decoder, + ), + ) + + @override + async def _create_subscription(self) -> None: + """Create NATS subscription and start consume task.""" + if self.subscription: + return + + self.subscription = await self._connection_state.js.pull_subscribe( + subject=self.clear_subject, + config=self.config, + **self.extra_options, + ) + self.add_task(self._consume_pull()) + + async def _consume_pull(self) -> None: + """Endless task consuming messages using NATS Pull subscriber.""" + assert self.subscription, "You should call `create_subscription` at first." # nosec B101 + + while self.running: # pragma: no branch + with suppress(TimeoutError, ConnectionClosedError): + messages = await self.subscription.fetch( + batch=self.pull_sub.batch_size, + timeout=self.pull_sub.timeout, + ) + + if messages: + await self.consume(messages) diff --git a/faststream/nats/subscriber/usecases/stream_push_subscriber.py b/faststream/nats/subscriber/usecases/stream_push_subscriber.py new file mode 100644 index 0000000000..ac14ae3509 --- /dev/null +++ b/faststream/nats/subscriber/usecases/stream_push_subscriber.py @@ -0,0 +1,106 @@ +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, + Optional, +) + +from typing_extensions import override + +from faststream._internal.subscriber.mixins import ConcurrentMixin + +from .stream_basic import StreamSubscriber + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + from nats.aio.msg import Msg + from nats.js import JetStreamContext + from nats.js.api import ConsumerConfig + + from faststream._internal.basic_types import ( + AnyDict, + ) + from faststream._internal.types import ( + BrokerMiddleware, + ) + from faststream.middlewares import AckPolicy + from faststream.nats.schemas import JStream + + +class PushStreamSubscription(StreamSubscriber): + subscription: Optional["JetStreamContext.PushSubscription"] + + @override + async def _create_subscription(self) -> None: + """Create NATS subscription and start consume task.""" + if self.subscription: + return + + self.subscription = await self._connection_state.js.subscribe( + subject=self.clear_subject, + queue=self.queue, + cb=self.consume, + config=self.config, + **self.extra_options, + ) + + +class ConcurrentPushStreamSubscriber( + ConcurrentMixin, + StreamSubscriber, +): + subscription: Optional["JetStreamContext.PushSubscription"] + + def __init__( + self, + *, + max_workers: int, + stream: "JStream", + # default args + subject: str, + config: "ConsumerConfig", + queue: str, + extra_options: Optional["AnyDict"], + # Subscriber args + ack_policy: "AckPolicy", + no_reply: bool, + broker_dependencies: Iterable["Dependant"], + broker_middlewares: Iterable["BrokerMiddleware[Msg]"], + # AsyncAPI args + title_: Optional[str], + description_: Optional[str], + include_in_schema: bool, + ) -> None: + super().__init__( + max_workers=max_workers, + # basic args + stream=stream, + subject=subject, + config=config, + queue=queue, + extra_options=extra_options, + # Propagated args + ack_policy=ack_policy, + no_reply=no_reply, + broker_middlewares=broker_middlewares, + broker_dependencies=broker_dependencies, + # AsyncAPI args + description_=description_, + title_=title_, + include_in_schema=include_in_schema, + ) + + @override + async def _create_subscription(self) -> None: + """Create NATS subscription and start consume task.""" + if self.subscription: + return + + self.start_consume_task() + + self.subscription = await self._connection_state.js.subscribe( + subject=self.clear_subject, + queue=self.queue, + cb=self._put_msg, + config=self.config, + **self.extra_options, + ) diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index b3c57d7541..63c9fb9a22 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -26,7 +26,7 @@ from faststream._internal.basic_types import SendableMessage from faststream.nats.publisher.specified import SpecificationPublisher from faststream.nats.response import NatsPublishCommand - from faststream.nats.subscriber.usecase import LogicSubscriber + from faststream.nats.subscriber.usecases.basic import LogicSubscriber __all__ = ("TestNatsBroker",) diff --git a/faststream/opentelemetry/baggage.py b/faststream/opentelemetry/baggage.py index 214da61e4b..b29f24e1bc 100644 --- a/faststream/opentelemetry/baggage.py +++ b/faststream/opentelemetry/baggage.py @@ -62,8 +62,7 @@ def to_headers(self, headers: Optional["AnyDict"] = None) -> "AnyDict": def from_msg(cls, msg: "StreamMessage[Any]") -> Self: """Create a Baggage instance from a StreamMessage.""" if len(msg.batch_headers) <= 1: - payload = baggage.get_all(_BAGGAGE_PROPAGATOR.extract(msg.headers)) - return cls(cast("AnyDict", payload)) + return cls.from_headers(msg.headers) cumulative_baggage: AnyDict = {} batch_baggage: list[AnyDict] = [] diff --git a/faststream/rabbit/subscriber/factory.py b/faststream/rabbit/subscriber/factory.py index 823844aeae..8a4475ec58 100644 --- a/faststream/rabbit/subscriber/factory.py +++ b/faststream/rabbit/subscriber/factory.py @@ -29,6 +29,8 @@ def create_subscriber( description_: Optional[str], include_in_schema: bool, ) -> SpecificationSubscriber: + _validate_input_for_misconfigure() + if ack_policy is EMPTY: ack_policy = AckPolicy.REJECT_ON_ERROR @@ -44,3 +46,7 @@ def create_subscriber( description_=description_, include_in_schema=include_in_schema, ) + + +def _validate_input_for_misconfigure() -> None: + """Nothing to check yet.""" diff --git a/faststream/redis/message.py b/faststream/redis/message.py index bb4260703e..b4b0d443d4 100644 --- a/faststream/redis/message.py +++ b/faststream/redis/message.py @@ -61,20 +61,20 @@ class RedisMessage(BrokerStreamMessage[PubSubMessage]): pass -class ListMessage(TypedDict): +class _ListMessage(TypedDict): """A class to represent an Abstract List message.""" channel: str -class DefaultListMessage(ListMessage): +class DefaultListMessage(_ListMessage): """A class to represent a single List message.""" type: Literal["list"] data: bytes -class BatchListMessage(ListMessage): +class BatchListMessage(_ListMessage): """A class to represent a List messages batch.""" type: Literal["blist"] @@ -95,22 +95,22 @@ class RedisBatchListMessage(BrokerStreamMessage[BatchListMessage]): bDATA_KEY = DATA_KEY.encode() # noqa: N816 -class StreamMessage(TypedDict): +class _StreamMessage(TypedDict): channel: str message_ids: list[bytes] -class DefaultStreamMessage(StreamMessage): +class DefaultStreamMessage(_StreamMessage): type: Literal["stream"] data: dict[bytes, bytes] -class BatchStreamMessage(StreamMessage): +class BatchStreamMessage(_StreamMessage): type: Literal["bstream"] data: list[dict[bytes, bytes]] -_StreamMsgType = TypeVar("_StreamMsgType", bound=StreamMessage) +_StreamMsgType = TypeVar("_StreamMsgType", bound=_StreamMessage) class _RedisStreamMessageMixin(BrokerStreamMessage[_StreamMsgType]): diff --git a/faststream/redis/opentelemetry/provider.py b/faststream/redis/opentelemetry/provider.py index 852cd8ca5f..d818864973 100644 --- a/faststream/redis/opentelemetry/provider.py +++ b/faststream/redis/opentelemetry/provider.py @@ -1,4 +1,3 @@ -from collections.abc import Sized from typing import TYPE_CHECKING, cast from opentelemetry.semconv.trace import SpanAttributes @@ -32,7 +31,7 @@ def get_consume_attrs_from_message( if cast(str, msg.raw_message.get("type", "")).startswith("b"): attrs[SpanAttributes.MESSAGING_BATCH_MESSAGE_COUNT] = len( - cast(Sized, msg._decoded_body), + msg.raw_message["data"] ) return attrs diff --git a/faststream/redis/prometheus/provider.py b/faststream/redis/prometheus/provider.py index d34227a15e..533905e6a8 100644 --- a/faststream/redis/prometheus/provider.py +++ b/faststream/redis/prometheus/provider.py @@ -1,5 +1,4 @@ -from collections.abc import Sized -from typing import TYPE_CHECKING, Optional, Union, cast +from typing import TYPE_CHECKING, Optional, Union from faststream.prometheus import ( ConsumeAttrs, @@ -45,7 +44,7 @@ def get_consume_attrs_from_message( return { "destination_name": _get_destination(msg.raw_message), "message_size": len(msg.body), - "messages_count": len(cast(Sized, msg._decoded_body)), + "messages_count": len(msg.raw_message["data"]), } diff --git a/tests/brokers/base/fastapi.py b/tests/brokers/base/fastapi.py index d9b4582742..602a040840 100644 --- a/tests/brokers/base/fastapi.py +++ b/tests/brokers/base/fastapi.py @@ -575,8 +575,7 @@ async def hello_router2(dep: None = Depends(dep1)) -> str: mock.assert_called_once() assert not mock.not_call.called - @pytest.mark.xfail(reason="https://github.com/airtai/faststream/issues/1742") - async def test_nested_router(self, mock: Mock, queue: str) -> None: + async def test_nested_router(self, queue: str) -> None: router = self.router_class() router2 = self.router_class() @@ -600,7 +599,4 @@ async def hello_router2() -> str: queue, timeout=0.5, ) - assert await r.decode() == "hi", r - - mock.assert_called_once() - assert not mock.not_call.called + assert r.body == b"hi" diff --git a/tests/brokers/confluent/test_requests.py b/tests/brokers/confluent/test_requests.py index 190cff5df6..a0343ced57 100644 --- a/tests/brokers/confluent/test_requests.py +++ b/tests/brokers/confluent/test_requests.py @@ -14,7 +14,7 @@ async def on_receive(self) -> None: self.msg._raw_msg *= 2 async def consume_scope(self, call_next, msg): - msg._decoded_body *= 2 + msg.body *= 2 return await call_next(msg) diff --git a/tests/brokers/kafka/test_requests.py b/tests/brokers/kafka/test_requests.py index 41a1687b09..f84ba2a5db 100644 --- a/tests/brokers/kafka/test_requests.py +++ b/tests/brokers/kafka/test_requests.py @@ -12,7 +12,7 @@ async def on_receive(self) -> None: self.msg.value *= 2 async def consume_scope(self, call_next, msg): - msg._decoded_body *= 2 + msg.body *= 2 return await call_next(msg) diff --git a/tests/brokers/nats/test_consume.py b/tests/brokers/nats/test_consume.py index 8a6cd3917e..82ff607e4a 100644 --- a/tests/brokers/nats/test_consume.py +++ b/tests/brokers/nats/test_consume.py @@ -178,13 +178,12 @@ async def handler(msg: NatsMessage) -> None: async def test_core_consume_no_ack( self, queue: str, - stream: JStream, ) -> None: event = asyncio.Event() consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue, ack_policy=AckPolicy.DO_NOTHING) + @consume_broker.subscriber(queue) async def handler(msg: NatsMessage) -> None: event.set() @@ -293,12 +292,15 @@ async def handler(msg: NatsMessage) -> None: async def test_consume_no_ack( self, queue: str, + stream: str, ) -> None: event = asyncio.Event() consume_broker = self.get_broker(apply_types=True) - @consume_broker.subscriber(queue, ack_policy=AckPolicy.DO_NOTHING) + @consume_broker.subscriber( + queue, stream=stream, ack_policy=AckPolicy.DO_NOTHING + ) async def handler(msg: NatsMessage) -> None: event.set() diff --git a/tests/brokers/nats/test_requests.py b/tests/brokers/nats/test_requests.py index af52ca02da..579f13113d 100644 --- a/tests/brokers/nats/test_requests.py +++ b/tests/brokers/nats/test_requests.py @@ -12,7 +12,7 @@ async def on_receive(self) -> None: self.msg.data *= 2 async def consume_scope(self, call_next, msg): - msg._decoded_body *= 2 + msg.body *= 2 return await call_next(msg) diff --git a/tests/brokers/rabbit/test_requests.py b/tests/brokers/rabbit/test_requests.py index 2d5226a99f..fd5fb93ebf 100644 --- a/tests/brokers/rabbit/test_requests.py +++ b/tests/brokers/rabbit/test_requests.py @@ -13,7 +13,7 @@ async def on_receive(self) -> None: self.msg.body *= 2 async def consume_scope(self, call_next, msg): - msg._decoded_body *= 2 + msg.body *= 2 return await call_next(msg) diff --git a/tests/brokers/redis/test_requests.py b/tests/brokers/redis/test_requests.py index b9d2e3f244..f1d4fc3c0f 100644 --- a/tests/brokers/redis/test_requests.py +++ b/tests/brokers/redis/test_requests.py @@ -14,7 +14,7 @@ async def on_receive(self) -> None: self.msg["data"] = json.dumps(data) async def consume_scope(self, call_next, msg): - msg._decoded_body *= 2 + msg.body *= 2 return await call_next(msg) diff --git a/tests/prometheus/redis/test_provider.py b/tests/prometheus/redis/test_provider.py index 469f5237b8..c1b593b545 100644 --- a/tests/prometheus/redis/test_provider.py +++ b/tests/prometheus/redis/test_provider.py @@ -3,6 +3,13 @@ import pytest from faststream.prometheus import MetricsSettingsProvider +from faststream.redis.message import ( + BatchListMessage, + BatchStreamMessage, + DefaultListMessage, + DefaultStreamMessage, + PubSubMessage, +) from faststream.redis.prometheus.provider import ( BatchRedisMetricsSettingsProvider, RedisMetricsSettingsProvider, @@ -47,8 +54,8 @@ def test_get_consume_attrs_from_message(self, queue: str, destination: str) -> N "message_size": len(body), "messages_count": 1, } - raw_message = {} + raw_message = {"data": body} if destination: raw_message[destination] = queue @@ -79,18 +86,21 @@ def get_provider() -> MetricsSettingsProvider: def test_get_consume_attrs_from_message(self, queue: str, destination: str) -> None: decoded_body = ["Hi ", "again, ", "FastStream!"] body = str(decoded_body).encode() + expected_attrs = { "destination_name": queue if destination else "", "message_size": len(body), "messages_count": len(decoded_body), } - raw_message = {} + + raw_message = {"data": decoded_body} if destination: raw_message[destination] = queue message = SimpleNamespace( - body=body, _decoded_body=decoded_body, raw_message=raw_message + body=body, + raw_message=raw_message, ) provider = self.get_provider() @@ -103,19 +113,44 @@ def test_get_consume_attrs_from_message(self, queue: str, destination: str) -> N ("msg", "expected_provider"), ( pytest.param( - {"type": "blist"}, - BatchRedisMetricsSettingsProvider(), - id="batch message", + PubSubMessage( + type="message", + channel="test-channel", + data=b"", + pattern=None, + ), + RedisMetricsSettingsProvider(), + id="PubSub message", ), pytest.param( - {"type": "not_blist"}, + DefaultListMessage(type="list", channel="test-list", data=b""), RedisMetricsSettingsProvider(), - id="single message", + id="Single List message", ), pytest.param( - None, + BatchListMessage(type="blist", channel="test-list", data=[b"", b""]), + BatchRedisMetricsSettingsProvider(), + id="Batch List message", + ), + pytest.param( + DefaultStreamMessage( + type="stream", + channel="test-stream", + data=b"", + message_ids=[], + ), RedisMetricsSettingsProvider(), - id="None message", + id="Single Stream message", + ), + pytest.param( + BatchStreamMessage( + type="bstream", + channel="test-stream", + data=[{b"": b""}, {b"": b""}], + message_ids=[], + ), + BatchRedisMetricsSettingsProvider(), + id="Batch Stream message", ), ), )