diff --git a/README.md b/README.md index a7892a9188..2e83a771d6 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,8 @@ By default it also comes with extra standard dependencies: * shellingham: to automatically detect the current shell when installing completion. * With `shellingham` you can just use `--install-completion`. * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. +* anyio: and Typer will automatically detect the appropriate engine to run asynchronous code. + * With Trio installed alongside Typer, Typer will use Trio to run asynchronous code by default. ### `typer-slim` diff --git a/docs/features.md b/docs/features.md index 5a8643fa35..7b1b25e4f4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -16,6 +16,10 @@ If you need a 2 minute refresher of how to use Python types (even if you don't u You will also see a 20 seconds refresher on the section [Tutorial - User Guide: First Steps](tutorial/first-steps.md){.internal-link target=_blank}. +## Async support + +Supports **asyncio** out of the box. [AnyIO](https://github.com/agronholm/anyio) is available as extra dependency for automatic support of [Trio](https://github.com/python-trio/trio). + ## Editor support **Typer** was designed to be easy and intuitive to use, to ensure the best development experience. With autocompletion everywhere. diff --git a/docs/index.md b/docs/index.md index bc653e84f2..78c5cdad2c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -366,6 +366,8 @@ By default it also comes with extra standard dependencies: * shellingham: to automatically detect the current shell when installing completion. * With `shellingham` you can just use `--install-completion`. * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. +* anyio: and Typer will automatically detect the appropriate engine to run asynchronous code. + * With Trio installed alongside Typer, Typer will use Trio to run asynchronous code by default. ### `typer-slim` diff --git a/docs/tutorial/async.md b/docs/tutorial/async.md new file mode 100644 index 0000000000..6292e3471e --- /dev/null +++ b/docs/tutorial/async.md @@ -0,0 +1,70 @@ +# Async support + +## Engines + +Typer supports `asyncio` out of the box. Trio is supported through +anyio, which can be installed as optional dependency: + +
+ +```console +$ pip install typer[anyio] +---> 100% +Successfully installed typer anyio +``` + +
+ +### Default engine selection + +*none* | anyio | trio | anyio + trio +--- | --- | --- | --- +asyncio | asyncio via anyio | asyncio* | trio via anyio + +* If you don't want to install `anyio` when using `trio`, provide your own async_runner function + +## Using with run() + +Async functions can be run just like normal functions: + +{* docs_src/asynchronous/tutorial001.py hl[1,8:9,14] *} + +Or using `anyio`: + +{* docs_src/asynchronous/tutorial002.py hl[1,8] *} + +## Using with commands + +Async functions can be registered as commands explicitely just like synchronous functions: + +{* docs_src/asynchronous/tutorial003.py hl[1,8:9,15] *} + +Or using `anyio`: + +{* docs_src/asynchronous/tutorial004.py hl[1,9] *} + +Or using `trio` via `anyio`: + +{* docs_src/asynchronous/tutorial005.py hl[1,9] *} + +## Customizing async engine + +You can customize the async engine by providing an additional parameter `async_runner` to the Typer instance or to the command decorator. + +When both are provided, the one from the decorator will take precedence over the one from the Typer instance. + +Customize a single command: + +{* docs_src/asynchronous/tutorial007.py hl[15] *} + +Customize the default engine for the Typer instance: + +{* docs_src/asynchronous/tutorial008.py hl[6] *} + +## Using with callback + +The callback function supports asynchronous functions with the `async_runner` parameter as well: + +{* docs_src/asynchronous/tutorial006.py hl[15] *} + +Because the asynchronous functions are wrapped in a synchronous context before being executed, it is possible to mix async engines between the callback and commands. diff --git a/docs_src/asynchronous/tutorial001.py b/docs_src/asynchronous/tutorial001.py new file mode 100644 index 0000000000..dc857b7f43 --- /dev/null +++ b/docs_src/asynchronous/tutorial001.py @@ -0,0 +1,14 @@ +import asyncio + +import typer + +app = typer.Typer() + + +async def main(): + await asyncio.sleep(1) + typer.echo("Hello World") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/asynchronous/tutorial002.py b/docs_src/asynchronous/tutorial002.py new file mode 100644 index 0000000000..35f626ab78 --- /dev/null +++ b/docs_src/asynchronous/tutorial002.py @@ -0,0 +1,13 @@ +import anyio +import typer + +app = typer.Typer() + + +async def main(): + await anyio.sleep(1) + typer.echo("Hello World") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/asynchronous/tutorial003.py b/docs_src/asynchronous/tutorial003.py new file mode 100644 index 0000000000..6266979b8b --- /dev/null +++ b/docs_src/asynchronous/tutorial003.py @@ -0,0 +1,15 @@ +import asyncio + +import typer + +app = typer.Typer(async_runner=asyncio.run) + + +@app.command() +async def wait(seconds: int): + await asyncio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds") + + +if __name__ == "__main__": + app() diff --git a/docs_src/asynchronous/tutorial004.py b/docs_src/asynchronous/tutorial004.py new file mode 100644 index 0000000000..7ad9b5d23c --- /dev/null +++ b/docs_src/asynchronous/tutorial004.py @@ -0,0 +1,14 @@ +import anyio +import typer + +app = typer.Typer() + + +@app.command() +async def wait(seconds: int): + await anyio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds") + + +if __name__ == "__main__": + app() diff --git a/docs_src/asynchronous/tutorial005.py b/docs_src/asynchronous/tutorial005.py new file mode 100644 index 0000000000..4325b9280f --- /dev/null +++ b/docs_src/asynchronous/tutorial005.py @@ -0,0 +1,14 @@ +import trio +import typer + +app = typer.Typer() + + +@app.command() +async def wait(seconds: int): + await trio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds") + + +if __name__ == "__main__": + app() diff --git a/docs_src/asynchronous/tutorial006.py b/docs_src/asynchronous/tutorial006.py new file mode 100644 index 0000000000..6901707269 --- /dev/null +++ b/docs_src/asynchronous/tutorial006.py @@ -0,0 +1,24 @@ +import asyncio + +import trio +import typer + +app = typer.Typer() + + +@app.command() +async def wait_trio(seconds: int): + await trio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds using trio (default)") + + +@app.callback(async_runner=lambda c: asyncio.run(c)) +async def wait_asyncio(seconds: int): + await asyncio.sleep(seconds) + typer.echo( + f"Waited for {seconds} seconds before running command using asyncio (customized)" + ) + + +if __name__ == "__main__": + app() diff --git a/docs_src/asynchronous/tutorial007.py b/docs_src/asynchronous/tutorial007.py new file mode 100644 index 0000000000..bd91855d1b --- /dev/null +++ b/docs_src/asynchronous/tutorial007.py @@ -0,0 +1,22 @@ +import asyncio + +import trio +import typer + +app = typer.Typer() + + +@app.command() +async def wait_trio(seconds: int): + await trio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds using trio (default)") + + +@app.command(async_runner=asyncio.run) +async def wait_asyncio(seconds: int): + await asyncio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds using asyncio (custom runner)") + + +if __name__ == "__main__": + app() diff --git a/docs_src/asynchronous/tutorial008.py b/docs_src/asynchronous/tutorial008.py new file mode 100644 index 0000000000..e8ffd25f1c --- /dev/null +++ b/docs_src/asynchronous/tutorial008.py @@ -0,0 +1,22 @@ +import asyncio + +import anyio +import typer + +app = typer.Typer(async_runner=lambda c: anyio.run(lambda: c, backend="asyncio")) + + +@app.command() +async def wait_anyio(seconds: int): + await anyio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds using asyncio via anyio") + + +@app.command() +async def wait_asyncio(seconds: int): + await asyncio.sleep(seconds) + typer.echo(f"Waited for {seconds} seconds using asyncio") + + +if __name__ == "__main__": + app() diff --git a/mkdocs.yml b/mkdocs.yml index a9a661098b..b8a32bb82c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -133,6 +133,7 @@ nav: - tutorial/testing.md - tutorial/using-click.md - tutorial/package.md + - tutorial/async.md - tutorial/exceptions.md - tutorial/one-file-per-command.md - tutorial/typer-command.md diff --git a/requirements-tests.txt b/requirements-tests.txt index db41b866d4..9d57720cdb 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -5,8 +5,12 @@ pytest-cov >=2.10.0,<8.0.0 coverage[toml] >=6.2,<8.0 pytest-xdist >=1.32.0,<4.0.0 pytest-sugar >=0.9.4,<1.2.0 +pytest-mock >=3.11.1 mypy ==1.14.1 ruff ==0.14.5 +anyio >=4.0.0 +filelock >=3.4.0,<4.0.0 +trio >=0.22 # Needed explicitly by typer-slim rich >=10.11.0 shellingham >=1.3.0 diff --git a/tests/test_completion/conftest.py b/tests/test_completion/conftest.py new file mode 100644 index 0000000000..5da91079ff --- /dev/null +++ b/tests/test_completion/conftest.py @@ -0,0 +1,26 @@ +import pytest +from filelock import FileLock + + +@pytest.fixture +def bashrc_lock(): + with FileLock(".bachrc.lock"): + yield + + +@pytest.fixture +def zshrc_lock(): + with FileLock(".zsh.lock"): + yield + + +@pytest.fixture +def fish_config_lock(): + with FileLock(".fish.lock"): + yield + + +@pytest.fixture +def powershell_profile_lock(): + with FileLock(".powershell.lock"): + yield diff --git a/tests/test_completion/for_testing/commands_help_tutorial001_async.py b/tests/test_completion/for_testing/commands_help_tutorial001_async.py new file mode 100644 index 0000000000..9d83a5f657 --- /dev/null +++ b/tests/test_completion/for_testing/commands_help_tutorial001_async.py @@ -0,0 +1,58 @@ +import typer + +app = typer.Typer(help="Awesome CLI user manager.") + + +@app.command() +async def create(username: str): + """ + Create a new user with USERNAME. + """ + typer.echo(f"Creating user: {username}") + + +@app.command() +async def delete( + username: str, + # force: bool = typer.Option(False, "--force") + force: bool = typer.Option( + False, + prompt="Are you sure you want to delete the user?", + help="Force deletion without confirmation.", + ), +): + """ + Delete a user with USERNAME. + + If --force is not used, will ask for confirmation. + """ + typer.echo(f"Deleting user: {username}" if force else "Operation cancelled") + + +@app.command() +async def delete_all( + force: bool = typer.Option( + False, + prompt="Are you sure you want to delete ALL users?", + help="Force deletion without confirmation.", + ), +): + """ + Delete ALL users in the database. + + If --force is not used, will ask for confirmation. + """ + + typer.echo("Deleting all users" if force else "Operation cancelled") + + +@app.command() +async def init(): + """ + Initialize the users database. + """ + typer.echo("Initializing user database") + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/for_testing/commands_index_tutorial002_async.py b/tests/test_completion/for_testing/commands_index_tutorial002_async.py new file mode 100644 index 0000000000..6afab4aaae --- /dev/null +++ b/tests/test_completion/for_testing/commands_index_tutorial002_async.py @@ -0,0 +1,17 @@ +import typer + +app = typer.Typer() + + +@app.command() +def create(): + typer.echo("Creating user: Hiro Hamada") + + +@app.command() +def delete(): + typer.echo("Deleting user: Hiro Hamada") + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/test_commands_index_tutorial002_async.py b/tests/test_completion/test_commands_index_tutorial002_async.py new file mode 100644 index 0000000000..6e5d924a09 --- /dev/null +++ b/tests/test_completion/test_commands_index_tutorial002_async.py @@ -0,0 +1,21 @@ +from typer.testing import CliRunner + +from tests.test_completion.for_testing import ( + commands_index_tutorial002_async as async_mod, +) + +app = async_mod.app + +runner = CliRunner() + + +def test_create(): + result = runner.invoke(app, ["create"]) + assert result.exit_code == 0 + assert "Creating user: Hiro Hamada" in result.output + + +def test_delete(): + result = runner.invoke(app, ["delete"]) + assert result.exit_code == 0 + assert "Deleting user: Hiro Hamada" in result.output diff --git a/tests/test_completion/test_completion.py b/tests/test_completion/test_completion.py index 4e1586e51d..a0f5b4cff6 100644 --- a/tests/test_completion/test_completion.py +++ b/tests/test_completion/test_completion.py @@ -3,14 +3,20 @@ import sys from pathlib import Path -from docs_src.commands.index import tutorial001 as mod +import pytest + +from docs_src.asynchronous import tutorial001 as async_mod +from docs_src.commands.index import tutorial001 as sync_mod from ..utils import needs_bash, needs_linux, requires_completion_permission +mod_params = ("mod", (sync_mod, async_mod)) + @needs_bash @needs_linux -def test_show_completion(): +@pytest.mark.parametrize(*mod_params) +def test_show_completion(bashrc_lock, mod): result = subprocess.run( [ "bash", @@ -27,7 +33,8 @@ def test_show_completion(): @needs_bash @needs_linux @requires_completion_permission -def test_install_completion(): +@pytest.mark.parametrize(*mod_params) +def test_install_completion(bashrc_lock, mod): bash_completion_path: Path = Path.home() / ".bashrc" text = "" if bash_completion_path.is_file(): # pragma: no cover @@ -50,7 +57,8 @@ def test_install_completion(): assert "Completion will take effect once you restart the terminal" in result.stdout -def test_completion_invalid_instruction(): +@pytest.mark.parametrize(*mod_params) +def test_completion_invalid_instruction(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, @@ -64,7 +72,8 @@ def test_completion_invalid_instruction(): assert "Invalid completion instruction." in result.stderr -def test_completion_source_bash(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_bash(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, @@ -80,7 +89,8 @@ def test_completion_source_bash(): ) -def test_completion_source_invalid_shell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_invalid_shell(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, @@ -93,7 +103,8 @@ def test_completion_source_invalid_shell(): assert "Shell xxx not supported." in result.stderr -def test_completion_source_invalid_instruction(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_invalid_instruction(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, @@ -106,7 +117,8 @@ def test_completion_source_invalid_instruction(): assert 'Completion instruction "explode" not supported.' in result.stderr -def test_completion_source_zsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_zsh(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, @@ -119,7 +131,8 @@ def test_completion_source_zsh(): assert "compdef _tutorial001py_completion tutorial001.py" in result.stdout -def test_completion_source_fish(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_fish(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, @@ -132,7 +145,8 @@ def test_completion_source_fish(): assert "complete --command tutorial001.py --no-files" in result.stdout -def test_completion_source_powershell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_powershell(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, @@ -148,7 +162,8 @@ def test_completion_source_powershell(): ) -def test_completion_source_pwsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_pwsh(bashrc_lock, mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__], capture_output=True, diff --git a/tests/test_completion/test_completion_complete.py b/tests/test_completion/test_completion_complete.py index ea37f68546..0ce22a9753 100644 --- a/tests/test_completion/test_completion_complete.py +++ b/tests/test_completion/test_completion_complete.py @@ -2,48 +2,60 @@ import subprocess import sys -from docs_src.commands.help import tutorial001 as mod +import pytest +from docs_src.commands.help import tutorial001 as sync_mod -def test_completion_complete_subcommand_bash(): +from .for_testing import commands_help_tutorial001_async as async_mod + +mod_params = ("mod", (sync_mod, async_mod)) + + +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_bash(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_bash", - "COMP_WORDS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_bash", + "COMP_WORDS": f"{filename} del", "COMP_CWORD": "1", }, ) assert "delete\ndelete-all" in result.stdout -def test_completion_complete_subcommand_bash_invalid(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_bash_invalid(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_bash", - "COMP_WORDS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_bash", + "COMP_WORDS": f"{filename} del", "COMP_CWORD": "42", }, ) assert "create\ndelete\ndelete-all\ninit" in result.stdout -def test_completion_complete_subcommand_zsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_zsh(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_zsh", - "_TYPER_COMPLETE_ARGS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": f"{filename} del", }, ) assert ( @@ -52,29 +64,33 @@ def test_completion_complete_subcommand_zsh(): ) in result.stdout -def test_completion_complete_subcommand_zsh_files(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_zsh_files(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_zsh", - "_TYPER_COMPLETE_ARGS": "tutorial001.py delete ", + f"_{filename.upper()}_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": f"{filename} delete ", }, ) assert ("_files") in result.stdout -def test_completion_complete_subcommand_fish(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_fish(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_fish", - "_TYPER_COMPLETE_ARGS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": f"{filename} del", "_TYPER_COMPLETE_FISH_ACTION": "get-args", }, ) @@ -84,45 +100,51 @@ def test_completion_complete_subcommand_fish(): ) -def test_completion_complete_subcommand_fish_should_complete(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_fish_should_complete(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_fish", - "_TYPER_COMPLETE_ARGS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": f"{filename} del", "_TYPER_COMPLETE_FISH_ACTION": "is-args", }, ) assert result.returncode == 0 -def test_completion_complete_subcommand_fish_should_complete_no(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_fish_should_complete_no(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_fish", - "_TYPER_COMPLETE_ARGS": "tutorial001.py delete ", + f"_{filename.upper()}_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": "{filename} delete ", "_TYPER_COMPLETE_FISH_ACTION": "is-args", }, ) assert result.returncode != 0 -def test_completion_complete_subcommand_powershell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_powershell(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_powershell", - "_TYPER_COMPLETE_ARGS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": f"{filename} del", }, ) assert ( @@ -130,15 +152,17 @@ def test_completion_complete_subcommand_powershell(): ) in result.stdout -def test_completion_complete_subcommand_pwsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_pwsh(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_pwsh", - "_TYPER_COMPLETE_ARGS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_pwsh", + "_TYPER_COMPLETE_ARGS": f"{filename} del", }, ) assert ( @@ -146,15 +170,17 @@ def test_completion_complete_subcommand_pwsh(): ) in result.stdout -def test_completion_complete_subcommand_noshell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_noshell(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL001.PY_COMPLETE": "complete_noshell", - "_TYPER_COMPLETE_ARGS": "tutorial001.py del", + f"_{filename.upper()}_COMPLETE": "complete_noshell", + "_TYPER_COMPLETE_ARGS": f"{filename} del", }, ) - assert ("") in result.stdout + assert "" in result.stdout diff --git a/tests/test_completion/test_completion_complete_no_help.py b/tests/test_completion/test_completion_complete_no_help.py index 4ac2bf98de..4af97a8eae 100644 --- a/tests/test_completion/test_completion_complete_no_help.py +++ b/tests/test_completion/test_completion_complete_no_help.py @@ -2,62 +2,76 @@ import subprocess import sys -from docs_src.commands.index import tutorial002 as mod +import pytest +from docs_src.commands.index import tutorial002 as sync_mod -def test_completion_complete_subcommand_zsh(): +from .for_testing import commands_index_tutorial002_async as async_mod + +mod_params = ("mod", (sync_mod, async_mod)) + + +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_zsh(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL002.PY_COMPLETE": "complete_zsh", - "_TYPER_COMPLETE_ARGS": "tutorial002.py ", + f"_{filename.upper()}_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": f"{filename} ", }, ) assert "create" in result.stdout assert "delete" in result.stdout -def test_completion_complete_subcommand_fish(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_fish(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL002.PY_COMPLETE": "complete_fish", - "_TYPER_COMPLETE_ARGS": "tutorial002.py ", + f"_{filename.upper()}_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": f"{filename} ", "_TYPER_COMPLETE_FISH_ACTION": "get-args", }, ) assert "create\ndelete" in result.stdout -def test_completion_complete_subcommand_powershell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_powershell(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL002.PY_COMPLETE": "complete_powershell", - "_TYPER_COMPLETE_ARGS": "tutorial002.py ", + f"_{filename.upper()}_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": f"{filename} ", }, ) - assert ("create::: \ndelete::: ") in result.stdout + assert "create::: \ndelete::: " in result.stdout -def test_completion_complete_subcommand_pwsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_complete_subcommand_pwsh(mod): + filename = os.path.basename(mod.__file__) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, encoding="utf-8", env={ **os.environ, - "_TUTORIAL002.PY_COMPLETE": "complete_pwsh", - "_TYPER_COMPLETE_ARGS": "tutorial002.py ", + f"_{filename.upper()}_COMPLETE": "complete_pwsh", + "_TYPER_COMPLETE_ARGS": f"{filename} ", }, ) - assert ("create::: \ndelete::: ") in result.stdout + assert "create::: \ndelete::: " in result.stdout diff --git a/tests/test_completion/test_completion_help_tutorial001.py b/tests/test_completion/test_completion_help_tutorial001.py new file mode 100644 index 0000000000..2530d0089e --- /dev/null +++ b/tests/test_completion/test_completion_help_tutorial001.py @@ -0,0 +1,57 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from tests.test_completion.for_testing import ( + commands_help_tutorial001_async as async_mod, +) + +app = async_mod.app + +runner = CliRunner() + + +def test_init(): + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0 + assert "Initializing user database" in result.output + + +def test_delete(): + result = runner.invoke(app, ["delete", "--force", "Simone"]) + assert result.exit_code == 0 + assert "Deleting user: Simone" in result.output + + +def test_delete_no_force(): + result = runner.invoke(app, ["delete", "Simone"]) + # assert result.exit_code == 0 + assert "Are you sure you want to delete the user?" in result.output + + +def test_delete_all(): + result = runner.invoke(app, ["delete-all", "--force"]) + assert result.exit_code == 0 + assert "Deleting all users" in result.output + + +def test_delete_all_no_force(): + result = runner.invoke(app, ["delete-all"]) + # assert result.exit_code == 0 + assert "Are you sure you want to delete ALL users?" in result.output + + +def test_create(): + result = runner.invoke(app, ["create", "Simone"]) + assert result.exit_code == 0 + assert "Creating user: Simone" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", async_mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_completion/test_completion_install.py b/tests/test_completion/test_completion_install.py index 873c1416e9..3d8787d973 100644 --- a/tests/test_completion/test_completion_install.py +++ b/tests/test_completion/test_completion_install.py @@ -4,21 +4,26 @@ from pathlib import Path from unittest import mock +import pytest import shellingham import typer from typer.testing import CliRunner -from docs_src.commands.index import tutorial001 as mod +from docs_src.asynchronous import tutorial001 as async_mod +from docs_src.commands.index import tutorial001 as sync_mod from ..utils import requires_completion_permission +mod_params = ("mod", (sync_mod, async_mod)) + runner = CliRunner() app = typer.Typer() -app.command()(mod.main) +app.command()(sync_mod.main) @requires_completion_permission -def test_completion_install_no_shell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_install_no_shell(mod): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, "--install-completion"], capture_output=True, @@ -32,7 +37,8 @@ def test_completion_install_no_shell(): @requires_completion_permission -def test_completion_install_bash(): +@pytest.mark.parametrize(*mod_params) +def test_completion_install_bash(bashrc_lock, mod): bash_completion_path: Path = Path.home() / ".bashrc" text = "" if bash_completion_path.is_file(): @@ -72,7 +78,8 @@ def test_completion_install_bash(): @requires_completion_permission -def test_completion_install_zsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_install_zsh(zshrc_lock, mod): completion_path: Path = Path.home() / ".zshrc" text = "" if not completion_path.is_file(): # pragma: no cover @@ -110,7 +117,8 @@ def test_completion_install_zsh(): @requires_completion_permission -def test_completion_install_fish(): +@pytest.mark.parametrize(*mod_params) +def test_completion_install_fish(fish_config_lock, mod): script_path = Path(mod.__file__) completion_path: Path = ( Path.home() / f".config/fish/completions/{script_path.name}.fish" @@ -140,7 +148,8 @@ def test_completion_install_fish(): @requires_completion_permission -def test_completion_install_powershell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_install_powershell(powershell_profile_lock, mod): completion_path: Path = ( Path.home() / ".config/powershell/Microsoft.PowerShell_profile.ps1" ) diff --git a/tests/test_completion/test_completion_show.py b/tests/test_completion/test_completion_show.py index 10d8b6ff39..68bf3f876f 100644 --- a/tests/test_completion/test_completion_show.py +++ b/tests/test_completion/test_completion_show.py @@ -3,18 +3,22 @@ import sys from unittest import mock +import pytest import shellingham import typer from typer.testing import CliRunner -from docs_src.commands.index import tutorial001 as mod +from docs_src.asynchronous import tutorial001 as async_mod +from docs_src.commands.index import tutorial001 as sync_mod runner = CliRunner() -app = typer.Typer() -app.command()(mod.main) +mod_params = ("mod", (sync_mod, async_mod)) -def test_completion_show_no_shell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_show_no_shell(mod): + app = typer.Typer() + app.command()(mod.main) result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, "--show-completion"], capture_output=True, @@ -27,7 +31,10 @@ def test_completion_show_no_shell(): assert "Option '--show-completion' requires an argument" in result.stderr -def test_completion_show_bash(): +@pytest.mark.parametrize(*mod_params) +def test_completion_show_bash(mod): + app = typer.Typer() + app.command()(mod.main) result = subprocess.run( [ sys.executable, @@ -51,7 +58,10 @@ def test_completion_show_bash(): ) -def test_completion_source_zsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_zsh(mod): + app = typer.Typer() + app.command()(mod.main) result = subprocess.run( [ sys.executable, @@ -72,7 +82,10 @@ def test_completion_source_zsh(): assert "compdef _tutorial001py_completion tutorial001.py" in result.stdout -def test_completion_source_fish(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_fish(mod): + app = typer.Typer() + app.command()(mod.main) result = subprocess.run( [ sys.executable, @@ -93,7 +106,10 @@ def test_completion_source_fish(): assert "complete --command tutorial001.py --no-files" in result.stdout -def test_completion_source_powershell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_powershell(mod): + app = typer.Typer() + app.command()(mod.main) result = subprocess.run( [ sys.executable, @@ -117,7 +133,10 @@ def test_completion_source_powershell(): ) -def test_completion_source_pwsh(): +@pytest.mark.parametrize(*mod_params) +def test_completion_source_pwsh(mod): + app = typer.Typer() + app.command()(mod.main) result = subprocess.run( [ sys.executable, @@ -141,7 +160,10 @@ def test_completion_source_pwsh(): ) -def test_completion_show_invalid_shell(): +@pytest.mark.parametrize(*mod_params) +def test_completion_show_invalid_shell(mod): + app = typer.Typer() + app.command()(mod.main) with mock.patch.object( shellingham, "detect_shell", return_value=("xshell", "/usr/bin/xshell") ): diff --git a/tests/test_tutorial/test_asynchronous/__init__.py b/tests/test_tutorial/test_asynchronous/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial001.py b/tests/test_tutorial/test_asynchronous/test_tutorial001.py new file mode 100644 index 0000000000..d612f6fb65 --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial001.py @@ -0,0 +1,38 @@ +import subprocess +import sys +from unittest.mock import patch + +import typer +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial001 as async_mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(async_mod.main) + + +@patch("importlib.util.find_spec") +def test_asyncio(mocker): + mocker.side_effect = [True, None] + result = runner.invoke(app) + assert result.exit_code == 0 + assert "Hello World\n" in result.output + + +@patch("importlib.util.find_spec") +def test_asyncio_no_anyio(mocker): + mocker.side_effect = [None, None] + result = runner.invoke(app) + assert result.exit_code == 0 + assert "Hello World\n" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", async_mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial002.py b/tests/test_tutorial/test_asynchronous/test_tutorial002.py new file mode 100644 index 0000000000..8e0ab9cc3b --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial002.py @@ -0,0 +1,28 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial002 as async_mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(async_mod.main) + + +def test_anyio(): + result = runner.invoke(app) + + assert result.exit_code == 0 + assert "Hello World\n" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", async_mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial003.py b/tests/test_tutorial/test_asynchronous/test_tutorial003.py new file mode 100644 index 0000000000..e27e2894c8 --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial003.py @@ -0,0 +1,25 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial003 as mod + +app = mod.app + +runner = CliRunner() + + +def test_wait(): + result = runner.invoke(app, ["2"]) + assert result.exit_code == 0 + assert "Waited for 2 seconds" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial004.py b/tests/test_tutorial/test_asynchronous/test_tutorial004.py new file mode 100644 index 0000000000..846081862a --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial004.py @@ -0,0 +1,25 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial004 as mod + +app = mod.app + +runner = CliRunner() + + +def test_wait(): + result = runner.invoke(app, ["2"]) + assert result.exit_code == 0 + assert "Waited for 2 seconds" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial005.py b/tests/test_tutorial/test_asynchronous/test_tutorial005.py new file mode 100644 index 0000000000..5c9f94c5b1 --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial005.py @@ -0,0 +1,25 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial005 as mod + +app = mod.app + +runner = CliRunner() + + +def test_wait(): + result = runner.invoke(app, ["2"]) + assert result.exit_code == 0 + assert "Waited for 2 seconds" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial006.py b/tests/test_tutorial/test_asynchronous/test_tutorial006.py new file mode 100644 index 0000000000..50d7e4e914 --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial006.py @@ -0,0 +1,28 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial006 as mod + +app = mod.app + +runner = CliRunner() + + +def test_wait_asyncio(): + result = runner.invoke(app, ["1", "wait-trio", "2"]) + assert result.exit_code == 0 + assert ( + "Waited for 1 seconds before running command using asyncio (customized)\n" + "Waited for 2 seconds using trio (default)" in result.output + ) + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial007.py b/tests/test_tutorial/test_asynchronous/test_tutorial007.py new file mode 100644 index 0000000000..66a0400a7d --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial007.py @@ -0,0 +1,31 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial007 as mod + +app = mod.app + +runner = CliRunner() + + +def test_wait_trio(): + result = runner.invoke(app, ["wait-trio", "2"]) + assert result.exit_code == 0 + assert "Waited for 2 seconds using trio (default)" in result.output + + +def test_wait_asyncio(): + result = runner.invoke(app, ["wait-asyncio", "2"]) + assert result.exit_code == 0 + assert "Waited for 2 seconds using asyncio (custom runner)" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_asynchronous/test_tutorial008.py b/tests/test_tutorial/test_asynchronous/test_tutorial008.py new file mode 100644 index 0000000000..9a446c5871 --- /dev/null +++ b/tests/test_tutorial/test_asynchronous/test_tutorial008.py @@ -0,0 +1,31 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.asynchronous import tutorial008 as mod + +app = mod.app + +runner = CliRunner() + + +def test_wait_anyio(): + result = runner.invoke(app, ["wait-anyio", "2"]) + assert result.exit_code == 0 + assert "Waited for 2 seconds using asyncio via anyio" in result.output + + +def test_wait_asyncio(): + result = runner.invoke(app, ["wait-asyncio", "2"]) + assert result.exit_code == 0 + assert "Waited for 2 seconds using asyncio" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/typer/main.py b/typer/main.py index 71a25e6c4b..55e759f316 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1,3 +1,4 @@ +import importlib import inspect import os import platform @@ -7,11 +8,22 @@ import traceback from datetime import datetime from enum import Enum -from functools import update_wrapper +from functools import update_wrapper, wraps 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, + Coroutine, + Dict, + List, + Optional, + Sequence, + Tuple, + Type, + Union, +) from uuid import UUID import click @@ -31,6 +43,7 @@ from .models import ( AnyType, ArgumentInfo, + AsyncRunner, CommandFunctionType, CommandInfo, Default, @@ -45,11 +58,31 @@ ParameterInfo, ParamMeta, Required, + SyncCommandFunctionType, TyperInfo, TyperPath, ) from .utils import get_params_from_function + +def run_as_sync(coroutine: Coroutine[Any, Any, Any]) -> Any: + """ + When using anyio we try to predict the appropriate backend assuming that + alternative async engines are not mixed and only installed on demand. + """ + + if importlib.util.find_spec("anyio"): + import anyio + + backend = "trio" if importlib.util.find_spec("trio") else "asyncio" + + return anyio.run(lambda: coroutine, backend=backend) + else: + import asyncio + + return asyncio.run(coroutine) + + _original_except_hook = sys.excepthook _typer_developer_exception_attr_name = "__typer_developer_exception__" @@ -132,6 +165,7 @@ def __init__( hidden: bool = Default(False), deprecated: bool = Default(False), add_completion: bool = True, + async_runner: AsyncRunner = run_as_sync, # Rich settings rich_markup_mode: MarkupMode = Default(DEFAULT_MARKUP_MODE), rich_help_panel: Union[str, None] = Default(None), @@ -168,6 +202,22 @@ def __init__( self.registered_groups: List[TyperInfo] = [] self.registered_commands: List[CommandInfo] = [] self.registered_callback: Optional[TyperInfo] = None + self.async_runner = async_runner + + def to_sync( + self, f: CommandFunctionType, async_runner: Optional[AsyncRunner] + ) -> SyncCommandFunctionType: # type: ignore + if inspect.iscoroutinefunction(f): + run_sync: AsyncRunner = async_runner or self.async_runner + + @wraps(f) + def execute(*args: Any, **kwargs: Any) -> Any: + return run_sync(f(*args, **kwargs)) + + else: + execute = f + + return execute # type: ignore def callback( self, @@ -178,6 +228,7 @@ def callback( subcommand_metavar: Optional[str] = Default(None), chain: bool = Default(False), result_callback: Optional[Callable[..., Any]] = Default(None), + async_runner: Optional[AsyncRunner] = None, # Command context_settings: Optional[Dict[Any, Any]] = Default(None), help: Optional[str] = Default(None), @@ -199,7 +250,7 @@ def decorator(f: CommandFunctionType) -> CommandFunctionType: chain=chain, result_callback=result_callback, context_settings=context_settings, - callback=f, + callback=self.to_sync(f, async_runner), help=help, epilog=epilog, short_help=short_help, @@ -209,7 +260,7 @@ def decorator(f: CommandFunctionType) -> CommandFunctionType: deprecated=deprecated, rich_help_panel=rich_help_panel, ) - return f + return f # self.to_sync(f, async_runner) # TESTING return decorator @@ -227,6 +278,7 @@ def command( no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, + async_runner: Optional[AsyncRunner] = None, # Rich settings rich_help_panel: Union[str, None] = Default(None), ) -> Callable[[CommandFunctionType], CommandFunctionType]: @@ -239,7 +291,7 @@ def decorator(f: CommandFunctionType) -> CommandFunctionType: name=name, cls=cls, context_settings=context_settings, - callback=f, + callback=self.to_sync(f, async_runner), help=help, epilog=epilog, short_help=short_help, @@ -252,7 +304,10 @@ def decorator(f: CommandFunctionType) -> CommandFunctionType: rich_help_panel=rich_help_panel, ) ) - return f + # test = f() + # test2 = self.to_sync(f, async_runner) + # test3 = test2() + return f # self.to_sync(f, async_runner) # TESTING return decorator @@ -1071,7 +1126,9 @@ def wrapper(ctx: click.Context, args: List[str], incomplete: Optional[str]) -> A return wrapper -def run(function: Callable[..., Any]) -> None: +def run( + function: Union[Callable[..., Any], Callable[..., Coroutine[Any, Any, Any]]], +) -> None: app = Typer(add_completion=False) app.command()(function) app() diff --git a/typer/models.py b/typer/models.py index e0bddb965b..5cbea37876 100644 --- a/typer/models.py +++ b/typer/models.py @@ -4,6 +4,7 @@ TYPE_CHECKING, Any, Callable, + Coroutine, Dict, List, Optional, @@ -69,7 +70,22 @@ def __bool__(self) -> bool: DefaultType = TypeVar("DefaultType") -CommandFunctionType = TypeVar("CommandFunctionType", bound=Callable[..., Any]) + +AsyncCommandFunctionType = TypeVar( + "AsyncCommandFunctionType", bound=Callable[..., Coroutine[Any, Any, Any]] +) + +SyncCommandFunctionType = TypeVar("SyncCommandFunctionType", bound=Callable[..., Any]) + + +CommandFunctionType = TypeVar( + "CommandFunctionType", + Callable[..., Any], + Callable[..., Coroutine[Any, Any, Any]], +) + + +AsyncRunner = Callable[[Coroutine[Any, Any, Any]], Any] def Default(value: DefaultType) -> DefaultType: