From b97298181bd2a932be70ba417b9a712294fa327c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 30 Aug 2021 09:57:12 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20Click=208=20w?= =?UTF-8?q?hile=20keeping=20compatibility=20with=20Click=207=20(#317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 9 +- docs/tutorial/using-click.md | 12 +- pyproject.toml | 4 +- scripts/get-pwsh-activate.sh | 1 + scripts/test.sh | 1 - tests/assets/__init__.py | 0 tests/assets/compat_click7_8.py | 29 ++ tests/test_compat/__init__.py | 0 tests/test_compat/test_option_get_help.py | 41 ++ tests/test_completion/test_completion.py | 2 +- .../test_completion_install.py | 6 +- tests/test_completion/test_completion_show.py | 6 +- tests/test_others.py | 20 +- .../test_callback/test_tutorial001.py | 6 +- .../test_help/test_tutorial001.py | 24 +- .../test_options/test_tutorial001.py | 30 +- .../test_tutorial002.py | 7 +- .../test_tutorial001.py | 7 +- .../test_completion/test_tutorial008.py | 3 +- .../test_completion/test_tutorial009.py | 3 +- .../test_prompt/test_tutorial003.py | 7 +- .../test_bool/test_tutorial001.py | 7 +- .../test_bool/test_tutorial002.py | 7 +- .../test_datetime/test_tutorial001.py | 5 +- .../test_enum/test_tutorial001.py | 6 +- .../test_index/test_tutorial001.py | 7 +- .../test_number/test_tutorial001.py | 19 +- .../test_number/test_tutorial002.py | 6 +- .../test_uuid/test_tutorial001.py | 6 +- .../test_using_click/test_tutorial003.py | 5 +- typer/_completion_click7.py | 157 +++++++ typer/_completion_click8.py | 192 ++++++++ typer/_completion_shared.py | 244 ++++++++++ typer/completion.py | 414 +++-------------- typer/core.py | 419 ++++++++++++++++-- typer/main.py | 29 +- typer/models.py | 33 +- typer/params.py | 27 +- typer/testing.py | 4 +- typer/utils.py | 6 + 40 files changed, 1343 insertions(+), 468 deletions(-) create mode 100644 scripts/get-pwsh-activate.sh create mode 100644 tests/assets/__init__.py create mode 100644 tests/assets/compat_click7_8.py create mode 100644 tests/test_compat/__init__.py create mode 100644 tests/test_compat/test_option_get_help.py create mode 100644 typer/_completion_click7.py create mode 100644 typer/_completion_click8.py create mode 100644 typer/_completion_shared.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d12a96bf2..403856f367 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,18 +11,25 @@ jobs: strategy: matrix: python-version: [3.6, 3.7, 3.8] + click-7: [true, false] fail-fast: false steps: - uses: actions/checkout@v2 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install Flit run: pip install flit - name: Install Dependencies run: flit install --deps=develop --symlink + - name: Install Click 7 + if: matrix.click-7 + run: pip install "click<8.0.0" + - name: Lint + if: ${{ matrix.python-version != '3.6' && matrix.click-7 == false }} + run: bash scripts/lint.sh - name: Test run: bash scripts/test.sh - name: Upload coverage diff --git a/docs/tutorial/using-click.md b/docs/tutorial/using-click.md index eb9c9e1312..36a25d4c43 100644 --- a/docs/tutorial/using-click.md +++ b/docs/tutorial/using-click.md @@ -99,17 +99,7 @@ $ python main.py // Notice we have both subcommands, top and hello Usage: main.py [OPTIONS] COMMAND [ARGS]... - Typer app, including Click subapp - -Options: - --install-completion Install completion for the current shell. - --show-completion Show completion for the current shell, to copy it or customize the installation. - - --help Show this message and exit. - -Commands: - hello Simple program that greets NAME for a total of COUNT times. - top Top level command, form Typer +Error: Missing command. // Call the Typer part $ python main.py top diff --git a/pyproject.toml b/pyproject.toml index 0d121888f8..bdf673841b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ "License :: OSI Approved :: MIT License" ] requires = [ - "click >= 7.1.1, <7.2.0" + "click >= 7.1.1, <9.0.0" ] description-file = "README.md" requires-python = ">=3.6" @@ -43,7 +43,7 @@ test = [ "coverage >=5.2,<6.0", "pytest-xdist >=1.32.0,<2.0.0", "pytest-sugar >=0.9.4,<0.10.0", - "mypy ==0.782", + "mypy ==0.910", "black >=19.10b0,<20.0b0", "isort >=5.0.6,<6.0.0" ] diff --git a/scripts/get-pwsh-activate.sh b/scripts/get-pwsh-activate.sh new file mode 100644 index 0000000000..78e8f3c153 --- /dev/null +++ b/scripts/get-pwsh-activate.sh @@ -0,0 +1 @@ +curl https://raw.githubusercontent.com/python/cpython/main/Lib/venv/scripts/common/Activate.ps1 -o Activate.ps1 diff --git a/scripts/test.sh b/scripts/test.sh index 9f0ecb39a3..77dd74d738 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,6 +4,5 @@ set -e set -x bash ./scripts/test-files.sh -bash ./scripts/lint.sh # Use xdist-pytest --forked to ensure modified sys.path to import relative modules in examples keeps working pytest --cov=typer --cov=tests --cov=docs_src --cov-report=term-missing --cov-report=xml -o console_output_style=progress --forked --numprocesses=auto ${@} diff --git a/tests/assets/__init__.py b/tests/assets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/assets/compat_click7_8.py b/tests/assets/compat_click7_8.py new file mode 100644 index 0000000000..7ec8447f7f --- /dev/null +++ b/tests/assets/compat_click7_8.py @@ -0,0 +1,29 @@ +from typing import List + +import click +import typer + +app = typer.Typer() + + +def shell_complete( + ctx: click.Context, param: click.Parameter, incomplete: str +) -> List[str]: + return ["Jonny"] + + +@app.command(context_settings={"auto_envvar_prefix": "TEST"}) +def main( + name: str = typer.Option("John", hidden=True), + lastname: str = typer.Option("Doe", "/lastname", show_default="Mr. Doe"), + age: int = typer.Option(lambda: 42, show_default=True), + nickname: str = typer.Option("", shell_complete=shell_complete), +): + """ + Say hello. + """ + typer.echo(f"Hello {name} {lastname}, it seems you have {age}, {nickname}") + + +if __name__ == "__main__": + app() diff --git a/tests/test_compat/__init__.py b/tests/test_compat/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_compat/test_option_get_help.py b/tests/test_compat/test_option_get_help.py new file mode 100644 index 0000000000..e8e1eac8f8 --- /dev/null +++ b/tests/test_compat/test_option_get_help.py @@ -0,0 +1,41 @@ +import os +import subprocess + +from typer.testing import CliRunner + +from tests.assets import compat_click7_8 as mod + +runner = CliRunner() + + +def test_hidden_option(): + result = runner.invoke(mod.app, ["--help"]) + assert result.exit_code == 0 + assert "Say hello" in result.output + assert "--name" not in result.output + assert "/lastname" in result.output + assert "TEST_LASTNAME" in result.output + assert "(dynamic)" in result.output + + +def test_coverage_call(): + result = runner.invoke(mod.app) + assert result.exit_code == 0 + assert "Hello John Doe, it seems you have 42" in result.output + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_COMPAT_CLICK7_8.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "compat_click7_8.py --nickname ", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + # TODO: when deprecating Click 7, remove second option + assert "Jonny" in result.stdout or "_files" in result.stdout diff --git a/tests/test_completion/test_completion.py b/tests/test_completion/test_completion.py index f0e11c34a9..d7ec6d3c6b 100644 --- a/tests/test_completion/test_completion.py +++ b/tests/test_completion/test_completion.py @@ -106,7 +106,7 @@ def test_completion_source_invalid_instruction(): "_TYPER_COMPLETE_TESTING": "True", }, ) - assert "Hello World" in result.stdout + assert 'Completion instruction "explode" not supported.' in result.stderr def test_completion_source_zsh(): diff --git a/tests/test_completion/test_completion_install.py b/tests/test_completion/test_completion_install.py index d60bc8acb3..f1acda1bee 100644 --- a/tests/test_completion/test_completion_install.py +++ b/tests/test_completion/test_completion_install.py @@ -26,7 +26,11 @@ def test_completion_install_no_shell(): "_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION": "True", }, ) - assert "Error: --install-completion option requires an argument" in result.stderr + # TODO: when deprecating Click 7, remove second option + assert ( + "Error: Option '--install-completion' requires an argument" in result.stderr + or "Error: --install-completion option requires an argument" in result.stderr + ) def test_completion_install_bash(): diff --git a/tests/test_completion/test_completion_show.py b/tests/test_completion/test_completion_show.py index 156302cefb..2efb6d8af9 100644 --- a/tests/test_completion/test_completion_show.py +++ b/tests/test_completion/test_completion_show.py @@ -16,7 +16,11 @@ def test_completion_show_no_shell(): "_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION": "True", }, ) - assert "Error: --show-completion option requires an argument" in result.stderr + # TODO: when deprecating Click 7, remove second option + assert ( + "Error: Option '--show-completion' requires an argument" in result.stderr + or "Error: --show-completion option requires an argument" in result.stderr + ) def test_completion_show_bash(): diff --git a/tests/test_others.py b/tests/test_others.py index 897941a082..2060a86e7b 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -141,11 +141,14 @@ def test_completion_untyped_parameters(): }, ) assert "info name is: completion_no_types.py" in result.stderr - assert "args is: ['--name', 'Sebastian', '--name']" in result.stderr + # TODO: when deprecating Click 7, remove second option + assert ( + "args is: []" in result.stderr + or "args is: ['--name', 'Sebastian', '--name']" in result.stderr + ) assert "incomplete is: Ca" in result.stderr assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout - assert '"Sebastian":"The type hints guy."' in result.stdout result = subprocess.run( ["coverage", "run", str(file_path)], @@ -171,11 +174,14 @@ def test_completion_untyped_parameters_different_order_correct_names(): }, ) assert "info name is: completion_no_types_order.py" in result.stderr - assert "args is: ['--name', 'Sebastian', '--name']" in result.stderr + # TODO: when deprecating Click 7, remove second option + assert ( + "args is: []" in result.stderr + or "args is: ['--name', 'Sebastian', '--name']" in result.stderr + ) assert "incomplete is: Ca" in result.stderr assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout - assert '"Sebastian":"The type hints guy."' in result.stdout result = subprocess.run( ["coverage", "run", str(file_path)], @@ -213,8 +219,12 @@ def main(arg1, arg2: int, arg3: "int", arg4: bool = False, arg5: "bool" = False) typer.echo(f"arg5: {type(arg5)} {arg5}") result = runner.invoke(app, ["Hello", "2", "invalid"]) + # TODO: when deprecating Click 7, remove second option + assert ( - "Error: Invalid value for 'ARG3': invalid is not a valid integer" + "Error: Invalid value for 'ARG3': 'invalid' is not a valid integer" + in result.stdout + or "Error: Invalid value for 'ARG3': invalid is not a valid integer" in result.stdout ) result = runner.invoke(app, ["Hello", "2", "3", "--arg4", "--arg5"]) diff --git a/tests/test_tutorial/test_commands/test_callback/test_tutorial001.py b/tests/test_tutorial/test_commands/test_callback/test_tutorial001.py index af494eddf3..6d4dc1ff77 100644 --- a/tests/test_tutorial/test_commands/test_callback/test_tutorial001.py +++ b/tests/test_tutorial/test_commands/test_callback/test_tutorial001.py @@ -49,7 +49,11 @@ def test_delete_verbose(): def test_wrong_verbose(): result = runner.invoke(app, ["delete", "--verbose", "Camila"]) assert result.exit_code != 0 - assert "Error: no such option: --verbose" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Error: No such option: --verbose" in result.output + or "Error: no such option: --verbose" in result.output + ) def test_script(): diff --git a/tests/test_tutorial/test_commands/test_help/test_tutorial001.py b/tests/test_tutorial/test_commands/test_help/test_tutorial001.py index e378885c27..232369ca80 100644 --- a/tests/test_tutorial/test_commands/test_help/test_tutorial001.py +++ b/tests/test_tutorial/test_commands/test_help/test_tutorial001.py @@ -66,28 +66,44 @@ def test_create(): def test_delete(): result = runner.invoke(app, ["delete", "Camila"], input="y\n") assert result.exit_code == 0 - assert "Are you sure you want to delete the user? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete the user? [y/n]:" in result.output + or "Are you sure you want to delete the user? [y/N]:" in result.output + ) assert "Deleting user: Camila" in result.output def test_no_delete(): result = runner.invoke(app, ["delete", "Camila"], input="n\n") assert result.exit_code == 0 - assert "Are you sure you want to delete the user? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete the user? [y/n]:" in result.output + or "Are you sure you want to delete the user? [y/N]:" in result.output + ) assert "Operation cancelled" in result.output def test_delete_all(): result = runner.invoke(app, ["delete-all"], input="y\n") assert result.exit_code == 0 - assert "Are you sure you want to delete ALL users? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete ALL users? [y/n]:" in result.output + or "Are you sure you want to delete ALL users? [y/N]:" in result.output + ) assert "Deleting all users" in result.output def test_no_delete_all(): result = runner.invoke(app, ["delete-all"], input="n\n") assert result.exit_code == 0 - assert "Are you sure you want to delete ALL users? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete ALL users? [y/n]:" in result.output + or "Are you sure you want to delete ALL users? [y/N]:" in result.output + ) assert "Operation cancelled" in result.output diff --git a/tests/test_tutorial/test_commands/test_options/test_tutorial001.py b/tests/test_tutorial/test_commands/test_options/test_tutorial001.py index 69c112577e..3b8b5ca90a 100644 --- a/tests/test_tutorial/test_commands/test_options/test_tutorial001.py +++ b/tests/test_tutorial/test_commands/test_options/test_tutorial001.py @@ -28,35 +28,55 @@ def test_create(): def test_delete(): result = runner.invoke(app, ["delete", "Camila"], input="y\n") assert result.exit_code == 0 - assert "Are you sure you want to delete the user? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete the user? [y/n]:" in result.output + or "Are you sure you want to delete the user? [y/N]:" in result.output + ) assert "Deleting user: Camila" in result.output def test_no_delete(): result = runner.invoke(app, ["delete", "Camila"], input="n\n") assert result.exit_code == 0 - assert "Are you sure you want to delete the user? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete the user? [y/n]:" in result.output + or "Are you sure you want to delete the user? [y/N]:" in result.output + ) assert "Operation cancelled" in result.output def test_delete_all(): result = runner.invoke(app, ["delete-all"], input="y\n") assert result.exit_code == 0 - assert "Are you sure you want to delete ALL users? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete ALL users? [y/n]:" in result.output + or "Are you sure you want to delete ALL users? [y/N]:" in result.output + ) assert "Deleting all users" in result.output def test_no_delete_all(): result = runner.invoke(app, ["delete-all"], input="n\n") assert result.exit_code == 0 - assert "Are you sure you want to delete ALL users? [y/N]:" in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete ALL users? [y/n]:" in result.output + or "Are you sure you want to delete ALL users? [y/N]:" in result.output + ) assert "Operation cancelled" in result.output def test_delete_all_force(): result = runner.invoke(app, ["delete-all", "--force"]) assert result.exit_code == 0 - assert "Are you sure you want to delete ALL users? [y/N]:" not in result.output + # TODO: when deprecating Click 7, remove second option + assert ( + "Are you sure you want to delete ALL users? [y/n]:" not in result.output + or "Are you sure you want to delete ALL users? [y/N]:" not in result.output + ) assert "Deleting all users" in result.output diff --git a/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py b/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py index 5cef200d14..d97a744ea1 100644 --- a/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py +++ b/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py @@ -29,7 +29,12 @@ def test_defaults(): def test_invalid_args(): result = runner.invoke(app, ["Draco", "Hagrid"]) assert result.exit_code != 0 - assert "Error: argument names takes 3 values" in result.stdout + # TODO: when deprecating Click 7, remove second option + + assert ( + "Error: Argument 'names' takes 3 values" in result.stdout + or "Error: argument names takes 3 values" in result.stdout + ) def test_valid_args(): diff --git a/tests/test_tutorial/test_multiple_values/test_options_with_multiple_values/test_tutorial001.py b/tests/test_tutorial/test_multiple_values/test_options_with_multiple_values/test_tutorial001.py index 0b942e0731..a6862f6f92 100644 --- a/tests/test_tutorial/test_multiple_values/test_options_with_multiple_values/test_tutorial001.py +++ b/tests/test_tutorial/test_multiple_values/test_options_with_multiple_values/test_tutorial001.py @@ -34,7 +34,12 @@ def test_user_2(): def test_invalid_user(): result = runner.invoke(app, ["--user", "Camila", "50"]) assert result.exit_code != 0 - assert "Error: --user option requires 3 arguments" in result.output + # TODO: when deprecating Click 7, remove second option + + assert ( + "Error: Option '--user' requires 3 arguments" in result.output + or "Error: --user option requires 3 arguments" in result.output + ) def test_script(): diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial008.py b/tests/test_tutorial/test_options/test_completion/test_tutorial008.py index 959072b79f..597d8f002c 100644 --- a/tests/test_tutorial/test_options/test_completion/test_tutorial008.py +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial008.py @@ -28,7 +28,8 @@ def test_completion(): assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout assert '"Sebastian":"The type hints guy."' in result.stdout - assert "['--name']" in result.stderr + # TODO: when deprecating Click 7, remove second option + assert "[]" in result.stderr or "['--name']" in result.stderr def test_1(): diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial009.py b/tests/test_tutorial/test_options/test_completion/test_tutorial009.py index 90a0cc08ec..8e5290806e 100644 --- a/tests/test_tutorial/test_options/test_completion/test_tutorial009.py +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial009.py @@ -28,7 +28,8 @@ def test_completion(): assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout assert '"Sebastian":"The type hints guy."' not in result.stdout - assert "['--name', 'Sebastian', '--name']" in result.stderr + # TODO: when deprecating Click 7, remove second option + assert "[]" in result.stderr or "['--name', 'Sebastian', '--name']" in result.stderr def test_1(): diff --git a/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py b/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py index 61ec646be7..7ef29de19e 100644 --- a/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py +++ b/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py @@ -22,7 +22,12 @@ def test_prompt_not_equal(): app, input="Old Project\nNew Spice\nOld Project\nOld Project\n" ) assert result.exit_code == 0 - assert "Error: the two entered values do not match" in result.output + # TODO: when deprecating Click 7, remove second option + + assert ( + "Error: The two entered values do not match" in result.output + or "Error: the two entered values do not match" in result.output + ) assert "Deleting project Old Project" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py index ba1c28ac63..344d65047f 100644 --- a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py @@ -33,7 +33,12 @@ def test_force(): def test_invalid_no_force(): result = runner.invoke(app, ["--no-force"]) assert result.exit_code != 0 - assert "Error: no such option: --no-force" in result.output + # TODO: when deprecating Click 7, remove second option + + assert ( + "Error: No such option: --no-force" in result.output + or "Error: no such option: --no-force" in result.output + ) def test_script(): diff --git a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial002.py b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial002.py index f58ed5f9ef..098b196849 100644 --- a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial002.py +++ b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial002.py @@ -39,7 +39,12 @@ def test_reject(): def test_invalid_no_accept(): result = runner.invoke(app, ["--no-accept"]) assert result.exit_code != 0 - assert "Error: no such option: --no-accept" in result.output + # TODO: when deprecating Click 7, remove second option + + assert ( + "Error: No such option: --no-accept" in result.output + or "Error: no such option: --no-accept" in result.output + ) def test_script(): diff --git a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py index eae6c61632..60b69ef7ee 100644 --- a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py @@ -27,8 +27,11 @@ def test_main(): def test_invalid(): result = runner.invoke(app, ["july-19-1989"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option assert ( - "Error: Invalid value for 'BIRTH:[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]': invalid datetime format: july-19-1989. (choose from %Y-%m-%d, %Y-%m-%dT%H:%M:%S, %Y-%m-%d %H:%M:%S)" + "Error: Invalid value for 'BIRTH:[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]': 'july-19-1989' does not match the formats '%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S'" + in result.output + or "Error: Invalid value for 'BIRTH:[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]': invalid datetime format: july-19-1989. (choose from %Y-%m-%d, %Y-%m-%dT%H:%M:%S, %Y-%m-%d %H:%M:%S)" in result.output ) diff --git a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py index 69f56af7a2..9944da5b17 100644 --- a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py @@ -26,8 +26,12 @@ def test_main(): def test_invalid(): result = runner.invoke(app, ["--network", "capsule"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option + assert ( - "Error: Invalid value for '--network': invalid choice: capsule. (choose from simple, conv, lstm)" + "Error: Invalid value for '--network': 'capsule' is not one of 'simple', 'conv', 'lstm'" + in result.output + or "Error: Invalid value for '--network': invalid choice: capsule. (choose from simple, conv, lstm)" in result.output ) diff --git a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py index 5b2664cc49..2e1a3002da 100644 --- a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py @@ -32,8 +32,13 @@ def test_params(): def test_invalid(): result = runner.invoke(app, ["Camila", "--age", "15.3"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option + assert ( - "Error: Invalid value for '--age': 15.3 is not a valid integer" in result.output + "Error: Invalid value for '--age': '15.3' is not a valid integer" + in result.output + or "Error: Invalid value for '--age': 15.3 is not a valid integer" + in result.output ) diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py index abb569a4ec..64d2f2a5df 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py @@ -29,8 +29,13 @@ def test_params(): def test_invalid_id(): result = runner.invoke(app, ["1002"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option assert ( - "Error: Invalid value for 'ID': 1002 is not in the valid range of 0 to 1000." + ( + "Error: Invalid value for 'ID': 1002 is not in the range 0<=x<=1000." + in result.output + ) + or "Error: Invalid value for 'ID': 1002 is not in the valid range of 0 to 1000." in result.output ) @@ -38,8 +43,12 @@ def test_invalid_id(): def test_invalid_age(): result = runner.invoke(app, ["5", "--age", "15"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option + assert ( - "Error: Invalid value for '--age': 15 is smaller than the minimum valid value 18." + "Error: Invalid value for '--age': 15 is not in the range x>=18" + in result.output + or "Error: Invalid value for '--age': 15 is smaller than the minimum valid value 18." in result.output ) @@ -47,8 +56,12 @@ def test_invalid_age(): def test_invalid_score(): result = runner.invoke(app, ["5", "--age", "20", "--score", "100.5"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option + assert ( - "Error: Invalid value for '--score': 100.5 is bigger than the maximum valid value 100." + "Error: Invalid value for '--score': 100.5 is not in the range x<=100." + in result.output + or "Error: Invalid value for '--score': 100.5 is bigger than the maximum valid value 100." in result.output ) diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py index 2e73df50ec..c0bc226459 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py @@ -14,8 +14,12 @@ def test_invalid_id(): result = runner.invoke(app, ["1002"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option + assert ( - "Error: Invalid value for 'ID': 1002 is not in the valid range of 0 to 1000." + "Error: Invalid value for 'ID': 1002 is not in the range 0<=x<=1000" + in result.output + or "Error: Invalid value for 'ID': 1002 is not in the valid range of 0 to 1000." in result.output ) diff --git a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py index b172fcb696..dff324440f 100644 --- a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py @@ -21,8 +21,12 @@ def test_main(): def test_invalid_uuid(): result = runner.invoke(app, ["7479706572-72756c6573"]) assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option + assert ( - "Error: Invalid value for 'USER_ID': 7479706572-72756c6573 is not a valid UUID value" + "Error: Invalid value for 'USER_ID': '7479706572-72756c6573' is not a valid UUID" + in result.output + or "Error: Invalid value for 'USER_ID': 7479706572-72756c6573 is not a valid UUID value" in result.output ) diff --git a/tests/test_tutorial/test_using_click/test_tutorial003.py b/tests/test_tutorial/test_using_click/test_tutorial003.py index 75a72dfe14..eadd93ee9e 100644 --- a/tests/test_tutorial/test_using_click/test_tutorial003.py +++ b/tests/test_tutorial/test_using_click/test_tutorial003.py @@ -9,9 +9,8 @@ def test_cli(): result = runner.invoke(mod.typer_click_object, []) - assert "Usage" in result.stdout - assert "hello" in result.stdout - assert "top" in result.stdout + # TODO: when deprecating Click 7, remove second option + assert "Error: Missing command" in result.stdout or "Usage" in result.stdout def test_typer(): diff --git a/typer/_completion_click7.py b/typer/_completion_click7.py new file mode 100644 index 0000000000..9f4ad73f30 --- /dev/null +++ b/typer/_completion_click7.py @@ -0,0 +1,157 @@ +import os +import re +import sys + +import click +import click._bashcomplete + +from ._completion_shared import get_completion_script + +try: + import shellingham +except ImportError: # pragma: nocover + shellingham = None + + +_click_patched = False + + +def do_bash_complete(cli: click.Command, prog_name: str) -> bool: + cwords = click.parser.split_arg_string(os.getenv("COMP_WORDS", "")) + cword = int(os.getenv("COMP_CWORD", 0)) + args = cwords[1:cword] + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + for item in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): + click.echo(item[0]) + return True + + +def do_zsh_complete(cli: click.Command, prog_name: str) -> bool: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + cwords = click.parser.split_arg_string(completion_args) + args = cwords[1:] + if args and not completion_args.endswith(" "): + incomplete = args[-1] + args = args[:-1] + else: + incomplete = "" + + def escape(s: str) -> str: + return ( + s.replace('"', '""') + .replace("'", "''") + .replace("$", "\\$") + .replace("`", "\\`") + ) + + res = [] + for item, help in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): + if help: + res.append(f'"{escape(item)}":"{escape(help)}"') + else: + res.append(f'"{escape(item)}"') + if res: + args_str = "\n".join(res) + click.echo(f"_arguments '*: :(({args_str}))'") + else: + click.echo("_files") + return True + + +def do_fish_complete(cli: click.Command, prog_name: str) -> bool: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + complete_action = os.getenv("_TYPER_COMPLETE_FISH_ACTION", "") + cwords = click.parser.split_arg_string(completion_args) + args = cwords[1:] + if args and not completion_args.endswith(" "): + incomplete = args[-1] + args = args[:-1] + else: + incomplete = "" + show_args = [] + for item, help in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): + if help: + formatted_help = re.sub(r"\s", " ", help) + show_args.append(f"{item}\t{formatted_help}") + else: + show_args.append(item) + if complete_action == "get-args": + if show_args: + for arg in show_args: + click.echo(arg) + elif complete_action == "is-args": + if show_args: + # Activate complete args (no files) + sys.exit(0) + else: + # Deactivate complete args (allow files) + sys.exit(1) + return True + + +def do_powershell_complete(cli: click.Command, prog_name: str) -> bool: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + incomplete = os.getenv("_TYPER_COMPLETE_WORD_TO_COMPLETE", "") + cwords = click.parser.split_arg_string(completion_args) + args = cwords[1:] + for item, help in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): + click.echo(f"{item}:::{help or ' '}") + + return True + + +def do_shell_complete(*, cli: click.Command, prog_name: str, shell: str) -> bool: + if shell == "bash": + return do_bash_complete(cli, prog_name) + elif shell == "zsh": + return do_zsh_complete(cli, prog_name) + elif shell == "fish": + return do_fish_complete(cli, prog_name) + elif shell in {"powershell", "pwsh"}: + return do_powershell_complete(cli, prog_name) + return False + + +def handle_shell_complete( + cli: click.Command, prog_name: str, complete_var: str, complete_instr: str +) -> bool: + if "_" not in complete_instr: + click.echo("Invalid completion instruction.", err=True) + sys.exit(1) + command, shell = complete_instr.split("_", 1) + if command == "source": + click.echo( + get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + ) + return True + elif command == "complete": + return do_shell_complete(cli=cli, prog_name=prog_name, shell=shell) + click.echo(f'Completion instruction "{command}" not supported.', err=True) + return False + + +def completion_init() -> None: + global _click_patched + if not _click_patched: + testing = os.getenv("_TYPER_COMPLETE_TESTING") + + def testing_handle_shell_complete( + cli: click.Command, prog_name: str, complete_var: str, complete_instr: str + ) -> bool: + result = handle_shell_complete(cli, prog_name, complete_var, complete_instr) + if result: + # Avoid fast_exit(1) in Click so Coverage can finish + sys.exit(1) + return result + + if testing: + click._bashcomplete.bashcomplete = testing_handle_shell_complete + else: + click._bashcomplete.bashcomplete = handle_shell_complete + _click_patched = True diff --git a/typer/_completion_click8.py b/typer/_completion_click8.py new file mode 100644 index 0000000000..54e2b03d6f --- /dev/null +++ b/typer/_completion_click8.py @@ -0,0 +1,192 @@ +import os +import re +import sys +from typing import Any, Dict, List, Tuple + +import click +import click.parser +import click.shell_completion + +from ._completion_shared import ( + COMPLETION_SCRIPT_BASH, + COMPLETION_SCRIPT_FISH, + COMPLETION_SCRIPT_POWER_SHELL, + COMPLETION_SCRIPT_ZSH, + Shells, +) + +try: + import shellingham +except ImportError: # pragma: nocover + shellingham = None + + +class BashComplete(click.shell_completion.BashComplete): + name = Shells.bash.value + source_template = COMPLETION_SCRIPT_BASH + + def source_vars(self) -> Dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> Tuple[List[str], str]: + cwords = click.parser.split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + # TODO: Explore replicating the new behavior from Click, with item types and + # triggering completion for files and directories + # return f"{item.type},{item.value}" + return f"{item.value}" + + def complete(self) -> str: + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + out = [self.format_completion(item) for item in completions] + return "\n".join(out) + + +class ZshComplete(click.shell_completion.ZshComplete): + name = Shells.zsh.value + source_template = COMPLETION_SCRIPT_ZSH + + def source_vars(self) -> Dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> Tuple[List[str], str]: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + cwords = click.parser.split_arg_string(completion_args) + args = cwords[1:] + if args and not completion_args.endswith(" "): + incomplete = args[-1] + args = args[:-1] + else: + incomplete = "" + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + def escape(s: str) -> str: + return ( + s.replace('"', '""') + .replace("'", "''") + .replace("$", "\\$") + .replace("`", "\\`") + ) + + # TODO: Explore replicating the new behavior from Click, pay attention to + # the difference with and without escape + # return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}" + if item.help: + return f'"{escape(item.value)}":"{escape(item.help)}"' + else: + return f'"{escape(item.value)}"' + + def complete(self) -> str: + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + res = [self.format_completion(item) for item in completions] + if res: + args_str = "\n".join(res) + return f"_arguments '*: :(({args_str}))'" + else: + return "_files" + + +class FishComplete(click.shell_completion.FishComplete): + name = Shells.fish.value + source_template = COMPLETION_SCRIPT_FISH + + def source_vars(self) -> Dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> Tuple[List[str], str]: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + cwords = click.parser.split_arg_string(completion_args) + args = cwords[1:] + if args and not completion_args.endswith(" "): + incomplete = args[-1] + args = args[:-1] + else: + incomplete = "" + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + # TODO: Explore replicating the new behavior from Click, pay attention to + # the difference with and without formatted help + # if item.help: + # return f"{item.type},{item.value}\t{item.help}" + + # return f"{item.type},{item.value} + if item.help: + formatted_help = re.sub(r"\s", " ", item.help) + return f"{item.value}\t{formatted_help}" + else: + return f"{item.value}" + + def complete(self) -> str: + complete_action = os.getenv("_TYPER_COMPLETE_FISH_ACTION", "") + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + show_args = [self.format_completion(item) for item in completions] + if complete_action == "get-args": + if show_args: + return "\n".join(show_args) + elif complete_action == "is-args": + if show_args: + # Activate complete args (no files) + sys.exit(0) + else: + # Deactivate complete args (allow files) + sys.exit(1) + return "" # pragma: no cover + + +class PowerShellComplete(click.shell_completion.ShellComplete): + name = Shells.powershell.value + source_template = COMPLETION_SCRIPT_POWER_SHELL + + def source_vars(self) -> Dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> Tuple[List[str], str]: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + incomplete = os.getenv("_TYPER_COMPLETE_WORD_TO_COMPLETE", "") + cwords = click.parser.split_arg_string(completion_args) + args = cwords[1:] + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + return f"{item.value}:::{item.help or ' '}" + + +def completion_init() -> None: + click.shell_completion.add_completion_class(BashComplete, Shells.bash.value) + click.shell_completion.add_completion_class(ZshComplete, Shells.zsh.value) + click.shell_completion.add_completion_class(FishComplete, Shells.fish.value) + click.shell_completion.add_completion_class( + PowerShellComplete, Shells.powershell.value + ) + click.shell_completion.add_completion_class(PowerShellComplete, Shells.pwsh.value) diff --git a/typer/_completion_shared.py b/typer/_completion_shared.py new file mode 100644 index 0000000000..7cbaf98d75 --- /dev/null +++ b/typer/_completion_shared.py @@ -0,0 +1,244 @@ +import os +import re +import subprocess +import sys +from enum import Enum +from pathlib import Path +from typing import Optional, Tuple + +import click + +try: + import shellingham +except ImportError: # pragma: nocover + shellingham = None + + +from typing import Optional + + +class Shells(str, Enum): + bash = "bash" + zsh = "zsh" + fish = "fish" + powershell = "powershell" + pwsh = "pwsh" + + +COMPLETION_SCRIPT_BASH = """ +%(complete_func)s() { + local IFS=$'\n' + COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ + COMP_CWORD=$COMP_CWORD \\ + %(autocomplete_var)s=complete_bash $1 ) ) + return 0 +} + +complete -o default -F %(complete_func)s %(prog_name)s +""" + +COMPLETION_SCRIPT_ZSH = """ +#compdef %(prog_name)s + +%(complete_func)s() { + eval $(env _TYPER_COMPLETE_ARGS="${words[1,$CURRENT]}" %(autocomplete_var)s=complete_zsh %(prog_name)s) +} + +compdef %(complete_func)s %(prog_name)s +""" + +COMPLETION_SCRIPT_FISH = 'complete --command %(prog_name)s --no-files --arguments "(env %(autocomplete_var)s=complete_fish _TYPER_COMPLETE_FISH_ACTION=get-args _TYPER_COMPLETE_ARGS=(commandline -cp) %(prog_name)s)" --condition "env %(autocomplete_var)s=complete_fish _TYPER_COMPLETE_FISH_ACTION=is-args _TYPER_COMPLETE_ARGS=(commandline -cp) %(prog_name)s"' + +COMPLETION_SCRIPT_POWER_SHELL = """ +Import-Module PSReadLine +Set-PSReadLineKeyHandler -Chord Tab -Function MenuComplete +$scriptblock = { + param($wordToComplete, $commandAst, $cursorPosition) + $Env:%(autocomplete_var)s = "complete_powershell" + $Env:_TYPER_COMPLETE_ARGS = $commandAst.ToString() + $Env:_TYPER_COMPLETE_WORD_TO_COMPLETE = $wordToComplete + %(prog_name)s | ForEach-Object { + $commandArray = $_ -Split ":::" + $command = $commandArray[0] + $helpString = $commandArray[1] + [System.Management.Automation.CompletionResult]::new( + $command, $command, 'ParameterValue', $helpString) + } + $Env:%(autocomplete_var)s = "" + $Env:_TYPER_COMPLETE_ARGS = "" + $Env:_TYPER_COMPLETE_WORD_TO_COMPLETE = "" +} +Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock $scriptblock +""" + +_completion_scripts = { + "bash": COMPLETION_SCRIPT_BASH, + "zsh": COMPLETION_SCRIPT_ZSH, + "fish": COMPLETION_SCRIPT_FISH, + "powershell": COMPLETION_SCRIPT_POWER_SHELL, + "pwsh": COMPLETION_SCRIPT_POWER_SHELL, +} + +# TODO: Probably refactor this, copied from Click 7.x +_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]") + + +def get_completion_script(*, prog_name: str, complete_var: str, shell: str) -> str: + cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) + script = _completion_scripts.get(shell) + if script is None: + click.echo(f"Shell {shell} not supported.", err=True) + sys.exit(1) + return ( + script + % dict( + complete_func="_{}_completion".format(cf_name), + prog_name=prog_name, + autocomplete_var=complete_var, + ) + ).strip() + + +def install_bash(*, prog_name: str, complete_var: str, shell: str) -> Path: + # Ref: https://github.com/scop/bash-completion#faq + # It seems bash-completion is the official completion system for bash: + # Ref: https://www.gnu.org/software/bash/manual/html_node/A-Programmable-Completion-Example.html + # But installing in the locations from the docs doesn't seem to have effect + completion_path = Path.home() / f".bash_completions/{prog_name}.sh" + rc_path = Path.home() / ".bashrc" + rc_path.parent.mkdir(parents=True, exist_ok=True) + rc_content = "" + if rc_path.is_file(): + rc_content = rc_path.read_text() + completion_init_lines = [f"source {completion_path}"] + for line in completion_init_lines: + if line not in rc_content: # pragma: nocover + rc_content += f"\n{line}" + rc_content += "\n" + rc_path.write_text(rc_content) + # Install completion + completion_path.parent.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + completion_path.write_text(script_content) + return completion_path + + +def install_zsh(*, prog_name: str, complete_var: str, shell: str) -> Path: + # Setup Zsh and load ~/.zfunc + zshrc_path = Path.home() / ".zshrc" + zshrc_path.parent.mkdir(parents=True, exist_ok=True) + zshrc_content = "" + if zshrc_path.is_file(): + zshrc_content = zshrc_path.read_text() + completion_init_lines = [ + "autoload -Uz compinit", + "compinit", + "zstyle ':completion:*' menu select", + "fpath+=~/.zfunc", + ] + for line in completion_init_lines: + if line not in zshrc_content: # pragma: nocover + zshrc_content += f"\n{line}" + zshrc_content += "\n" + zshrc_path.write_text(zshrc_content) + # Install completion under ~/.zfunc/ + path_obj = Path.home() / f".zfunc/_{prog_name}" + path_obj.parent.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + path_obj.write_text(script_content) + return path_obj + + +def install_fish(*, prog_name: str, complete_var: str, shell: str) -> Path: + path_obj = Path.home() / f".config/fish/completions/{prog_name}.fish" + parent_dir: Path = path_obj.parent + parent_dir.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + path_obj.write_text(f"{script_content}\n") + return path_obj + + +def install_powershell(*, prog_name: str, complete_var: str, shell: str) -> Path: + subprocess.run( + [ + shell, + "-Command", + "Set-ExecutionPolicy", + "Unrestricted", + "-Scope", + "CurrentUser", + ] + ) + result = subprocess.run( + [shell, "-NoProfile", "-Command", "echo", "$profile"], + check=True, + stdout=subprocess.PIPE, + ) + if result.returncode != 0: # pragma: nocover + click.echo("Couldn't get PowerShell user profile", err=True) + raise click.exceptions.Exit(result.returncode) + path_str = "" + if isinstance(result.stdout, str): # pragma: nocover + path_str = result.stdout + if isinstance(result.stdout, bytes): + try: + # PowerShell would be predominant in Windows + path_str = result.stdout.decode("windows-1252") + except UnicodeDecodeError: # pragma: nocover + try: + path_str = result.stdout.decode("utf8") + except UnicodeDecodeError: + click.echo("Couldn't decode the path automatically", err=True) + raise click.exceptions.Exit(1) + path_obj = Path(path_str.strip()) + parent_dir: Path = path_obj.parent + parent_dir.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + with path_obj.open(mode="a") as f: + f.write(f"{script_content}\n") + return path_obj + + +def install( + shell: Optional[str] = None, + prog_name: Optional[str] = None, + complete_var: Optional[str] = None, +) -> Tuple[str, Path]: + prog_name = prog_name or click.get_current_context().find_root().info_name + assert prog_name + if complete_var is None: + complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) + test_disable_detection = os.getenv("_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION") + if shell is None and shellingham is not None and not test_disable_detection: + shell, _ = shellingham.detect_shell() + if shell == "bash": + installed_path = install_bash( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell == "zsh": + installed_path = install_zsh( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell == "fish": + installed_path = install_fish( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell in {"powershell", "pwsh"}: + installed_path = install_powershell( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + else: + click.echo(f"Shell {shell} is not supported.") + raise click.exceptions.Exit(1) diff --git a/typer/completion.py b/typer/completion.py index 4a0b01251d..98183c5791 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -1,17 +1,13 @@ import os -import re -import subprocess import sys -from enum import Enum -from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, Dict, Tuple import click -import click._bashcomplete +from ._completion_shared import Shells, get_completion_script, install from .models import ParamMeta from .params import Option -from .utils import get_params_from_function +from .utils import _get_click_major, get_params_from_function try: import shellingham @@ -53,9 +49,11 @@ def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any prog_name = ctx.find_root().info_name assert prog_name complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) + shell = "" + test_disable_detection = os.getenv("_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION") if isinstance(value, str): shell = value - elif shellingham: + elif shellingham and not test_disable_detection: shell, _ = shellingham.detect_shell() script_content = get_completion_script( prog_name=prog_name, complete_var=complete_var, shell=shell @@ -64,14 +62,6 @@ def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any sys.exit(0) -class Shells(str, Enum): - bash = "bash" - zsh = "zsh" - fish = "fish" - powershell = "powershell" - pwsh = "pwsh" - - # Create a fake command function to extract the completion parameters def _install_completion_placeholder_function( install_completion: bool = Option( @@ -111,359 +101,59 @@ def _install_completion_no_auto_placeholder_function( pass # pragma no cover -COMPLETION_SCRIPT_BASH = """ -%(complete_func)s() { - local IFS=$'\n' - COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ - COMP_CWORD=$COMP_CWORD \\ - %(autocomplete_var)s=complete_bash $1 ) ) - return 0 -} - -complete -o default -F %(complete_func)s %(prog_name)s -""" - -COMPLETION_SCRIPT_ZSH = """ -#compdef %(prog_name)s - -%(complete_func)s() { - eval $(env _TYPER_COMPLETE_ARGS="${words[1,$CURRENT]}" %(autocomplete_var)s=complete_zsh %(prog_name)s) -} - -compdef %(complete_func)s %(prog_name)s -""" - -COMPLETION_SCRIPT_FISH = 'complete --command %(prog_name)s --no-files --arguments "(env %(autocomplete_var)s=complete_fish _TYPER_COMPLETE_FISH_ACTION=get-args _TYPER_COMPLETE_ARGS=(commandline -cp) %(prog_name)s)" --condition "env %(autocomplete_var)s=complete_fish _TYPER_COMPLETE_FISH_ACTION=is-args _TYPER_COMPLETE_ARGS=(commandline -cp) %(prog_name)s"' - -COMPLETION_SCRIPT_POWER_SHELL = """ -Import-Module PSReadLine -Set-PSReadLineKeyHandler -Chord Tab -Function MenuComplete -$scriptblock = { - param($wordToComplete, $commandAst, $cursorPosition) - $Env:%(autocomplete_var)s = "complete_powershell" - $Env:_TYPER_COMPLETE_ARGS = $commandAst.ToString() - $Env:_TYPER_COMPLETE_WORD_TO_COMPLETE = $wordToComplete - %(prog_name)s | ForEach-Object { - $commandArray = $_ -Split ":::" - $command = $commandArray[0] - $helpString = $commandArray[1] - [System.Management.Automation.CompletionResult]::new( - $command, $command, 'ParameterValue', $helpString) - } - $Env:%(autocomplete_var)s = "" - $Env:_TYPER_COMPLETE_ARGS = "" - $Env:_TYPER_COMPLETE_WORD_TO_COMPLETE = "" -} -Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock $scriptblock -""" - - -def install( - shell: Optional[str] = None, - prog_name: Optional[str] = None, - complete_var: Optional[str] = None, -) -> Tuple[str, Path]: - prog_name = prog_name or click.get_current_context().find_root().info_name - assert prog_name - if complete_var is None: - complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) - if shell is None and shellingham is not None: - shell, _ = shellingham.detect_shell() - if shell == "bash": - installed_path = install_bash( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - return shell, installed_path - elif shell == "zsh": - installed_path = install_zsh( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - return shell, installed_path - elif shell == "fish": - installed_path = install_fish( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - return shell, installed_path - elif shell in {"powershell", "pwsh"}: - installed_path = install_powershell( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - return shell, installed_path - else: - click.echo(f"Shell {shell} is not supported.") - raise click.exceptions.Exit(1) - - -def install_bash(*, prog_name: str, complete_var: str, shell: str) -> Path: - # Ref: https://github.com/scop/bash-completion#faq - # It seems bash-completion is the official completion system for bash: - # Ref: https://www.gnu.org/software/bash/manual/html_node/A-Programmable-Completion-Example.html - # But installing in the locations from the docs doesn't seem to have effect - completion_path = Path.home() / f".bash_completions/{prog_name}.sh" - rc_path = Path.home() / ".bashrc" - rc_path.parent.mkdir(parents=True, exist_ok=True) - rc_content = "" - if rc_path.is_file(): - rc_content = rc_path.read_text() - completion_init_lines = [f"source {completion_path}"] - for line in completion_init_lines: - if line not in rc_content: # pragma: nocover - rc_content += f"\n{line}" - rc_content += "\n" - rc_path.write_text(rc_content) - # Install completion - completion_path.parent.mkdir(parents=True, exist_ok=True) - script_content = get_completion_script( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - completion_path.write_text(script_content) - return completion_path - - -def install_zsh(*, prog_name: str, complete_var: str, shell: str) -> Path: - # Setup Zsh and load ~/.zfunc - zshrc_path = Path.home() / ".zshrc" - zshrc_path.parent.mkdir(parents=True, exist_ok=True) - zshrc_content = "" - if zshrc_path.is_file(): - zshrc_content = zshrc_path.read_text() - completion_init_lines = [ - "autoload -Uz compinit", - "compinit", - "zstyle ':completion:*' menu select", - "fpath+=~/.zfunc", - ] - for line in completion_init_lines: - if line not in zshrc_content: # pragma: nocover - zshrc_content += f"\n{line}" - zshrc_content += "\n" - zshrc_path.write_text(zshrc_content) - # Install completion under ~/.zfunc/ - path_obj = Path.home() / f".zfunc/_{prog_name}" - path_obj.parent.mkdir(parents=True, exist_ok=True) - script_content = get_completion_script( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - path_obj.write_text(script_content) - return path_obj - - -def install_fish(*, prog_name: str, complete_var: str, shell: str) -> Path: - path_obj = Path.home() / f".config/fish/completions/{prog_name}.fish" - parent_dir: Path = path_obj.parent - parent_dir.mkdir(parents=True, exist_ok=True) - script_content = get_completion_script( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - path_obj.write_text(f"{script_content}\n") - return path_obj - - -def install_powershell(*, prog_name: str, complete_var: str, shell: str) -> Path: - subprocess.run( - [ - shell, - "-Command", - "Set-ExecutionPolicy", - "Unrestricted", - "-Scope", - "CurrentUser", - ] - ) - result = subprocess.run( - [shell, "-NoProfile", "-Command", "echo", "$profile"], - check=True, - stdout=subprocess.PIPE, - ) - if result.returncode != 0: # pragma: nocover - click.echo("Couldn't get PowerShell user profile", err=True) - raise click.exceptions.Exit(result.returncode) - path_str = "" - if isinstance(result.stdout, str): # pragma: nocover - path_str = result.stdout - if isinstance(result.stdout, bytes): - try: - # PowerShell would be predominant in Windows - path_str = result.stdout.decode("windows-1252") - except UnicodeDecodeError: # pragma: nocover - try: - path_str = result.stdout.decode("utf8") - except UnicodeDecodeError: - click.echo("Couldn't decode the path automatically", err=True) - raise click.exceptions.Exit(1) - path_obj = Path(path_str.strip()) - parent_dir: Path = path_obj.parent - parent_dir.mkdir(parents=True, exist_ok=True) - script_content = get_completion_script( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - with path_obj.open(mode="a") as f: - f.write(f"{script_content}\n") - return path_obj - - -def do_bash_complete(cli: click.Command, prog_name: str) -> bool: - cwords = click.parser.split_arg_string(os.getenv("COMP_WORDS", "")) - cword = int(os.getenv("COMP_CWORD", 0)) - args = cwords[1:cword] - try: - incomplete = cwords[cword] - except IndexError: - incomplete = "" - - for item in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): - click.echo(item[0]) - return True - - -def do_zsh_complete(cli: click.Command, prog_name: str) -> bool: - completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") - cwords = click.parser.split_arg_string(completion_args) - args = cwords[1:] - if args and not completion_args.endswith(" "): - incomplete = args[-1] - args = args[:-1] - else: - incomplete = "" - - def escape(s: str) -> str: - return ( - s.replace('"', '""') - .replace("'", "''") - .replace("$", "\\$") - .replace("`", "\\`") - ) - - res = [] - for item, help in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): - if help: - res.append(f'"{escape(item)}":"{escape(help)}"') - else: - res.append(f'"{escape(item)}"') - if res: - args_str = "\n".join(res) - click.echo(f"_arguments '*: :(({args_str}))'") - else: - click.echo("_files") - return True - +def completion_init() -> None: + if _get_click_major() < 8: + from ._completion_click7 import completion_init -def do_fish_complete(cli: click.Command, prog_name: str) -> bool: - completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") - complete_action = os.getenv("_TYPER_COMPLETE_FISH_ACTION", "") - cwords = click.parser.split_arg_string(completion_args) - args = cwords[1:] - if args and not completion_args.endswith(" "): - incomplete = args[-1] - args = args[:-1] + completion_init() else: - incomplete = "" - show_args = [] - for item, help in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): - if help: - formatted_help = re.sub(r"\s", " ", help) - show_args.append(f"{item}\t{formatted_help}") - else: - show_args.append(item) - if complete_action == "get-args": - if show_args: - for arg in show_args: - click.echo(arg) - elif complete_action == "is-args": - if show_args: - # Activate complete args (no files) - sys.exit(0) - else: - # Deactivate complete args (allow files) - sys.exit(1) - return True - - -def do_powershell_complete(cli: click.Command, prog_name: str) -> bool: - completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") - incomplete = os.getenv("_TYPER_COMPLETE_WORD_TO_COMPLETE", "") - cwords = click.parser.split_arg_string(completion_args) - args = cwords[1:] - for item, help in click._bashcomplete.get_choices(cli, prog_name, args, incomplete): - click.echo(f"{item}:::{help or ' '}") - - return True - - -def do_shell_complete(*, cli: click.Command, prog_name: str, shell: str) -> bool: - if shell == "bash": - return do_bash_complete(cli, prog_name) - elif shell == "zsh": - return do_zsh_complete(cli, prog_name) - elif shell == "fish": - return do_fish_complete(cli, prog_name) - elif shell in {"powershell", "pwsh"}: - return do_powershell_complete(cli, prog_name) - return False - + from ._completion_click8 import completion_init + + completion_init() + + +# Re-implement Click's shell_complete to add error message with: +# Invalid completion instruction +# To use 7.x instruction style for compatibility +# And to add extra error messages, for compatibility with Typer in previous versions +# 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], + prog_name: str, + complete_var: str, + instruction: str, +) -> int: + import click + import click.shell_completion + + if "_" not in instruction: + click.echo("Invalid completion instruction.", err=True) + return 1 -_completion_scripts = { - "bash": COMPLETION_SCRIPT_BASH, - "zsh": COMPLETION_SCRIPT_ZSH, - "fish": COMPLETION_SCRIPT_FISH, - "powershell": COMPLETION_SCRIPT_POWER_SHELL, - "pwsh": COMPLETION_SCRIPT_POWER_SHELL, -} + # Click 8 changed the order/style of shell instructions from e.g. + # source_bash to bash_source + # Typer override to preserve the old style for compatibility + # Original in Click 8.x commented: + # shell, _, instruction = instruction.partition("_") + instruction, _, shell = instruction.partition("_") + # Typer override end + comp_cls = click.shell_completion.get_completion_class(shell) -def get_completion_script(*, prog_name: str, complete_var: str, shell: str) -> str: - cf_name = click._bashcomplete._invalid_ident_char_re.sub( - "", prog_name.replace("-", "_") - ) - script = _completion_scripts.get(shell) - if script is None: + if comp_cls is None: click.echo(f"Shell {shell} not supported.", err=True) - sys.exit(1) - return ( - script - % dict( - complete_func="_{}_completion".format(cf_name), - prog_name=prog_name, - autocomplete_var=complete_var, - ) - ).strip() - + return 1 -def handle_shell_complete( - cli: click.Command, prog_name: str, complete_var: str, complete_instr: str -) -> bool: - if "_" not in complete_instr: - click.echo("Invalid completion instruction.", err=True) - sys.exit(1) - command, shell = complete_instr.split("_", 1) - if command == "source": - click.echo( - get_completion_script( - prog_name=prog_name, complete_var=complete_var, shell=shell - ) - ) - return True - elif command == "complete": - return do_shell_complete(cli=cli, prog_name=prog_name, shell=shell) - return False + comp = comp_cls(cli, ctx_args, prog_name, complete_var) + if instruction == "source": + click.echo(comp.source()) + return 0 -def completion_init() -> None: - global _click_patched - if not _click_patched: - testing = os.getenv("_TYPER_COMPLETE_TESTING") - - def testing_handle_shell_complete( - cli: click.Command, prog_name: str, complete_var: str, complete_instr: str - ) -> bool: - result = handle_shell_complete(cli, prog_name, complete_var, complete_instr) - if result: - # Avoid fast_exit(1) in Click so Coverage can finish - sys.exit(1) - return result + if instruction == "complete": + click.echo(comp.complete()) + return 0 - if testing: - click._bashcomplete.bashcomplete = testing_handle_shell_complete - else: - click._bashcomplete.bashcomplete = handle_shell_complete - _click_patched = True + click.echo(f'Completion instruction "{instruction}" not supported.', err=True) + return 1 diff --git a/typer/core.py b/typer/core.py index b42a0c49d5..fc6ddb312e 100644 --- a/typer/core.py +++ b/typer/core.py @@ -1,7 +1,86 @@ import inspect -from typing import Any, Callable, List, Optional, Tuple, Union +import os +import sys +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Tuple, + Union, + cast, +) +import click import click.core +import click.formatting +import click.parser +import click.types + +from .utils import _get_click_major + +if TYPE_CHECKING: # pragma: no cover + import click.shell_completion + + +# TODO: when deprecating Click 7, remove this +def _typer_param_shell_complete( + self: click.core.Parameter, ctx: click.Context, incomplete: str +) -> List["click.shell_completion.CompletionItem"]: + if self._custom_shell_complete is not None: + results = self._custom_shell_complete(ctx, self, incomplete) + + if results and isinstance(results[0], str): + from click.shell_completion import CompletionItem + + results = [CompletionItem(c) for c in results] + + return cast(List["click.shell_completion.CompletionItem"], results) + + return self.type.shell_complete(ctx, self, incomplete) + + +def _typer_param_setup_autocompletion_compat( + self: click.Parameter, + *, + autocompletion: Optional[ + Callable[[click.Context, List[str], str], List[Union[Tuple[str, str], str]]] + ] = None, +) -> None: + if autocompletion is not None and self._custom_shell_complete is None: + import warnings + + warnings.warn( + "'autocompletion' is renamed to 'shell_complete'. The old name is" + " deprecated and will be removed in Click 8.1. See the docs about" + " 'Parameter' for information about new behavior.", + DeprecationWarning, + stacklevel=2, + ) + + def compat_autocompletion( + ctx: click.Context, param: click.core.Parameter, incomplete: str + ) -> List["click.shell_completion.CompletionItem"]: + from click.shell_completion import CompletionItem + + out = [] + + for c in autocompletion(ctx, [], incomplete): # type: ignore + if isinstance(c, tuple): + c = CompletionItem(c[0], help=c[1]) + elif isinstance(c, str): + c = CompletionItem(c) + + if c.value.startswith(incomplete): + out.append(c) + + return out + + self._custom_shell_complete = compat_autocompletion class TyperArgument(click.core.Argument): @@ -19,6 +98,12 @@ def __init__( expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[List["click.shell_completion.CompletionItem"], List[str]], + ] + ] = None, autocompletion: Optional[Callable[..., Any]] = None, # TyperArgument show_default: Union[bool, str] = True, @@ -32,21 +117,29 @@ def __init__( self.show_choices = show_choices self.show_envvar = show_envvar self.hidden = hidden - super().__init__( - param_decls=param_decls, - type=type, - required=required, - default=default, - callback=callback, - nargs=nargs, - metavar=metavar, - expose_value=expose_value, - is_eager=is_eager, - envvar=envvar, - autocompletion=autocompletion, - ) + kwargs: Dict[str, Any] = { + "param_decls": param_decls, + "type": type, + "required": required, + "default": default, + "callback": callback, + "nargs": nargs, + "metavar": metavar, + "expose_value": expose_value, + "is_eager": is_eager, + "envvar": envvar, + } + if _get_click_major() > 7: + kwargs["shell_complete"] = shell_complete + else: + kwargs["autocompletion"] = autocompletion + super().__init__(**kwargs) + if _get_click_major() > 7: + _typer_param_setup_autocompletion_compat( + self, autocompletion=autocompletion + ) - def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: # type: ignore + def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: # Modified version of click.core.Option.get_help_record() # to support Arguments if self.hidden: @@ -64,7 +157,7 @@ def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: # t else envvar ) extra.append(f"env var: {var_str}") - if self.default is not None and (self.show_default or ctx.show_default): # type: ignore + if self.default is not None and (self.show_default or ctx.show_default): if isinstance(self.show_default, str): default_string = f"({self.show_default})" elif isinstance(self.default, (list, tuple)): @@ -72,7 +165,7 @@ def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: # t elif inspect.isfunction(self.default): default_string = "(dynamic)" else: - default_string = self.default + default_string = str(self.default) extra.append(f"default: {default_string}") if self.required: extra.append("required") @@ -86,7 +179,7 @@ def make_metavar(self) -> str: # to include Argument name if self.metavar is not None: return self.metavar - var = self.name.upper() + var = (self.name or "").upper() if not self.required: var = "[{}]".format(var) type_var = self.type.get_metavar(self) @@ -96,24 +189,282 @@ def make_metavar(self) -> str: var += "..." return var + def shell_complete( + self, ctx: click.Context, incomplete: str + ) -> List["click.shell_completion.CompletionItem"]: + return _typer_param_shell_complete(self, ctx=ctx, incomplete=incomplete) + + +class TyperOption(click.core.Option): + def __init__( + self, + *, + # Parameter + param_decls: List[str], + type: Optional[Union[click.types.ParamType, Any]] = None, + required: Optional[bool] = None, + default: Optional[Any] = None, + callback: Optional[Callable[..., Any]] = None, + nargs: Optional[int] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, List[str]]] = None, + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[List["click.shell_completion.CompletionItem"], List[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + # Option + show_default: Union[bool, str] = False, + prompt: Union[bool, str] = False, + confirmation_prompt: Union[bool, str] = False, + prompt_required: bool = True, + hide_input: bool = False, + is_flag: Optional[bool] = None, + flag_value: Optional[Any] = None, + multiple: bool = False, + count: bool = False, + allow_from_autoenv: bool = True, + help: Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = False, + ): + # TODO: when deprecating Click 7, remove custom kwargs with prompt_required + # and call super().__init__() directly + kwargs: Dict[str, Any] = { + "param_decls": param_decls, + "type": type, + "required": required, + "default": default, + "callback": callback, + "nargs": nargs, + "metavar": metavar, + "expose_value": expose_value, + "is_eager": is_eager, + "envvar": envvar, + "show_default": show_default, + "prompt": prompt, + "confirmation_prompt": confirmation_prompt, + "hide_input": hide_input, + "is_flag": is_flag, + "flag_value": flag_value, + "multiple": multiple, + "count": count, + "allow_from_autoenv": allow_from_autoenv, + "help": help, + "hidden": hidden, + "show_choices": show_choices, + "show_envvar": show_envvar, + } + if _get_click_major() > 7: + kwargs["prompt_required"] = prompt_required + kwargs["shell_complete"] = shell_complete + else: + kwargs["autocompletion"] = autocompletion + super().__init__(**kwargs) + if _get_click_major() > 7: + _typer_param_setup_autocompletion_compat( + self, autocompletion=autocompletion + ) + + def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: + # Click 7.x was not breaking this use case, so in that case, re-use its logic + if _get_click_major() < 8: + return super().get_help_record(ctx) + # Duplicate all of Click's logic only to modify a single line, to allow boolean + # flags with only names for False values as it's currently supported by Typer + # Ref: https://typer.tiangolo.com/tutorial/parameter-types/bool/#only-names-for-false + if self.hidden: + return None + + any_prefix_is_slash = False + + def _write_opts(opts: Sequence[str]) -> str: + nonlocal any_prefix_is_slash + + rv, any_slashes = click.formatting.join_options(opts) + + if any_slashes: + any_prefix_is_slash = True + + if not self.is_flag and not self.count: + rv += f" {self.make_metavar()}" + + return rv + + rv = [_write_opts(self.opts)] + + if self.secondary_opts: + rv.append(_write_opts(self.secondary_opts)) + + help = self.help or "" + extra = [] + + if self.show_envvar: + envvar = self.envvar + + if envvar is None: + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + + if envvar is not None: + var_str = ( + envvar + if isinstance(envvar, str) + else ", ".join(str(d) for d in envvar) + ) + extra.append(_("env var: {var}").format(var=var_str)) + + # Temporarily enable resilient parsing to avoid type casting + # failing for the default. Might be possible to extend this to + # help formatting in general. + resilient = ctx.resilient_parsing + ctx.resilient_parsing = True + + try: + default_value = self.get_default(ctx, call=False) + finally: + ctx.resilient_parsing = resilient + + show_default_is_str = isinstance(self.show_default, str) + + if show_default_is_str or ( + default_value is not None and (self.show_default or ctx.show_default) + ): + if show_default_is_str: + default_string = f"({self.show_default})" + elif isinstance(default_value, (list, tuple)): + default_string = ", ".join(str(d) for d in default_value) + elif callable(default_value): + default_string = _("(dynamic)") + elif self.is_bool_flag and self.secondary_opts: + # For boolean flags that have distinct True/False opts, + # use the opt without prefix instead of the value. + # Typer override, original commented + # default_string = click.parser.split_opt( + # (self.opts if self.default else self.secondary_opts)[0] + # )[1] + if self.default: + if self.opts: + default_string = click.parser.split_opt(self.opts[0])[1] + else: + default_string = str(default_value) + else: + default_string = click.parser.split_opt(self.secondary_opts[0])[1] + # Typer override end + elif self.is_bool_flag and not self.secondary_opts and not default_value: + default_string = "" + else: + default_string = str(default_value) + + if default_string: + extra.append(_("default: {default}").format(default=default_string)) + + if isinstance(self.type, click.types._NumberRangeBase): + range_str = self.type._describe_range() + + if range_str: + extra.append(range_str) + + if self.required: + extra.append(_("required")) + + if extra: + extra_str = "; ".join(extra) + help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" + + return ("; " if any_prefix_is_slash else " / ").join(rv), help + + def shell_complete( + self, ctx: click.Context, incomplete: str + ) -> List["click.shell_completion.CompletionItem"]: + return _typer_param_shell_complete(self, ctx=ctx, incomplete=incomplete) + + +def _typer_format_options( + self: click.core.Command, *, ctx: click.Context, formatter: click.HelpFormatter +) -> None: + args = [] + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + if param.param_type_name == "argument": + args.append(rv) + elif param.param_type_name == "option": + opts.append(rv) + + # TODO: explore adding Click's gettext support, e.g.: + # from gettext import gettext as _ + # with formatter.section(_("Options")): + # ... + if args: + with formatter.section("Arguments"): + formatter.write_dl(args) + if opts: + with formatter.section("Options"): + formatter.write_dl(opts) + + +def _typer_main_shell_completion( + self: click.core.Command, + *, + ctx_args: Dict[str, Any], + prog_name: str, + complete_var: Optional[str] = None, +) -> None: + if complete_var is None: + complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() + + instruction = os.environ.get(complete_var) + + if not instruction: + return + + from .completion import shell_complete + + rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction) + sys.exit(rv) + class TyperCommand(click.core.Command): def format_options( self, ctx: click.Context, formatter: click.HelpFormatter ) -> None: - args = [] - opts = [] - for param in self.get_params(ctx): - rv = param.get_help_record(ctx) - if rv is not None: - if param.param_type_name == "argument": - args.append(rv) - elif param.param_type_name == "option": - opts.append(rv) - - if args: - with formatter.section("Arguments"): - formatter.write_dl(args) - if opts: - with formatter.section("Options"): - formatter.write_dl(opts) + _typer_format_options(self, ctx=ctx, formatter=formatter) + + def _main_shell_completion( + self, + ctx_args: Dict[str, Any], + prog_name: str, + complete_var: Optional[str] = None, + ) -> None: + _typer_main_shell_completion( + self, ctx_args=ctx_args, prog_name=prog_name, complete_var=complete_var + ) + + +class TyperGroup(click.core.Group): + def format_options( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + _typer_format_options(self, ctx=ctx, formatter=formatter) + self.format_commands(ctx, formatter) + + def _main_shell_completion( + self, + ctx_args: Dict[str, Any], + prog_name: str, + complete_var: Optional[str] = None, + ) -> None: + _typer_main_shell_completion( + self, ctx_args=ctx_args, prog_name=prog_name, complete_var=complete_var + ) diff --git a/typer/main.py b/typer/main.py index 02d9a5d7fe..d2a45849f8 100644 --- a/typer/main.py +++ b/typer/main.py @@ -9,7 +9,7 @@ import click from .completion import get_completion_inspect_parameters -from .core import TyperArgument, TyperCommand +from .core import TyperArgument, TyperCommand, TyperGroup, TyperOption from .models import ( AnyType, ArgumentInfo, @@ -45,7 +45,7 @@ def __init__( name: Optional[str] = Default(None), cls: Optional[Type[click.Command]] = Default(None), invoke_without_command: bool = Default(False), - no_args_is_help: Optional[bool] = Default(None), + no_args_is_help: bool = Default(False), subcommand_metavar: Optional[str] = Default(None), chain: bool = Default(False), result_callback: Optional[Callable[..., Any]] = Default(None), @@ -90,7 +90,7 @@ def callback( *, cls: Optional[Type[click.Command]] = Default(None), invoke_without_command: bool = Default(False), - no_args_is_help: Optional[bool] = Default(None), + no_args_is_help: bool = Default(False), subcommand_metavar: Optional[str] = Default(None), chain: bool = Default(False), result_callback: Optional[Callable[..., Any]] = Default(None), @@ -173,7 +173,7 @@ def add_typer( name: Optional[str] = Default(None), cls: Optional[Type[click.Command]] = Default(None), invoke_without_command: bool = Default(False), - no_args_is_help: Optional[bool] = Default(None), + no_args_is_help: bool = Default(False), subcommand_metavar: Optional[str] = Default(None), chain: bool = Default(False), result_callback: Optional[Callable[..., Any]] = Default(None), @@ -347,17 +347,19 @@ def get_group_from_info(group_info: TyperInfo) -> click.Command: commands: Dict[str, click.Command] = {} for command_info in group_info.typer_instance.registered_commands: command = get_command_from_info(command_info=command_info) - commands[command.name] = command + if command.name: + commands[command.name] = command for sub_group_info in group_info.typer_instance.registered_groups: sub_group = get_group_from_info(sub_group_info) - commands[sub_group.name] = sub_group + if sub_group.name: + commands[sub_group.name] = sub_group solved_info = solve_typer_info_defaults(group_info) ( params, convertors, context_param_name, ) = get_params_convertors_ctx_param_name_from_function(solved_info.callback) - cls = solved_info.cls or click.Group + cls = solved_info.cls or TyperGroup group = cls( # type: ignore name=solved_info.name or "", commands=commands, @@ -422,7 +424,7 @@ def get_command_from_info(command_info: CommandInfo) -> click.Command: context_param_name, ) = get_params_convertors_ctx_param_name_from_function(command_info.callback) cls = command_info.cls or TyperCommand - command = cls( # type: ignore + command = cls( name=name, context_settings=command_info.context_settings, callback=get_callback( @@ -484,7 +486,8 @@ def get_callback( for param_name in parameters: use_params[param_name] = None for param in params: - use_params[param.name] = param.default + if param.name: + use_params[param.name] = param.default def wrapper(**kwargs: Any) -> Any: for k, v in kwargs.items(): @@ -537,7 +540,7 @@ def get_click_type( or parameter_info.path_type or parameter_info.resolve_path ): - return click.Path( # type: ignore + return click.Path( exists=parameter_info.exists, file_okay=parameter_info.file_okay, dir_okay=parameter_info.dir_okay, @@ -683,12 +686,13 @@ def get_click_param( else: param_decls.append(default_option_declaration) return ( - click.Option( + TyperOption( # Option param_decls=param_decls, - show_default=parameter_info.show_default, # type: ignore + show_default=parameter_info.show_default, prompt=parameter_info.prompt, confirmation_prompt=parameter_info.confirmation_prompt, + prompt_required=parameter_info.prompt_required, hide_input=parameter_info.hide_input, is_flag=is_flag, flag_value=parameter_info.flag_value, @@ -710,6 +714,7 @@ def get_click_param( expose_value=parameter_info.expose_value, is_eager=parameter_info.is_eager, envvar=parameter_info.envvar, + shell_complete=parameter_info.shell_complete, autocompletion=get_param_completion(parameter_info.autocompletion), ), convertor, diff --git a/typer/models.py b/typer/models.py index 2b7dc6df9e..e1de3a46f8 100644 --- a/typer/models.py +++ b/typer/models.py @@ -16,6 +16,8 @@ import click if TYPE_CHECKING: # pragma: no cover + import click.shell_completion + from .main import Typer # noqa @@ -119,7 +121,7 @@ def __init__( name: Optional[str] = Default(None), cls: Optional[Type[click.Command]] = Default(None), invoke_without_command: bool = Default(False), - no_args_is_help: Optional[bool] = Default(None), + no_args_is_help: bool = Default(False), subcommand_metavar: Optional[str] = Default(None), chain: bool = Default(False), result_callback: Optional[Callable[..., Any]] = Default(None), @@ -164,6 +166,12 @@ def __init__( expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[List["click.shell_completion.CompletionItem"], List[str]], + ] + ] = None, autocompletion: Optional[Callable[..., Any]] = None, # TyperArgument show_default: Union[bool, str] = True, @@ -184,7 +192,7 @@ def __init__( encoding: Optional[str] = None, errors: Optional[str] = "strict", lazy: Optional[bool] = None, - atomic: Optional[bool] = False, + atomic: bool = False, # Path exists: bool = False, file_okay: bool = True, @@ -202,6 +210,7 @@ def __init__( self.expose_value = expose_value self.is_eager = is_eager self.envvar = envvar + self.shell_complete = shell_complete self.autocompletion = autocompletion # TyperArgument self.show_default = show_default @@ -246,11 +255,18 @@ def __init__( expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[List["click.shell_completion.CompletionItem"], List[str]], + ] + ] = None, autocompletion: Optional[Callable[..., Any]] = None, # Option show_default: bool = True, prompt: Union[bool, str] = False, confirmation_prompt: bool = False, + prompt_required: bool = True, hide_input: bool = False, is_flag: Optional[bool] = None, flag_value: Optional[Any] = None, @@ -273,7 +289,7 @@ def __init__( encoding: Optional[str] = None, errors: Optional[str] = "strict", lazy: Optional[bool] = None, - atomic: Optional[bool] = False, + atomic: bool = False, # Path exists: bool = False, file_okay: bool = True, @@ -292,6 +308,7 @@ def __init__( expose_value=expose_value, is_eager=is_eager, envvar=envvar, + shell_complete=shell_complete, autocompletion=autocompletion, # TyperArgument show_default=show_default, @@ -325,6 +342,7 @@ def __init__( ) self.prompt = prompt self.confirmation_prompt = confirmation_prompt + self.prompt_required = prompt_required self.hide_input = hide_input self.is_flag = is_flag self.flag_value = flag_value @@ -344,6 +362,12 @@ def __init__( expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[List["click.shell_completion.CompletionItem"], List[str]], + ] + ] = None, autocompletion: Optional[Callable[..., Any]] = None, # TyperArgument show_default: Union[bool, str] = True, @@ -364,7 +388,7 @@ def __init__( encoding: Optional[str] = None, errors: Optional[str] = "strict", lazy: Optional[bool] = None, - atomic: Optional[bool] = False, + atomic: bool = False, # Path exists: bool = False, file_okay: bool = True, @@ -383,6 +407,7 @@ def __init__( expose_value=expose_value, is_eager=is_eager, envvar=envvar, + shell_complete=shell_complete, autocompletion=autocompletion, # TyperArgument show_default=show_default, diff --git a/typer/params.py b/typer/params.py index f502551dbf..91fcd59c15 100644 --- a/typer/params.py +++ b/typer/params.py @@ -1,7 +1,12 @@ -from typing import Any, Callable, List, Optional, Type, Union +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Type, Union + +import click from .models import ArgumentInfo, OptionInfo +if TYPE_CHECKING: # pragma: no cover + import click.shell_completion + def Option( # Parameter @@ -12,11 +17,18 @@ def Option( expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[List["click.shell_completion.CompletionItem"], List[str]], + ] + ] = None, autocompletion: Optional[Callable[..., Any]] = None, # Option show_default: bool = True, prompt: Union[bool, str] = False, confirmation_prompt: bool = False, + prompt_required: bool = True, hide_input: bool = False, is_flag: Optional[bool] = None, flag_value: Optional[Any] = None, @@ -39,7 +51,7 @@ def Option( encoding: Optional[str] = None, errors: Optional[str] = "strict", lazy: Optional[bool] = None, - atomic: Optional[bool] = False, + atomic: bool = False, # Path exists: bool = False, file_okay: bool = True, @@ -59,11 +71,13 @@ def Option( expose_value=expose_value, is_eager=is_eager, envvar=envvar, + shell_complete=shell_complete, autocompletion=autocompletion, # Option show_default=show_default, prompt=prompt, confirmation_prompt=confirmation_prompt, + prompt_required=prompt_required, hide_input=hide_input, is_flag=is_flag, flag_value=flag_value, @@ -108,6 +122,12 @@ def Argument( expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[List["click.shell_completion.CompletionItem"], List[str]], + ] + ] = None, autocompletion: Optional[Callable[..., Any]] = None, # TyperArgument show_default: Union[bool, str] = True, @@ -128,7 +148,7 @@ def Argument( encoding: Optional[str] = None, errors: Optional[str] = "strict", lazy: Optional[bool] = None, - atomic: Optional[bool] = False, + atomic: bool = False, # Path exists: bool = False, file_okay: bool = True, @@ -150,6 +170,7 @@ def Argument( expose_value=expose_value, is_eager=is_eager, envvar=envvar, + shell_complete=shell_complete, autocompletion=autocompletion, # TyperArgument show_default=show_default, diff --git a/typer/testing.py b/typer/testing.py index 44eb1b947b..409a199dd1 100644 --- a/typer/testing.py +++ b/typer/testing.py @@ -1,4 +1,4 @@ -from typing import IO, Any, Iterable, Mapping, Optional, Text, Union +from typing import IO, Any, Mapping, Optional, Sequence, Text, Union from click.testing import CliRunner as ClickCliRunner # noqa from click.testing import Result @@ -10,7 +10,7 @@ class CliRunner(ClickCliRunner): def invoke( # type: ignore self, app: Typer, - args: Optional[Union[str, Iterable[str]]] = None, + args: Optional[Union[str, Sequence[str]]] = None, input: Optional[Union[bytes, Text, IO[Any]]] = None, env: Optional[Mapping[str, str]] = None, catch_exceptions: bool = True, diff --git a/typer/utils.py b/typer/utils.py index d015037576..2af0e17384 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -1,6 +1,8 @@ import inspect from typing import Any, Callable, Dict, get_type_hints +import click + from .models import ParamMeta @@ -16,3 +18,7 @@ def get_params_from_function(func: Callable[..., Any]) -> Dict[str, ParamMeta]: name=param.name, default=param.default, annotation=annotation ) return params + + +def _get_click_major() -> int: + return int(click.__version__.split(".")[0])