Skip to content

Commit

Permalink
feat: provide a typer integration, highly inspired by the click one
Browse files Browse the repository at this point in the history
  • Loading branch information
Slyces committed Jan 7, 2025
1 parent fd0cf9f commit c7d4f1f
Show file tree
Hide file tree
Showing 11 changed files with 353 additions and 1 deletion.
2 changes: 2 additions & 0 deletions docs/integrations/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down
49 changes: 49 additions & 0 deletions docs/integrations/typer.rst
Original file line number Diff line number Diff line change
@@ -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:
...
Empty file.
52 changes: 52 additions & 0 deletions examples/integrations/typer_app/with_auto_inject.py
Original file line number Diff line number Diff line change
@@ -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()

53 changes: 53 additions & 0 deletions examples/integrations/typer_app/with_inject.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions requirements/typer-0151.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r test.txt
typer==0.15.1
2 changes: 2 additions & 0 deletions requirements/typer-latest.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r test.txt
typer
59 changes: 59 additions & 0 deletions src/dishka/integrations/typer.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions tests/integrations/typer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import pytest

pytest.importorskip("typer")

125 changes: 125 additions & 0 deletions tests/integrations/typer/test_typer.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 5 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ env_list =
sanic-23121,
aiogram-dialog-210,
grpcio-1641,
click-817
click-817,
typer-0151

[testenv]
use_develop = true
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit c7d4f1f

Please sign in to comment.