Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: typer integration #332

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion 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 @@ -43,7 +44,7 @@ You can create custom integrations for your framework of choice.
* - :ref:`grpcio`
- :ref:`Aiogram_dialog`
- :ref:`FastStream`
-
- :ref:`Typer`

* - :ref:`Fastapi`
- :ref:`pyTelegramBotAPI<telebot>`
Expand Down
66 changes: 66 additions & 0 deletions docs/integrations/typer.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.. _typer:

Typer
=================================


Though it is not required, you can use dishka-click integration. It features:

* automatic APP and REQUEST scope management
* automatic injection of dependencies into handler function
* passing ``typer.Context`` object as a context data to providers
* you can still request ``typer.Context`` as with usual typer commands


How to use
****************

1. Import

.. code-block:: python

from dishka.integrations.typer import setup_dishka, inject, TyperProvider


2. Create provider. You can use ``typer.Context`` as a factory parameter to access on REQUEST-scope.

.. code-block:: python

class YourProvider(Provider):
@provide(scope=Scope.REQUEST)
def command_name(self, context: typer.Context) -> str | None:
return context.command.name


3. *(optional)* Use ``TyperProvider()`` when creating your container if you are using ``typer.Context`` in providers.

.. code-block:: python

container = make_async_container(YourProvider(), Typerprovider())


4. Mark those of your command handlers parameters which are to be injected with ``FromDishka[]``. You can use ``typer.Context`` in the command as usual.

.. code-block:: python

app = typer.Typer()

@app.command(name="greet")
def greet_user(ctx: typer.Context, greeter: FromDishka[Greeter], user: Annotated[str, typer.Argument()]) -> None:
...

4a. *(optional)* decorate commands using ``@inject`` if you want to mark them 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:
...


5. *(optional)* Use ``auto_inject=True`` when setting up dishka to automatically inject dependencies into your command handlers. When doing this, ensure all commands have already been created when you call setup. This limitation is not required when using ``@inject`` manually.

.. code-block:: python

setup_dishka(container=container, app=app, auto_inject=True)
Empty file.
70 changes: 70 additions & 0 deletions examples/integrations/typer_app/with_auto_inject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""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, provide
from dishka.entities.scope import Scope
from dishka.integrations.typer import FromDishka, TyperProvider, inject, setup_dishka


class Greeter(Protocol):
"""Protocol to be extra generic on our greeting infrastructure."""
def __call__(self, text: str) -> None: ...


class ColorfulProvider(Provider):

@provide(scope=Scope.REQUEST) # We need Scope.REQUEST for the context
def greeter(self, context: typer.Context) -> Greeter:
if context.command.name == "hello":
# Hello should most certainly be blue
return partial(typer.secho, fg="blue")
if context.command.name == "goodbye":
# Goodbye should be red
return partial(typer.secho, fg="red")
# Unexpected commands can be yellow
return partial(typer.secho, fg="yellow")


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}!")


@app.command()
def hi(
greeter: FromDishka[Greeter],
name: Annotated[str, typer.Argument(..., help="The name to greet")],
) -> None:
greeter(f"Hi {name}")


# Build the container with the `TyperProvider` to get the `typer.Context`
# parameter in REQUEST providers
container = make_container(ColorfulProvider(scope=Scope.REQUEST), TyperProvider())

# Setup dishka to inject the dependency container
# *Must* be after defining the commands when using auto_inject
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 noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def get_tests(self) -> str:
IntegrationEnv("taskiq", "latest"),
IntegrationEnv("telebot", "415"),
IntegrationEnv("telebot", "latest"),
IntegrationEnv("typer", "0151"),
IntegrationEnv("typer", "latest"),
]


Expand Down
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
95 changes: 95 additions & 0 deletions src/dishka/integrations/typer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Integration for Typer https://typer.tiangolo.com"""

__all__ = [
"FromDishka",
"inject",
"setup_dishka",
]

from collections.abc import Callable
from inspect import Parameter
from typing import Final, ParamSpec, TypeVar, cast, get_type_hints

import click
import typer

from dishka import Container, FromDishka, Scope
from dishka.dependency_source.make_context_var import from_context
from dishka.provider import Provider
from .base import is_dishka_injected, wrap_injection

T = TypeVar("T")
P = ParamSpec("P")
CONTAINER_NAME: Final = "dishka_container"
CONTAINER_NAME_REQ: Final = "dishka_container_req"


def inject(func: Callable[P, T]) -> Callable[P, T]:
Slyces marked this conversation as resolved.
Show resolved Hide resolved
hints = get_type_hints(func)
context_hint = next(
(name for name, hint in hints.items() if hint is typer.Context),
None,
)
if context_hint is None:
additional_params = [
Parameter(
name="___dishka_context",
annotation=typer.Context,
kind=Parameter.KEYWORD_ONLY,
),
]
else:
additional_params = []

param_name = context_hint or "___dishka_context"

def get_container(_, p):
context: typer.Context = p[param_name]
container = context.meta[CONTAINER_NAME]

req_container = context.with_resource(
container({typer.Context: context}, scope=Scope.REQUEST),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think we should do it. I treat typer as well as click as framework which actually just runs app as a whole: inside click/typer handlers there can be other frameworks started (like fastapi, etc.)

So, from my point of view, expected behavior for typer handler is to have Scope.APP, not REQUEST. Let't keep the logic we have for click.

I see the problem now: you cannot pass Context because APP-scoped container is already created. My fault, let's rollback and ignore this requirement.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you go back to typer.Context not being available to providers?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. We cannot pass it here, so let it be user's problem.

)
context.meta[CONTAINER_NAME_REQ] = req_container
return req_container

return wrap_injection(
func=func,
is_async=False,
additional_params=additional_params,
container_getter=get_container,
)


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)


class TyperProvider(Provider):
context = from_context(provides=typer.Context, scope=Scope.APP)


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)
3 changes: 3 additions & 0 deletions tests/integrations/typer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("typer")
Loading