From c7d4f1f063a4ea585e3345b61c0616c56b8dcdc8 Mon Sep 17 00:00:00 2001 From: slyces Date: Tue, 7 Jan 2025 16:11:29 +0100 Subject: [PATCH] feat: provide a typer integration, highly inspired by the click one --- docs/integrations/index.rst | 2 + docs/integrations/typer.rst | 49 +++++++ examples/integrations/typer_app/__init__.py | 0 .../typer_app/with_auto_inject.py | 52 ++++++++ .../integrations/typer_app/with_inject.py | 53 ++++++++ requirements/typer-0151.txt | 2 + requirements/typer-latest.txt | 2 + src/dishka/integrations/typer.py | 59 +++++++++ tests/integrations/typer/__init__.py | 4 + tests/integrations/typer/test_typer.py | 125 ++++++++++++++++++ tox.ini | 6 +- 11 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 docs/integrations/typer.rst create mode 100644 examples/integrations/typer_app/__init__.py create mode 100644 examples/integrations/typer_app/with_auto_inject.py create mode 100644 examples/integrations/typer_app/with_inject.py create mode 100644 requirements/typer-0151.txt create mode 100644 requirements/typer-latest.txt create mode 100644 src/dishka/integrations/typer.py create mode 100644 tests/integrations/typer/__init__.py create mode 100644 tests/integrations/typer/test_typer.py diff --git a/docs/integrations/index.rst b/docs/integrations/index.rst index 4d28eebe..fbe7d799 100644 --- a/docs/integrations/index.rst +++ b/docs/integrations/index.rst @@ -25,6 +25,7 @@ You can create custom integrations for your framework of choice. starlette taskiq telebot + typer adding_new .. list-table:: Built-in frameworks integrations @@ -39,6 +40,7 @@ You can create custom integrations for your framework of choice. - :ref:`aiogram` - :ref:`arq` - :ref:`Click` + - :ref:`Typer` * - :ref:`grpcio` - :ref:`Aiogram_dialog` diff --git a/docs/integrations/typer.rst b/docs/integrations/typer.rst new file mode 100644 index 00000000..980eaa5a --- /dev/null +++ b/docs/integrations/typer.rst @@ -0,0 +1,49 @@ +.. _typer: + +Typer +================================= + + +Though it is not required, you can use dishka-click integration. It features automatic injection to command handlers +In contrast with other integrations there is no scope management. + + + +How to use +**************** + +1. Import + +.. code-block:: python + + from dishka.integrations.typer import setup_dishka, inject + + +2. Create a container and set it up with the typer app. Pass ``auto_inject=True`` if you do not want to use the ``@inject`` decorator explicitly. + +.. code-block:: python + + app = typer.Typer() + + container = make_container(MyProvider()) + setup_dishka(container=container, app=app, auto_inject=True) + + +3. Mark those of your command handlers parameters which are to be injected with ``FromDishka[]`` + +.. code-block:: python + + app = typer.Typer() + + @app.command(name="greet") + def greet_user(greeter: FromDishka[Greeter], user: Annotated[str, typer.Argument()]) -> None: + ... + +3a. *(optional)* decorate them using ``@inject`` if you want to mark commands explicitly + +.. code-block:: python + + @app.command(name="greet") + @inject # Use this decorator *before* the command decorator + def greet_user(greeter: FromDishka[Greeter], user: Annotated[str, typer.Argument()]) -> None: + ... diff --git a/examples/integrations/typer_app/__init__.py b/examples/integrations/typer_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/integrations/typer_app/with_auto_inject.py b/examples/integrations/typer_app/with_auto_inject.py new file mode 100644 index 00000000..26334b3e --- /dev/null +++ b/examples/integrations/typer_app/with_auto_inject.py @@ -0,0 +1,52 @@ +"""Example typer app greeting a user, taken from +https://typer.tiangolo.com/#example-upgrade with small modifications. +""" + +from typing import Annotated, Protocol +import typer +from functools import partial +from dishka import make_container, Provider +from dishka.entities.scope import Scope +from dishka.integrations.typer import FromDishka, inject, setup_dishka + + +class Greeter(Protocol): + """Protocol to be extra generic on our greeting infrastructure.""" + def __call__(self, text: str) -> None: ... + + +provider = Provider(scope=Scope.APP) + +# We provide an advanced greeting experience with `typer.secho` +# For a less advanced implementation, we could use `print` +provider.provide(lambda: partial(typer.secho, fg="blue"), provides=Greeter) + + +app = typer.Typer() + + +@app.command() +def hello( + greeter: FromDishka[Greeter], + name: Annotated[str, typer.Argument(..., help="The name to greet")], +) -> None: + greeter(f"Hello {name}") + + +@app.command() +def goodbye(greeter: FromDishka[Greeter], name: str, formal: bool = False) -> None: + if formal: + greeter(f"Goodbye Ms. {name}. Have a good day.") + else: + greeter(f"Bye {name}!") + + +# Setup dishka to inject the dependency container +# *Must* be after defining the commands when using auto_inject +container = make_container(provider) +setup_dishka(container=container, app=app, auto_inject=True) + + +if __name__ == "__main__": + app() + diff --git a/examples/integrations/typer_app/with_inject.py b/examples/integrations/typer_app/with_inject.py new file mode 100644 index 00000000..ed82f421 --- /dev/null +++ b/examples/integrations/typer_app/with_inject.py @@ -0,0 +1,53 @@ +"""Example typer app greeting a user, taken from +https://typer.tiangolo.com/#example-upgrade with small modifications. +""" + +from typing import Annotated, Protocol +import typer +from functools import partial +from dishka import make_container, Provider +from dishka.entities.scope import Scope +from dishka.integrations.typer import FromDishka, inject, setup_dishka + + +class Greeter(Protocol): + """Protocol to be extra generic on our greeting infrastructure.""" + def __call__(self, text: str) -> None: ... + + +provider = Provider(scope=Scope.APP) + +# We provide an advanced greeting experience with `typer.secho` +# For a less advanced implementation, we could use `print` +provider.provide(lambda: partial(typer.secho, fg="blue"), provides=Greeter) + + +app = typer.Typer() + + +# Setup dishka to inject the dependency container +# Can be done before or after defining the commands when using @inject manually +container = make_container(provider) +setup_dishka(container=container, app=app, auto_inject=False) + + +@app.command() +@inject +def hello( + greeter: FromDishka[Greeter], + name: Annotated[str, typer.Argument(..., help="The name to greet")], +) -> None: + greeter(f"Hello {name}") + + +@app.command() +@inject +def goodbye(greeter: FromDishka[Greeter], name: str, formal: bool = False) -> None: + if formal: + greeter(f"Goodbye Ms. {name}. Have a good day.") + else: + greeter(f"Bye {name}!") + + +if __name__ == "__main__": + app() diff --git a/requirements/typer-0151.txt b/requirements/typer-0151.txt new file mode 100644 index 00000000..a921c9a1 --- /dev/null +++ b/requirements/typer-0151.txt @@ -0,0 +1,2 @@ +-r test.txt +typer==0.15.1 diff --git a/requirements/typer-latest.txt b/requirements/typer-latest.txt new file mode 100644 index 00000000..a8b1cbc8 --- /dev/null +++ b/requirements/typer-latest.txt @@ -0,0 +1,2 @@ +-r test.txt +typer diff --git a/src/dishka/integrations/typer.py b/src/dishka/integrations/typer.py new file mode 100644 index 00000000..b250bfdd --- /dev/null +++ b/src/dishka/integrations/typer.py @@ -0,0 +1,59 @@ +"""Integration for Typer https://typer.tiangolo.com""" +__all__ = [ + "FromDishka", + "inject", + "setup_dishka", +] + +from collections.abc import Callable +from typing import Final, TypeVar + +import typer +from click import get_current_context +from typer.models import CommandInfo + +from dishka import Container, FromDishka +from .base import is_dishka_injected, wrap_injection + +T = TypeVar("T") +CONTAINER_NAME: Final = "dishka_container" + + +def inject(func: Callable[..., T]) -> Callable[..., T]: + return wrap_injection( + func=func, + container_getter=lambda _, __: get_current_context().meta[ + CONTAINER_NAME + ], + remove_depends=True, + is_async=False, + ) + + +def _inject_commands(app: typer.Typer) -> None: + for command in app.registered_commands: + if command.callback is not None and not is_dishka_injected(command.callback): + command.callback = inject(command.callback) + + for group in app.registered_groups: + if group.typer_instance is not None: + _inject_commands(group.typer_instance) + + +def setup_dishka( + container: Container, + app: typer.Typer, + *, + finalize_container: bool = True, + auto_inject: bool = False, +) -> None: + + @app.callback() + def inject_dishka_container(context: typer.Context) -> None: + context.meta[CONTAINER_NAME] = container + + if finalize_container: + context.call_on_close(container.close) + + if auto_inject: + _inject_commands(app) diff --git a/tests/integrations/typer/__init__.py b/tests/integrations/typer/__init__.py new file mode 100644 index 00000000..abdc364e --- /dev/null +++ b/tests/integrations/typer/__init__.py @@ -0,0 +1,4 @@ +import pytest + +pytest.importorskip("typer") + diff --git a/tests/integrations/typer/test_typer.py b/tests/integrations/typer/test_typer.py new file mode 100644 index 00000000..33a195c7 --- /dev/null +++ b/tests/integrations/typer/test_typer.py @@ -0,0 +1,125 @@ +from contextlib import contextmanager +from typing import Annotated, Any, Callable, ContextManager, Iterable, Iterator + +import typer +import pytest +from typer.testing import CliRunner + +from dishka import FromDishka, make_container +from dishka.integrations.typer import inject, setup_dishka +from dishka.provider import Provider +from ..common import ( + APP_DEP_VALUE, + AppDep, + AppMock, + AppProvider, +) + + +AppFactory = Callable[[Callable[..., Any], Provider], ContextManager[typer.Typer]] + + +@contextmanager +def dishka_app(handler: Callable[..., Any], provider: Provider) -> Iterator[typer.Typer]: + app = typer.Typer() + app.command(name="test")(inject(handler)) + + container = make_container(provider) + setup_dishka(container=container, app=app, finalize_container=False) + + yield app + container.close() + + +@contextmanager +def dishka_auto_app(handler: Callable[..., Any], provider: Provider) -> Iterator[typer.Typer]: + app = typer.Typer() + app.command(name="test")(handler) + + container = make_container(provider) + setup_dishka(container=container, app=app, finalize_container=False, auto_inject=True) + + yield app + container.close() + + +@contextmanager +def dishka_nested_group_app(handler: Callable[..., Any], provider: Provider) -> Iterator[typer.Typer]: + app = typer.Typer() + group = typer.Typer() + group.command(name="sub")(handler) + app.add_typer(group, name="test") + + container = make_container(provider) + setup_dishka(container=container, app=app, finalize_container=False, auto_inject=True) + + yield app + container.close() + + +def handle_with_app( + a: FromDishka[AppDep], + mock: FromDishka[AppMock], +) -> None: + mock(a) + + +@pytest.mark.parametrize( + "app_factory", + [ + dishka_app, + dishka_auto_app, + ], +) +def test_app_dependency(app_provider: AppProvider, app_factory: AppFactory) -> None: + runner = CliRunner() + with app_factory(handle_with_app, app_provider) as command: + result = runner.invoke(command, ["test"]) + assert result.exit_code == 0, result.stdout + app_provider.app_mock.assert_called_with(APP_DEP_VALUE) + app_provider.app_released.assert_not_called() + app_provider.app_released.assert_called_once() + + +def test_app_dependency_with_nested_groups(app_provider: AppProvider): + runner = CliRunner() + with dishka_nested_group_app(handle_with_app, app_provider) as command: + result = runner.invoke(command, ["test", "sub"]) + assert result.exit_code == 0 + app_provider.app_mock.assert_called_with(APP_DEP_VALUE) + app_provider.app_released.assert_not_called() + app_provider.app_released.assert_called() + + +def handle_with_app_and_options( + a: FromDishka[AppDep], + mock: FromDishka[AppMock], + name: Annotated[str, typer.Argument()] = "Wade", + surname: Annotated[str | None, typer.Option()] = None, +) -> None: + mock(a, name, surname) + + +@pytest.mark.parametrize( + "app_factory", + [ + dishka_app, + dishka_auto_app, + ], +) +def test_app_dependency_with_option(app_provider: AppProvider, app_factory: AppFactory) -> None: + runner = CliRunner() + with app_factory(handle_with_app_and_options, app_provider) as command: + result = runner.invoke(command, ["test", "Wade"]) + assert result.exit_code == 0, result.stdout + app_provider.app_mock.assert_called_with(APP_DEP_VALUE, "Wade", None) + app_provider.request_released.assert_not_called() + + +def test_app_dependency_with_nested_groups_and_option(app_provider: AppProvider) -> None: + runner = CliRunner() + with dishka_nested_group_app(handle_with_app_and_options, app_provider) as command: + result = runner.invoke(command, ["test", "sub", "Wade", "--surname", "Wilson"]) + assert result.exit_code == 0, result.stdout + app_provider.app_mock.assert_called_with(APP_DEP_VALUE, "Wade", "Wilson") + app_provider.request_released.assert_not_called() diff --git a/tox.ini b/tox.ini index cfaa0bbd..ef743d86 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,8 @@ env_list = sanic-23121, aiogram-dialog-210, grpcio-1641, - click-817 + click-817, + typer-0151 [testenv] use_develop = true @@ -57,6 +58,8 @@ deps = grpcio-latest: -r requirements/grpcio-latest.txt click-817: -r requirements/click-817.txt click-latest: -r requirements/click-latest.txt + typer-0151: -r requirements/typer-0151.txt + typer-latest: -r requirements/typer-latest.txt commands = integrations-base: pytest --cov=dishka --cov-append --cov-report=term-missing -v tests/integrations/base @@ -74,6 +77,7 @@ commands = aiogram-dialog: pytest --cov=dishka --cov-append --cov-report=term-missing -v tests/integrations/aiogram_dialog grpcio: pytest --cov=dishka --cov-append --cov-report=term-missing -v tests/integrations/grpcio click: pytest --cov=dishka --cov-append --cov-report=term-missing -v tests/integrations/click + typer: pytest --cov=dishka --cov-append --cov-report=term-missing -v tests/integrations/typer allowlist_externals = pytest