diff --git a/docs/tutorial/async-cmd.md b/docs/tutorial/async-cmd.md new file mode 100644 index 0000000000..e7d46b249c --- /dev/null +++ b/docs/tutorial/async-cmd.md @@ -0,0 +1,40 @@ +Typer allows you to use [async](https://docs.python.org/3/library/asyncio.html) functions. + +```Python +{!../docs_src/async_cmd/async001.py!} +``` + +
+ +```console +$ python main.py + +Hello Async World +``` + +
+ +It also works with commands, and you can mix regular and async commands: + +```Python +{!../docs_src/async_cmd/async002.py!} +``` + +
+ +```console +$ python main.py sync + +Hello Sync World + +$ python main.py async + +Hello Async World +``` +
+ +!!! info + Under the hood, Typer is running your async functions with [asyncio.run()](https://docs.python.org/3/library/asyncio-runner.html#asyncio.run) + +!!! warning + Typer only supports async functions on Python 3.7+ diff --git a/docs_src/async_cmd/async001.py b/docs_src/async_cmd/async001.py new file mode 100644 index 0000000000..bb4285f47e --- /dev/null +++ b/docs_src/async_cmd/async001.py @@ -0,0 +1,14 @@ +import asyncio + +import typer + +app = typer.Typer() + + +async def main(): + await asyncio.sleep(0) + print("Hello Async World") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/async_cmd/async002.py b/docs_src/async_cmd/async002.py new file mode 100644 index 0000000000..9d2f47b8c4 --- /dev/null +++ b/docs_src/async_cmd/async002.py @@ -0,0 +1,20 @@ +import asyncio + +import typer + +app = typer.Typer() + + +@app.command("sync") +def command_sync(): + print("Hello Sync World") + + +@app.command("async") +async def command_async(): + await asyncio.sleep(0) + print("Hello Async World") + + +if __name__ == "__main__": + app() diff --git a/mkdocs.yml b/mkdocs.yml index 8022a19589..6bfac160f0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - Typer Callback: tutorial/commands/callback.md - One or Multiple Commands: tutorial/commands/one-or-multiple.md - Using the Context: tutorial/commands/context.md + - Async functions: tutorial/async-cmd.md - CLI Option autocompletion: tutorial/options-autocompletion.md - CLI Parameter Types: - CLI Parameter Types Intro: tutorial/parameter-types/index.md diff --git a/pyproject.toml b/pyproject.toml index 9b173bbe2b..d3c6d940ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ test = [ "coverage >=6.2,<7.0", "pytest-xdist >=1.32.0,<4.0.0", "pytest-sugar >=0.9.4,<0.10.0", - "mypy ==0.910", + "mypy ==0.971", "black >=22.3.0,<23.0.0", "isort >=5.0.6,<6.0.0", "rich >=10.11.0,<14.0.0", diff --git a/tests/test_tutorial/test_async_cmd/__init__.py b/tests/test_tutorial/test_async_cmd/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_async_cmd/test_async001.py b/tests/test_tutorial/test_async_cmd/test_async001.py new file mode 100644 index 0000000000..30004d8cdf --- /dev/null +++ b/tests/test_tutorial/test_async_cmd/test_async001.py @@ -0,0 +1,45 @@ +import subprocess +import sys + +import pytest +import typer +from typer.testing import CliRunner + +from docs_src.async_cmd import async001 as mod + +runner = CliRunner() + + +@pytest.mark.skipif( + sys.version_info < (3, 7), + reason="typer support for async functions requires python3.7 or higher", +) +def test_cli(): + app = typer.Typer() + app.command()(mod.main) + result = runner.invoke(app, []) + assert result.output == "Hello Async World\n" + + +@pytest.mark.skipif( + sys.version_info < (3, 7), + reason="typer support for async functions requires python3.7 or higher", +) +def test_execute(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert result.stdout == "Hello Async World\n" + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_async_cmd/test_async002.py b/tests/test_tutorial/test_async_cmd/test_async002.py new file mode 100644 index 0000000000..24929e7a69 --- /dev/null +++ b/tests/test_tutorial/test_async_cmd/test_async002.py @@ -0,0 +1,59 @@ +import subprocess +import sys + +import pytest +from typer.testing import CliRunner + +from docs_src.async_cmd import async002 as mod + +app = mod.app + +runner = CliRunner() + + +def test_command_sync(): + result = runner.invoke(app, ["sync"]) + assert result.output == "Hello Sync World\n" + + +@pytest.mark.skipif( + sys.version_info < (3, 7), + reason="typer support for async functions requires python3.7 or higher", +) +def test_command_async(): + result = runner.invoke(app, ["async"]) + assert result.output == "Hello Async World\n" + + +def test_execute_sync(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "sync"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert result.stdout == "Hello Sync World\n" + + +@pytest.mark.skipif( + sys.version_info < (3, 7), + reason="typer support for async functions requires python3.7 or higher", +) +def test_execute_async(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "async"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert result.stdout == "Hello Async World\n" + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/typer/_typing.py b/typer/_typing.py index d906e2227d..bfa8114fcb 100644 --- a/typer/_typing.py +++ b/typer/_typing.py @@ -4,14 +4,13 @@ import sys from os import PathLike -from typing import ( # type: ignore +from typing import ( TYPE_CHECKING, AbstractSet, Any, ClassVar, Dict, Generator, - Iterable, List, Mapping, NewType, diff --git a/typer/completion.py b/typer/completion.py index 2712d08da4..c42ad95b81 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -1,6 +1,6 @@ import os import sys -from typing import Any, Dict, Tuple +from typing import Any, MutableMapping, Tuple import click @@ -120,7 +120,7 @@ def completion_init() -> None: # This is only called in new Command method, only used by Click 8.x+ def shell_complete( cli: click.BaseCommand, - ctx_args: Dict[str, Any], + ctx_args: MutableMapping[str, Any], prog_name: str, complete_var: str, instruction: str, diff --git a/typer/core.py b/typer/core.py index c0776368a6..0c73784c23 100644 --- a/typer/core.py +++ b/typer/core.py @@ -9,6 +9,7 @@ Callable, Dict, List, + MutableMapping, Optional, Sequence, TextIO, @@ -634,7 +635,7 @@ def _typer_format_options( def _typer_main_shell_completion( self: click.core.Command, *, - ctx_args: Dict[str, Any], + ctx_args: MutableMapping[str, Any], prog_name: str, complete_var: Optional[str] = None, ) -> None: @@ -696,7 +697,7 @@ def format_options( def _main_shell_completion( self, - ctx_args: Dict[str, Any], + ctx_args: MutableMapping[str, Any], prog_name: str, complete_var: Optional[str] = None, ) -> None: @@ -758,7 +759,7 @@ def format_options( def _main_shell_completion( self, - ctx_args: Dict[str, Any], + ctx_args: MutableMapping[str, Any], prog_name: str, complete_var: Optional[str] = None, ) -> None: diff --git a/typer/main.py b/typer/main.py index aa39e82849..e7464ccb19 100644 --- a/typer/main.py +++ b/typer/main.py @@ -8,7 +8,18 @@ from pathlib import Path from traceback import FrameSummary, StackSummary from types import TracebackType -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Tuple, + Type, + Union, + cast, +) from uuid import UUID import click @@ -34,7 +45,7 @@ Required, TyperInfo, ) -from .utils import get_params_from_function +from .utils import ensure_sync, get_params_from_function try: import rich @@ -51,7 +62,7 @@ def except_hook( - exc_type: Type[BaseException], exc_value: BaseException, tb: TracebackType + exc_type: Type[BaseException], exc_value: BaseException, tb: Optional[TracebackType] ) -> None: exception_config: Union[DeveloperExceptionConfig, None] = getattr( exc_value, _typer_developer_exception_attr_name, None @@ -235,6 +246,8 @@ def command( cls = TyperCommand def decorator(f: CommandFunctionType) -> CommandFunctionType: + f = cast(CommandFunctionType, ensure_sync(f)) + self.registered_commands.append( CommandInfo( name=name, diff --git a/typer/utils.py b/typer/utils.py index 44816e2420..9518059575 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -1,8 +1,21 @@ import inspect from copy import copy -from typing import Any, Callable, Dict, List, Tuple, Type, cast, get_type_hints - -from typing_extensions import Annotated +from functools import wraps +from typing import ( + Any, + Callable, + Coroutine, + Dict, + List, + Tuple, + Type, + TypeVar, + Union, + cast, + get_type_hints, +) + +from typing_extensions import Annotated, ParamSpec from ._typing import get_args, get_origin from .models import ArgumentInfo, OptionInfo, ParameterInfo, ParamMeta @@ -185,3 +198,23 @@ def get_params_from_function(func: Callable[..., Any]) -> Dict[str, ParamMeta]: name=param.name, default=default, annotation=annotation ) return params + + +P = ParamSpec("P") +R = TypeVar("R") + + +def ensure_sync(f: Callable[P, Union[R, Coroutine[Any, Any, R]]]) -> Callable[P, R]: + # If `f` is an async function, wrap it into asyncio.run(f) + if not inspect.iscoroutinefunction(f): + f_sync = cast(Callable[P, R], f) + return f_sync + + @wraps(f) + def run_f(*args: P.args, **kwargs: P.kwargs) -> R: + import asyncio + + f_async = cast(Callable[P, Coroutine[Any, Any, R]], f) + return asyncio.run(f_async(*args, **kwargs)) + + return run_f