├── click_repl ├── py.typed ├── exceptions.py ├── __init__.py ├── _ctx_stack.py ├── globals_.py ├── _repl.py ├── core.py ├── utils.py └── _completer.py ├── tests ├── testdir │ └── test file.txt ├── __init__.py ├── test_dev │ ├── test_register_internal_cmds.py │ ├── test_sys_cmds.py │ ├── test_get_internal_cmds.py │ └── test_internal_cmds.py ├── test_completion │ ├── test_common_tests │ │ ├── test_cmd_completion.py │ │ ├── test_arg_completion.py │ │ ├── test_hidden_cmd_and_args.py │ │ └── test_option_completion.py │ ├── test_click_version_le_7 │ │ ├── test_option_completion_v7.py │ │ └── test_arg_completion_v7.py │ ├── test_path_type │ │ └── test_path_type.py │ └── test_click_version_ge_8 │ │ ├── test_option_completion_v8.py │ │ └── test_arg_completion_v8.py ├── test_basic.py ├── test_repl_ctx.py ├── test_command_collection.py └── test_repl.py ├── MANIFEST.in ├── setup.py ├── pyproject.toml ├── Makefile ├── SECURITY.md ├── .github └── workflows │ └── tests.yml ├── tox.ini ├── Changelog.rst ├── LICENSE ├── setup.cfg ├── .gitignore └── README.md /click_repl/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdir/test file.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import setup 4 | 5 | 6 | if __name__ == '__main__': 7 | setup() 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.pytest.ini_options] 6 | addopts = [ 7 | "--cov=click_repl" 8 | ] 9 | 10 | testpaths = [ 11 | "tests", 12 | ] 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import sys 3 | from io import StringIO 4 | 5 | 6 | @contextlib.contextmanager 7 | def mock_stdin(text): 8 | old_stdin = sys.stdin 9 | try: 10 | sys.stdin = StringIO(text) 11 | yield sys.stdin 12 | finally: 13 | sys.stdin = old_stdin 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | release: 2 | @python setup.py sdist bdist_wheel upload 3 | .PHONY: release 4 | 5 | testrepl: 6 | @python bin/testrepl.py repl 7 | .PHONY: testrepl 8 | 9 | venv: 10 | @python -m venv .venv 11 | .venv/bin/pip install --upgrade pip 12 | .venv/bin/pip install tox 13 | .PHONY: venv 14 | 15 | tox: venv 16 | .venv/bin/tox 17 | .PHONY: tox 18 | 19 | clean: 20 | rm -rf .venv .tox 21 | .PHONY: clean 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | This section tell about which versions of this project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 0.3.x | :white_check_mark: | 11 | | 0.2.x | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Please reach out to auvipy@gmail.com for reporting security concerns via email. 16 | -------------------------------------------------------------------------------- /click_repl/exceptions.py: -------------------------------------------------------------------------------- 1 | class InternalCommandException(Exception): 2 | pass 3 | 4 | 5 | class ExitReplException(InternalCommandException): 6 | pass 7 | 8 | 9 | class CommandLineParserError(Exception): 10 | pass 11 | 12 | 13 | class InvalidGroupFormat(Exception): 14 | pass 15 | 16 | 17 | # Handle click.exceptions.Exit introduced in Click 7.0 18 | try: 19 | from click.exceptions import Exit as ClickExit 20 | except (ImportError, ModuleNotFoundError): 21 | 22 | class ClickExit(RuntimeError): # type: ignore[no-redef] 23 | pass 24 | -------------------------------------------------------------------------------- /tests/test_dev/test_register_internal_cmds.py: -------------------------------------------------------------------------------- 1 | import click_repl 2 | import pytest 3 | 4 | 5 | def test_register_cmd_from_str(): 6 | click_repl.utils._register_internal_command( 7 | "help2", click_repl.utils._help_internal, "temporary internal help command" 8 | ) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "test_input", 13 | [ 14 | ({"h": "help"}, str), 15 | (["h", "help", "?"], str()), 16 | ], 17 | ) 18 | def test_register_func_xfails(test_input): 19 | with pytest.raises(ValueError): 20 | click_repl.utils._register_internal_command(*test_input) 21 | -------------------------------------------------------------------------------- /click_repl/__init__.py: -------------------------------------------------------------------------------- 1 | from ._completer import ClickCompleter as ClickCompleter # noqa: F401 2 | from .core import pass_context as pass_context # noqa: F401 3 | from ._repl import register_repl as register_repl # noqa: F401 4 | from ._repl import repl as repl # noqa: F401 5 | from .exceptions import CommandLineParserError as CommandLineParserError # noqa: F401 6 | from .exceptions import ExitReplException as ExitReplException # noqa: F401 7 | from .exceptions import ( # noqa: F401 8 | InternalCommandException as InternalCommandException, 9 | ) 10 | from .utils import exit as exit # noqa: F401 11 | 12 | __version__ = "0.3.0" 13 | -------------------------------------------------------------------------------- /click_repl/_ctx_stack.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from .core import ReplContext 7 | 8 | 9 | # To store the ReplContext objects generated throughout the Runtime. 10 | _context_stack: list[ReplContext] = [] 11 | 12 | 13 | def _push_context(ctx: ReplContext) -> None: 14 | """ 15 | Pushes a new REPL context onto the current stack. 16 | 17 | Parameters 18 | ---------- 19 | ctx 20 | The :class:`~click_repl.core.ReplContext` object that should be 21 | added to the REPL context stack. 22 | """ 23 | _context_stack.append(ctx) 24 | 25 | 26 | def _pop_context() -> None: 27 | """Removes the top-level REPL context from the stack.""" 28 | _context_stack.pop() 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest] 13 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.9'] 14 | click-version: ['7.1.2', '8.1.2'] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install tox tox-gh-actions click==${{ matrix.click-version }} 26 | - name: Test with tox 27 | run: tox 28 | -------------------------------------------------------------------------------- /tests/test_dev/test_sys_cmds.py: -------------------------------------------------------------------------------- 1 | import click_repl 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "test_input, expected", 7 | [("!echo hi", "hi\n"), ("!echo hi hi", "hi hi\n"), ("!", "")], 8 | ) 9 | def test_system_commands(capfd, test_input, expected): 10 | click_repl.utils._execute_internal_and_sys_cmds(test_input) 11 | 12 | captured_stdout = capfd.readouterr().out.replace("\r\n", "\n") 13 | assert captured_stdout == expected 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "test_input", 18 | ["!echo hi", "!echo hi hi", "!"], 19 | ) 20 | def test_no_system_commands(capfd, test_input): 21 | click_repl.utils._execute_internal_and_sys_cmds( 22 | test_input, allow_system_commands=False 23 | ) 24 | 25 | captured_stdout = capfd.readouterr().out.replace("\r\n", "\n") 26 | assert captured_stdout == "" 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{py,38,39,310,311,312,313} 4 | flake8 5 | click7 6 | 7 | minversion = 3.8.20 8 | isolated_build = true 9 | 10 | [gh-actions] 11 | python = 12 | 3.8: py38 13 | 3.9: py39, mypy, click7, flake8 14 | 3.10: py310 15 | 3.11: py311 16 | 3.12: py312 17 | 3.13: py313 18 | 19 | [testenv] 20 | setenv = 21 | PYTHONPATH = {toxinidir} 22 | deps = 23 | pytest 24 | pytest-cov 25 | flake8 26 | mypy 27 | tox 28 | commands = 29 | pytest --basetemp={envtmpdir} 30 | 31 | [testenv:flake8] 32 | basepython = python3.9 33 | deps = flake8 34 | commands = flake8 click_repl tests 35 | 36 | [testenv:mypy] 37 | basepython = python3.3 38 | deps = mypy 39 | commands = mypy click_repl 40 | 41 | [testenv:click7] 42 | basepython = python3.9 43 | deps = 44 | click==7.1.2 45 | pytest 46 | pytest-cov 47 | commands = pytest --basetemp={envtmpdir} 48 | -------------------------------------------------------------------------------- /tests/test_completion/test_common_tests/test_cmd_completion.py: -------------------------------------------------------------------------------- 1 | import click 2 | from unittest import TestCase 3 | from prompt_toolkit.document import Document 4 | from click_repl import ClickCompleter 5 | 6 | 7 | @click.group() 8 | def cli(): 9 | pass 10 | 11 | 12 | @cli.group() 13 | def cmd(): 14 | pass 15 | 16 | 17 | @cmd.command() 18 | def subcmd(): 19 | pass 20 | 21 | 22 | class Test_Command_Autocompletion(TestCase): 23 | def setUp(self): 24 | self.c = ClickCompleter(cli, click.Context(cli)) 25 | 26 | def test_valid_subcmd(self): 27 | res = list(self.c.get_completions(Document("cmd s"))) 28 | self.assertListEqual([i.text for i in res], ["subcmd"]) 29 | 30 | def test_not_valid_subcmd(self): 31 | try: 32 | res = list(self.c.get_completions(Document("not cmd"))) 33 | except Exception as e: 34 | self.fail(f"Autocompletion raised exception: {e}") 35 | self.assertListEqual(res, []) 36 | -------------------------------------------------------------------------------- /Changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | ================ 4 | Change history 5 | ================ 6 | 7 | .. _version-0.3.0: 8 | 9 | 0.3.0 10 | ===== 11 | :release-date: 15 Jun, 2023 12 | :release-by: Asif Saif Uddin 13 | 14 | - Drop Python 2 support, remove six. 15 | - Uses PromptSession() class from prompt_toolkit instead of prompt() function (#63). 16 | - Added filter for hidden commands and options (#86). 17 | - Added click's autocompletion support (#88). 18 | - Added tab-completion for Path and BOOL type arguments (#95). 19 | - Added 'expand environmental variables in path' feature (#96). 20 | - Delegate command dispatching to the actual group command. 21 | - Updated completer class and tests based on new fix#92 (#102). 22 | - Python 3.11 support. 23 | 24 | 25 | 26 | .. _version-0.2.0: 27 | 28 | 0.2.0 29 | ===== 30 | :release-date: 31 May, 2021 31 | :release-by: untitaker 32 | 33 | - Backwards compatibility between click 7 & 8 34 | - support for click 8 changes 35 | - Update tests to expect hyphens -------------------------------------------------------------------------------- /tests/test_completion/test_click_version_le_7/test_option_completion_v7.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | import pytest 5 | 6 | 7 | @click.group() 8 | def root_command(): 9 | pass 10 | 11 | 12 | c = ClickCompleter(root_command, click.Context(root_command)) 13 | 14 | 15 | @pytest.mark.skipif( 16 | click.__version__[0] > "7", 17 | reason="click-v7 old autocomplete function is not available, so skipped", 18 | ) 19 | def test_click7_autocomplete_option(): 20 | def shell_complete_func(ctx, args, incomplete): 21 | return [name for name in ("foo", "bar") if name.startswith(incomplete)] 22 | 23 | @root_command.command() 24 | @click.option("--handler", autocompletion=shell_complete_func) 25 | def autocompletion_opt_cmd2(handler): 26 | pass 27 | 28 | completions = list( 29 | c.get_completions(Document("autocompletion-opt-cmd2 --handler ")) 30 | ) 31 | assert {x.text for x in completions} == {"foo", "bar"} 32 | -------------------------------------------------------------------------------- /tests/test_completion/test_common_tests/test_arg_completion.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | 5 | 6 | @click.group() 7 | def root_command(): 8 | pass 9 | 10 | 11 | c = ClickCompleter(root_command, click.Context(root_command)) 12 | 13 | 14 | def test_boolean_arg(): 15 | @root_command.command() 16 | @click.argument("foo", type=click.BOOL) 17 | def bool_arg(foo): 18 | pass 19 | 20 | completions = list(c.get_completions(Document("bool-arg "))) 21 | assert {x.text for x in completions} == {"true", "false"} 22 | 23 | completions = list(c.get_completions(Document("bool-arg t"))) 24 | assert {x.text for x in completions} == {"true"} 25 | 26 | 27 | def test_arg_choices(): 28 | @root_command.command() 29 | @click.argument("handler", type=click.Choice(("foo", "bar"))) 30 | def arg_choices(handler): 31 | pass 32 | 33 | completions = list(c.get_completions(Document("arg-choices "))) 34 | assert {x.text for x in completions} == {"foo", "bar"} 35 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | import pytest 5 | 6 | 7 | @click.group() 8 | def root_command(): 9 | pass 10 | 11 | 12 | c = ClickCompleter(root_command, click.Context(root_command)) 13 | 14 | 15 | def test_arg_completion(): 16 | @root_command.command() 17 | @click.argument("handler", type=click.Choice(("foo", "bar"))) 18 | def arg_cmd(handler): 19 | pass 20 | 21 | completions = list(c.get_completions(Document("arg-cmd "))) 22 | assert {x.text for x in completions} == {"foo", "bar"} 23 | 24 | 25 | @root_command.command() 26 | @click.option("--handler", "-h", type=click.Choice(("foo", "bar")), help="Demo option") 27 | def option_cmd(handler): 28 | pass 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "test_input,expected", 33 | [ 34 | ("option-cmd ", {"--handler", "-h"}), 35 | ("option-cmd -h", {"-h"}), 36 | ("option-cmd --h", {"--handler"}), 37 | ], 38 | ) 39 | def test_option_completion(test_input, expected): 40 | completions = list(c.get_completions(Document(test_input))) 41 | assert {x.text for x in completions} == expected 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2015 Markus Unterwaditzer & contributors. 2 | Copyright (c) 2016-2026 Asif Saif Uddin & contributors. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/test_dev/test_get_internal_cmds.py: -------------------------------------------------------------------------------- 1 | import click_repl 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "test_input, expected", 7 | [ 8 | ("help", click_repl.utils._help_internal), 9 | ("h", click_repl.utils._help_internal), 10 | ("?", click_repl.utils._help_internal), 11 | ], 12 | ) 13 | def test_get_registered_target_help_cmd(test_input, expected): 14 | assert click_repl.utils._get_registered_target(test_input) == expected 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "test_input, expected", 19 | [ 20 | ("exit", click_repl.utils._exit_internal), 21 | ("quit", click_repl.utils._exit_internal), 22 | ("q", click_repl.utils._exit_internal), 23 | ], 24 | ) 25 | def test_get_registered_target_exit_cmd(test_input, expected): 26 | assert click_repl.utils._get_registered_target(test_input) == expected 27 | with pytest.raises(click_repl.exceptions.ExitReplException): 28 | expected() 29 | 30 | 31 | @pytest.mark.parametrize("test_input", ["hi", "hello", "76q358767"]) 32 | def test_get_registered_target(test_input): 33 | assert ( 34 | click_repl.utils._get_registered_target(test_input, "Not Found") == "Not Found" 35 | ) 36 | -------------------------------------------------------------------------------- /tests/test_repl_ctx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | import pytest 5 | 6 | import click_repl 7 | from tests import mock_stdin 8 | 9 | 10 | @click.group(invoke_without_command=True) 11 | @click.pass_context 12 | def cli(ctx): 13 | if ctx.invoked_subcommand is None: 14 | click_repl.repl(ctx) 15 | 16 | 17 | @cli.command() 18 | def hello(): 19 | print("Hello!") 20 | 21 | 22 | @cli.command() 23 | @click_repl.pass_context 24 | def history_test(repl_ctx): 25 | print(list(repl_ctx.history())) 26 | 27 | 28 | def test_repl_ctx_history(capsys): 29 | with mock_stdin("hello\nhistory-test\n"): 30 | with pytest.raises(SystemExit): 31 | cli(args=[], prog_name="test_repl_ctx_history") 32 | 33 | assert ( 34 | capsys.readouterr().out.replace("\r\n", "\n") 35 | == "Hello!\n['history-test', 'hello']\n" 36 | ) 37 | 38 | 39 | @cli.command() 40 | @click_repl.pass_context 41 | def prompt_test(repl_ctx): 42 | print(repl_ctx.prompt) 43 | 44 | 45 | def test_repl_ctx_prompt(capsys): 46 | with mock_stdin("prompt-test\n"): 47 | with pytest.raises(SystemExit): 48 | cli(args=[], prog_name="test_repl_ctx_history") 49 | 50 | assert capsys.readouterr().out.replace("\r\n", "\n") == "None\n" 51 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = click-repl 3 | version = attr: click_repl.__version__ 4 | description = REPL plugin for Click 5 | description_file = README.md 6 | long_description_content_type = text/markdown 7 | long_description = file: README.md 8 | 9 | url = https://github.com/click-contrib/click-repl 10 | 11 | author = Markus Unterwaditzer 12 | author_email = markus@unterwaditzer.net 13 | license = MIT 14 | 15 | classifiers = 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3 :: Only 18 | Programming Language :: Python :: 3.6 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.9 22 | Programming Language :: Python :: 3.10 23 | Programming Language :: Python :: 3.11 24 | 25 | [options] 26 | packages= 27 | click_repl 28 | 29 | install_requires = 30 | click>=7.0,<8.2.0 31 | prompt_toolkit>=3.0.36 32 | typing-extensions>=4.7.0 33 | 34 | python_requires = >=3.6 35 | zip_safe = no 36 | 37 | [options.extras_require] 38 | testing = 39 | pytest>=7.2.1 40 | pytest-cov>=4.0.0 41 | tox>=4.4.3 42 | flake8>=6.0.0 43 | mypy>=1.9.0 44 | 45 | [flake8] 46 | ignore = E203, E266, W503, E402, E731, C901 47 | max-line-length = 90 48 | max-complexity = 18 49 | select = B,C,E,F,W,T4,B9 50 | -------------------------------------------------------------------------------- /tests/test_dev/test_internal_cmds.py: -------------------------------------------------------------------------------- 1 | import click_repl 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize("test_input", [":help", ":h", ":?"]) 6 | def test_internal_help_commands(capsys, test_input): 7 | click_repl.utils._execute_internal_and_sys_cmds( 8 | test_input, allow_internal_commands=True 9 | ) 10 | 11 | captured_stdout = capsys.readouterr().out 12 | 13 | assert ( 14 | captured_stdout 15 | == """REPL help: 16 | 17 | External Commands: 18 | prefix external commands with "!" 19 | 20 | Internal Commands: 21 | prefix internal commands with ":" 22 | :exit, :q, :quit exits the repl 23 | :?, :h, :help displays general help information 24 | 25 | """ 26 | ) 27 | 28 | 29 | @pytest.mark.parametrize("test_input", [":exit", ":quit", ":q"]) 30 | def test_internal_exit_commands(test_input): 31 | with pytest.raises(click_repl.exceptions.ExitReplException): 32 | click_repl.utils._execute_internal_and_sys_cmds(test_input) 33 | 34 | 35 | @pytest.mark.parametrize("test_input", [":exit", ":quit", ":q"]) 36 | def test_no_internal_commands(capfd, test_input): 37 | click_repl.utils._execute_internal_and_sys_cmds( 38 | test_input, allow_internal_commands=False 39 | ) 40 | 41 | captured_stdout = capfd.readouterr().out.replace("\r\n", "\n") 42 | assert captured_stdout == "" 43 | -------------------------------------------------------------------------------- /tests/test_completion/test_path_type/test_path_type.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | import os 5 | import glob 6 | import pytest 7 | 8 | 9 | @click.group() 10 | def root_command(): 11 | pass 12 | 13 | 14 | c = ClickCompleter(root_command, click.Context(root_command)) 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "test_input,expected", 19 | [ 20 | ("path-type-arg ", glob.glob("*")), 21 | ("path-type-arg tests/", glob.glob("tests/*")), 22 | ("path-type-arg src/*", []), 23 | ("path-type-arg src/**", []), 24 | ( 25 | "path-type-arg tests/testdir/", 26 | glob.glob("tests/testdir/*"), 27 | ), 28 | ], 29 | ) 30 | def test_path_type_arg(test_input, expected): 31 | @root_command.command() 32 | @click.argument("path", type=click.Path()) 33 | def path_type_arg(path): 34 | pass 35 | 36 | completions = list(c.get_completions(Document(test_input))) 37 | assert {x.display[0][1] for x in completions} == { 38 | os.path.basename(i) for i in expected 39 | } 40 | 41 | 42 | # @pytest.mark.skipif(os.name != 'nt', reason='This is a test for Windows OS') 43 | # def test_win_path_env_expanders(): 44 | # completions = list(c.get_completions(Document('path-type-arg %LocalAppData%'))) 45 | # assert {x.display[0][1] for x in completions} == {'Local', 'LocalLow'} 46 | -------------------------------------------------------------------------------- /tests/test_command_collection.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter, repl 3 | from prompt_toolkit.document import Document 4 | import pytest 5 | 6 | 7 | def test_command_collection(): 8 | @click.group() 9 | def foo_group(): 10 | pass 11 | 12 | @foo_group.command() 13 | def foo_cmd(): 14 | pass 15 | 16 | @click.group() 17 | def foobar_group(): 18 | pass 19 | 20 | @foobar_group.command() 21 | def foobar_cmd(): 22 | pass 23 | 24 | cmd = click.CommandCollection(sources=(foo_group, foobar_group)) 25 | c = ClickCompleter(cmd, click.Context(cmd)) 26 | completions = list(c.get_completions(Document("foo"))) 27 | 28 | assert {x.text for x in completions} == {"foo-cmd", "foobar-cmd"} 29 | 30 | 31 | @click.group(invoke_without_command=True) 32 | @click.option("--user", required=True) 33 | @click.pass_context 34 | def cli(ctx, user): 35 | if ctx.invoked_subcommand is None: 36 | click.echo("Top-level user: {}".format(user)) 37 | repl(ctx) 38 | 39 | 40 | @cli.command() 41 | @click.option("--user") 42 | def c1(user): 43 | click.echo("Executed C1 with {}!".format(user)) 44 | 45 | 46 | c = ClickCompleter(cli, click.Context(cli)) 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "test_input,expected", [(" ", {"--user", "c1"}), ("c1 ", {"--user"})] 51 | ) 52 | def test_subcommand_invocation_from_group(test_input, expected): 53 | completions = list(c.get_completions(Document(test_input))) 54 | assert {x.text for x in completions} == expected 55 | -------------------------------------------------------------------------------- /click_repl/globals_.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, NoReturn, overload 5 | 6 | from ._ctx_stack import _context_stack 7 | 8 | if TYPE_CHECKING: 9 | from .core import ReplContext 10 | 11 | 12 | ISATTY = sys.stdin.isatty() 13 | 14 | 15 | @overload 16 | def get_current_repl_ctx() -> ReplContext | NoReturn: 17 | ... 18 | 19 | 20 | @overload 21 | def get_current_repl_ctx(silent: bool = False) -> ReplContext | NoReturn | None: 22 | ... 23 | 24 | 25 | def get_current_repl_ctx(silent: bool = False) -> ReplContext | NoReturn | None: 26 | """ 27 | Retrieves the current click-repl context. 28 | 29 | This function provides a way to access the context from anywhere 30 | in the code. This function serves as a more implicit alternative to the 31 | :func:`~click.core.pass_context` decorator. 32 | 33 | Parameters 34 | ---------- 35 | silent 36 | If set to :obj:`True`, the function returns :obj:`None` if no context 37 | is available. The default behavior is to raise a :exc:`~RuntimeError`. 38 | 39 | Returns 40 | ------- 41 | :class:`~click_repl.core.ReplContext` | None 42 | REPL context object if available, or :obj:`None` if ``silent`` is :obj:`True`. 43 | 44 | Raises 45 | ------ 46 | RuntimeError 47 | If there's no context object in the stack and ``silent`` is :obj:`False`. 48 | """ 49 | 50 | try: 51 | return _context_stack[-1] 52 | except IndexError: 53 | if not silent: 54 | raise RuntimeError("There is no active click-repl context.") 55 | 56 | return None 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .pytest_cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | ### JetBrains template 61 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 62 | 63 | *.iml 64 | 65 | ## Directory-based project format: 66 | .idea/ 67 | # if you remove the above rule, at least ignore the following: 68 | 69 | # User-specific stuff: 70 | # .idea/workspace.xml 71 | # .idea/tasks.xml 72 | # .idea/dictionaries 73 | 74 | # Sensitive or high-churn files: 75 | # .idea/dataSources.ids 76 | # .idea/dataSources.xml 77 | # .idea/sqlDataSources.xml 78 | # .idea/dynamic.xml 79 | # .idea/uiDesigner.xml 80 | 81 | # Gradle: 82 | # .idea/gradle.xml 83 | # .idea/libraries 84 | 85 | # Mongo Explorer plugin: 86 | # .idea/mongoSettings.xml 87 | 88 | ## File-based project format: 89 | *.ipr 90 | *.iws 91 | 92 | ## Plugin-specific files: 93 | 94 | # IntelliJ 95 | /out/ 96 | 97 | # mpeltonen/sbt-idea plugin 98 | .idea_modules/ 99 | 100 | # JIRA plugin 101 | atlassian-ide-plugin.xml 102 | 103 | # Crashlytics plugin (for Android Studio and IntelliJ) 104 | com_crashlytics_export_strings.xml 105 | crashlytics.properties 106 | crashlytics-build.properties 107 | -------------------------------------------------------------------------------- /tests/test_completion/test_click_version_le_7/test_arg_completion_v7.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | import pytest 5 | 6 | 7 | @click.group() 8 | def root_command(): 9 | pass 10 | 11 | 12 | c = ClickCompleter(root_command, click.Context(root_command)) 13 | 14 | 15 | @pytest.mark.skipif( 16 | click.__version__[0] > "7", 17 | reason="click-v7 old autocomplete function is not available, so skipped", 18 | ) 19 | def test_click7_autocomplete_arg(): 20 | def shell_complete_func(ctx, args, incomplete): 21 | return [name for name in ("foo", "bar") if name.startswith(incomplete)] 22 | 23 | @root_command.command() 24 | @click.argument("handler", autocompletion=shell_complete_func) 25 | def autocompletion_arg_cmd2(handler): 26 | pass 27 | 28 | completions = list(c.get_completions(Document("autocompletion-arg-cmd2 "))) 29 | assert {x.text for x in completions} == {"foo", "bar"} 30 | 31 | 32 | @pytest.mark.skipif( 33 | click.__version__[0] > "7", 34 | reason="click-v7 old autocomplete function is not available, so skipped", 35 | ) 36 | def test_tuple_return_type_shell_complete_func_click7(): 37 | def return_type_tuple_shell_complete(ctx, args, incomplete): 38 | return [ 39 | i 40 | for i in [ 41 | ("Hi", "hi"), 42 | ("Please", "please"), 43 | ("Hey", "hey"), 44 | ("Aye", "aye"), 45 | ] 46 | if i[1].startswith(incomplete) 47 | ] 48 | 49 | @root_command.command() 50 | @click.argument("foo", autocompletion=return_type_tuple_shell_complete) 51 | def tuple_type_autocompletion_cmd(foo): 52 | pass 53 | 54 | completions = list(c.get_completions(Document("tuple-type-autocompletion-cmd "))) 55 | assert {x.text for x in completions} == {"Hi", "Please", "Hey", "Aye"} and { 56 | x.display_meta[0][-1] for x in completions 57 | } == {"hi", "please", "hey", "aye"} 58 | 59 | completions = list(c.get_completions(Document("tuple-type-autocompletion-cmd h"))) 60 | assert {x.text for x in completions} == {"Hi", "Hey"} and { 61 | x.display_meta[0][-1] for x in completions 62 | } == {"hi", "hey"} 63 | -------------------------------------------------------------------------------- /tests/test_completion/test_click_version_ge_8/test_option_completion_v8.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | import pytest 5 | 6 | 7 | @click.group() 8 | def root_command(): 9 | pass 10 | 11 | 12 | c = ClickCompleter(root_command, click.Context(root_command)) 13 | 14 | with pytest.importorskip( 15 | "click.shell_complete.CompletionItem", 16 | minversion="8.0.0", 17 | reason="click-v8 built-in shell complete is not available, so skipped", 18 | ) as CompletionItem: 19 | 20 | def test_shell_complete_option_v8_class_type(): 21 | class MyVar(click.ParamType): 22 | name = "myvar" 23 | 24 | def shell_complete(self, ctx, param, incomplete): 25 | return [ 26 | CompletionItem(name) 27 | for name in ("foo", "bar") 28 | if name.startswith(incomplete) 29 | ] 30 | 31 | @root_command.command() 32 | @click.option("--handler", "-h", type=MyVar()) 33 | def autocompletion_opt_cmd(handler): 34 | pass 35 | 36 | completions = list(c.get_completions(Document("autocompletion-opt-cmd "))) 37 | assert {x.text for x in completions} == {"--handler", "bar"} 38 | 39 | 40 | with pytest.importorskip( 41 | "click.shell_complete.CompletionItem", 42 | minversion="8.0.0", 43 | reason="click-v8 built-in shell complete is not available, so skipped", 44 | ) as CompletionItem: 45 | 46 | def test_shell_complete_arg_v8_func_type(): 47 | def shell_complete_func(ctx, param, incomplete): 48 | return [ 49 | CompletionItem(name) 50 | for name in ("foo", "bar") 51 | if name.startswith(incomplete) 52 | ] 53 | 54 | @root_command.command() 55 | @click.option("--handler", "-h", shell_complete=shell_complete_func) 56 | def autocompletion_cmd2(handler): 57 | pass 58 | 59 | completions = list( 60 | c.get_completions(Document("autocompletion-cmd2 --handler ")) 61 | ) 62 | assert {x.text for x in completions} == {"foo", "bar"} 63 | 64 | completions = list( 65 | c.get_completions(Document("autocompletion-cmd2 --handler ")) 66 | ) 67 | assert {x.text for x in completions} == {"foo", "bar"} 68 | -------------------------------------------------------------------------------- /tests/test_completion/test_common_tests/test_hidden_cmd_and_args.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | 5 | 6 | @click.group() 7 | def root_command(): 8 | pass 9 | 10 | 11 | c = ClickCompleter(root_command, click.Context(root_command)) 12 | 13 | 14 | def test_hidden_cmd(): 15 | @root_command.command(hidden=True) 16 | @click.option("--handler", "-h") 17 | def hidden_cmd(handler): 18 | pass 19 | 20 | completions = list(c.get_completions(Document("hidden-"))) 21 | assert {x.text for x in completions} == set() 22 | 23 | 24 | def test_hidden_option(): 25 | @root_command.command() 26 | @click.option("--handler", "-h", hidden=True) 27 | def hidden_option_cmd(handler): 28 | pass 29 | 30 | completions = list(c.get_completions(Document("hidden-option-cmd "))) 31 | assert {x.text for x in completions} == set() 32 | 33 | 34 | def test_args_of_hidden_command(): 35 | @root_command.command(hidden=True) 36 | @click.argument("handler1", type=click.Choice(("foo", "bar"))) 37 | @click.option("--handler2", type=click.Choice(("foo", "bar"))) 38 | def args_choices_hidden_cmd(handler): 39 | pass 40 | 41 | completions = list(c.get_completions(Document("option-"))) 42 | assert {x.text for x in completions} == set() 43 | 44 | completions = list(c.get_completions(Document("args-choices-hidden-cmd foo "))) 45 | assert {x.text for x in completions} == set() 46 | 47 | completions = list( 48 | c.get_completions(Document("args-choices-hidden-cmd --handler ")) 49 | ) 50 | assert {x.text for x in completions} == set() 51 | 52 | 53 | def test_completion_multilevel_command(): 54 | @click.group() 55 | def root_group(): 56 | pass 57 | 58 | @root_group.group() 59 | def first_level_command(): 60 | pass 61 | 62 | @first_level_command.command() 63 | def second_level_command_one(): 64 | pass 65 | 66 | @first_level_command.command() 67 | def second_level_command_two(): 68 | pass 69 | 70 | c = ClickCompleter(root_group, click.Context(root_group)) 71 | 72 | completions = list(c.get_completions(Document("first-level-command "))) 73 | assert set(x.text for x in completions) == { 74 | "second-level-command-one", 75 | "second-level-command-two", 76 | } 77 | 78 | completions = list(c.get_completions(Document(" "))) 79 | assert {x.text for x in completions} == {"first-level-command"} 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | click-repl 2 | === 3 | 4 | [![Tests](https://github.com/click-contrib/click-repl/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/click-contrib/click-repl/actions/workflows/tests.yml) 5 | [![License](https://img.shields.io/pypi/l/click-repl?label=License)](https://github.com/click-contrib/click-repl/LICENSE) 6 | ![Python - version](https://img.shields.io/badge/python-3%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11-blue) 7 | [![PyPi - version](https://img.shields.io/badge/pypi-v0.2.0-blue)](https://pypi.org/project/click-repl/) 8 | ![wheels](https://img.shields.io/piwheels/v/click-repl?label=wheel) 9 | ![PyPI - Status](https://img.shields.io/pypi/status/click) 10 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/click-repl) 11 | 12 | Installation 13 | === 14 | 15 | Installation is done via pip: 16 | ``` 17 | pip install click-repl 18 | ``` 19 | Usage 20 | === 21 | 22 | In your [click](http://click.pocoo.org/) app: 23 | 24 | ```py 25 | import click 26 | from click_repl import register_repl 27 | 28 | @click.group() 29 | def cli(): 30 | pass 31 | 32 | @cli.command() 33 | def hello(): 34 | click.echo("Hello world!") 35 | 36 | register_repl(cli) 37 | cli() 38 | ``` 39 | In the shell: 40 | ``` 41 | $ my_app repl 42 | > hello 43 | Hello world! 44 | > ^C 45 | $ echo hello | my_app repl 46 | Hello world! 47 | ``` 48 | **Features not shown:** 49 | 50 | - Tab-completion. 51 | - The parent context is reused, which means `ctx.obj` persists between 52 | subcommands. If you're keeping caches on that object (like I do), using the 53 | app's repl instead of the shell is a huge performance win. 54 | - `!` - prefix executes shell commands. 55 | 56 | You can use the internal `:help` command to explain usage. 57 | 58 | Advanced Usage 59 | === 60 | 61 | For more flexibility over how your REPL works you can use the `repl` function 62 | directly instead of `register_repl`. For example, in your app: 63 | 64 | ```py 65 | import click 66 | from click_repl import repl 67 | from prompt_toolkit.history import FileHistory 68 | 69 | @click.group() 70 | def cli(): 71 | pass 72 | 73 | @cli.command() 74 | def myrepl(): 75 | prompt_kwargs = { 76 | 'history': FileHistory('/etc/myrepl/myrepl-history'), 77 | } 78 | repl(click.get_current_context(), prompt_kwargs=prompt_kwargs) 79 | 80 | cli() 81 | ``` 82 | And then your custom `myrepl` command will be available on your CLI, which 83 | will start a REPL which has its history stored in 84 | `/etc/myrepl/myrepl-history` and persist between sessions. 85 | 86 | Any arguments that can be passed to the [`python-prompt-toolkit`](https://github.com/prompt-toolkit/python-prompt-toolkit) [Prompt](http://python-prompt-toolkit.readthedocs.io/en/stable/pages/reference.html?prompt_toolkit.shortcuts.Prompt#prompt_toolkit.shortcuts.Prompt) class 87 | can be passed in the `prompt_kwargs` argument and will be used when 88 | instantiating your `Prompt`. 89 | -------------------------------------------------------------------------------- /tests/test_completion/test_click_version_ge_8/test_arg_completion_v8.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | import pytest 5 | 6 | 7 | @click.group() 8 | def root_command(): 9 | pass 10 | 11 | 12 | c = ClickCompleter(root_command, click.Context(root_command)) 13 | 14 | with pytest.importorskip( 15 | "click.shell_complete.CompletionItem", 16 | minversion="8.0.0", 17 | reason="click-v8 built-in shell complete is not available, so skipped", 18 | ) as CompletionItem: 19 | 20 | def test_shell_complete_arg_v8_class_type(): 21 | class MyVar(click.ParamType): 22 | name = "myvar" 23 | 24 | def shell_complete(self, ctx, param, incomplete): 25 | return [ 26 | CompletionItem(name) 27 | for name in ("foo", "bar") 28 | if name.startswith(incomplete) 29 | ] 30 | 31 | @root_command.command() 32 | @click.argument("handler", type=MyVar()) 33 | def autocompletion_arg_cmd(handler): 34 | pass 35 | 36 | completions = list(c.get_completions(Document("autocompletion-cmd "))) 37 | assert {x.text for x in completions} == {"foo", "bar"} 38 | 39 | 40 | with pytest.importorskip( 41 | "click.shell_complete.CompletionItem", 42 | minversion="8.0.0", 43 | reason="click-v8 built-in shell complete is not available, so skipped", 44 | ) as CompletionItem: 45 | 46 | def test_shell_complete_arg_v8_func_type(): 47 | def shell_complete_func(ctx, param, incomplete): 48 | return [ 49 | CompletionItem(name) 50 | for name in ("foo", "bar") 51 | if name.startswith(incomplete) 52 | ] 53 | 54 | @root_command.command() 55 | @click.argument("handler", shell_complete=shell_complete_func) 56 | def autocompletion_cmd2(handler): 57 | pass 58 | 59 | completions = list(c.get_completions(Document("autocompletion-cmd2 "))) 60 | assert {x.text for x in completions} == {"foo", "bar"} 61 | 62 | 63 | @pytest.mark.skipif( 64 | click.__version__[0] < "8", 65 | reason="click-v8 built-in shell complete is not available, so skipped", 66 | ) 67 | def test_tuple_return_type_shell_complete_func(): 68 | def return_type_tuple_shell_complete(ctx, param, incomplete): 69 | return [ 70 | i 71 | for i in [ 72 | ("Hi", "hi"), 73 | ("Please", "please"), 74 | ("Hey", "hey"), 75 | ("Aye", "aye"), 76 | ] 77 | if i[1].startswith(incomplete) 78 | ] 79 | 80 | @root_command.command() 81 | @click.argument("foo", shell_complete=return_type_tuple_shell_complete) 82 | def tuple_type_autocompletion_cmd(foo): 83 | pass 84 | 85 | completions = list(c.get_completions(Document("tuple-type-autocompletion-cmd "))) 86 | assert {x.text for x in completions} == {"Hi", "Please", "Hey", "Aye"} 87 | 88 | completions = list(c.get_completions(Document("tuple-type-autocompletion-cmd h"))) 89 | assert {x.text for x in completions} == {"Hi", "Hey"} and { 90 | x.display_meta[0][-1] for x in completions 91 | } == {"hi", "hey"} 92 | -------------------------------------------------------------------------------- /tests/test_completion/test_common_tests/test_option_completion.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_repl import ClickCompleter 3 | from prompt_toolkit.document import Document 4 | 5 | 6 | @click.group() 7 | def root_command(): 8 | pass 9 | 10 | 11 | c = ClickCompleter(root_command, click.Context(root_command)) 12 | 13 | 14 | def test_option_choices(): 15 | @root_command.command() 16 | @click.option("--handler", type=click.Choice(("foo", "bar"))) 17 | @click.option("--wrong", type=click.Choice(("bogged", "bogus"))) 18 | def option_choices(handler): 19 | pass 20 | 21 | completions = list(c.get_completions(Document("option-choices --handler "))) 22 | assert {x.text for x in completions} == {"foo", "bar"} 23 | 24 | completions = list(c.get_completions(Document("option-choices --wrong "))) 25 | assert {x.text for x in completions} == {"bogged", "bogus"} 26 | 27 | 28 | def test_boolean_option(): 29 | @root_command.command() 30 | @click.option("--foo", type=click.BOOL) 31 | def bool_option(foo): 32 | pass 33 | 34 | completions = list(c.get_completions(Document("bool-option --foo "))) 35 | assert {x.text for x in completions} == {"true", "false"} 36 | 37 | completions = list(c.get_completions(Document("bool-option --foo t"))) 38 | assert {x.text for x in completions} == {"true"} 39 | 40 | 41 | def test_only_unused_with_unique_option(): 42 | @root_command.command() 43 | @click.option("-u", type=click.BOOL) 44 | def unique_option(u): 45 | pass 46 | 47 | c.show_only_unused = True 48 | 49 | completions = list(c.get_completions(Document("unique-option "))) 50 | assert {x.text for x in completions} == {"-u"} 51 | 52 | completions = list(c.get_completions(Document("unique-option -u t "))) 53 | assert len(completions) == 0 54 | 55 | c.show_only_unused = False 56 | 57 | completions = list(c.get_completions(Document("unique-option -u t "))) 58 | assert {x.text for x in completions} == {"-u"} 59 | 60 | 61 | def test_only_unused_with_multiple_option(): 62 | @root_command.command() 63 | @click.option("-u", type=click.BOOL, multiple=True) 64 | def multiple_option(u): 65 | pass 66 | 67 | c.show_only_unused = True 68 | 69 | completions = list(c.get_completions(Document("multiple-option "))) 70 | assert {x.text for x in completions} == {"-u"} 71 | 72 | completions = list(c.get_completions(Document("multiple-option -u t "))) 73 | assert {x.text for x in completions} == {"-u"} 74 | 75 | c.show_only_unused = False 76 | 77 | completions = list(c.get_completions(Document("multiple-option -u t "))) 78 | assert {x.text for x in completions} == {"-u"} 79 | 80 | 81 | def test_shortest_only_mode(): 82 | @root_command.command() 83 | @click.option("--foo", "-f", is_flag=True) 84 | @click.option("-b", "--bar", is_flag=True) 85 | @click.option("--foobar", is_flag=True) 86 | def shortest_only(foo, bar, foobar): 87 | pass 88 | 89 | c.shortest_only = True 90 | 91 | completions = list(c.get_completions(Document("shortest-only "))) 92 | assert {x.text for x in completions} == {"-f", "-b", "--foobar"} 93 | 94 | completions = list(c.get_completions(Document("shortest-only -"))) 95 | assert {x.text for x in completions} == {"-f", "--foo", "-b", "--bar", "--foobar"} 96 | 97 | completions = list(c.get_completions(Document("shortest-only --f"))) 98 | assert {x.text for x in completions} == {"--foo", "--foobar"} 99 | 100 | c.shortest_only = False 101 | 102 | completions = list(c.get_completions(Document("shortest-only "))) 103 | assert {x.text for x in completions} == {"-f", "--foo", "-b", "--bar", "--foobar"} 104 | -------------------------------------------------------------------------------- /tests/test_repl.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pytest 3 | 4 | import click_repl 5 | from tests import mock_stdin 6 | 7 | 8 | def test_simple_repl(): 9 | @click.group() 10 | def cli(): 11 | pass 12 | 13 | @cli.command() 14 | @click.option("--baz", is_flag=True) 15 | def foo(baz): 16 | print("Foo!") 17 | 18 | @cli.command() 19 | @click.option("--foo", is_flag=True) 20 | def bar(foo): 21 | print("Bar!") 22 | 23 | click_repl.register_repl(cli) 24 | 25 | with pytest.raises(SystemExit): 26 | cli(args=[], prog_name="test_simple_repl") 27 | 28 | 29 | def test_repl_dispatches_subcommand(capsys): 30 | @click.group(invoke_without_command=True) 31 | @click.pass_context 32 | def cli(ctx): 33 | if ctx.invoked_subcommand is None: 34 | click_repl.repl(ctx) 35 | 36 | @cli.command() 37 | def foo(): 38 | print("Foo!") 39 | 40 | with mock_stdin("foo\n"): 41 | with pytest.raises(SystemExit): 42 | cli(args=[], prog_name="test_repl_dispatch_subcommand") 43 | 44 | assert capsys.readouterr().out.replace("\r\n", "\n") == "Foo!\n" 45 | 46 | 47 | def test_group_command_called(capsys): 48 | @click.group(invoke_without_command=True) 49 | @click.pass_context 50 | def cli(ctx): 51 | print("cli()") 52 | if ctx.invoked_subcommand is None: 53 | click_repl.repl(ctx) 54 | 55 | @cli.command() 56 | def foo(): 57 | print("Foo!") 58 | 59 | @cli.command() 60 | def bar(): 61 | print("Bar!") 62 | 63 | with mock_stdin("foo\nbar\n"): 64 | with pytest.raises(SystemExit): 65 | cli(args=[], prog_name="test_group_called") 66 | 67 | assert capsys.readouterr().out.replace("\r\n", "\n") == ( 68 | "cli()\ncli()\nFoo!\ncli()\nBar!\n" 69 | ) 70 | 71 | 72 | @click.group(invoke_without_command=True) 73 | @click.argument("argument", required=False) 74 | @click.pass_context 75 | def cli_arg_required_false(ctx, argument): 76 | if ctx.invoked_subcommand is None: 77 | click_repl.repl(ctx) 78 | 79 | 80 | @cli_arg_required_false.command() 81 | def foo(): 82 | print("Foo") 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "args, stdin, expected_err, expected_output", 87 | [ 88 | ([], "foo\n", click_repl.exceptions.InvalidGroupFormat, ""), 89 | (["temp_arg"], "", SystemExit, ""), 90 | (["temp_arg"], "foo\n", SystemExit, "Foo\n"), 91 | ], 92 | ) 93 | def test_group_argument_with_required_false( 94 | capsys, args, stdin, expected_err, expected_output 95 | ): 96 | with pytest.raises(expected_err): 97 | with mock_stdin(stdin): 98 | cli_arg_required_false(args=args, prog_name="cli_arg_required_false") 99 | 100 | assert capsys.readouterr().out.replace("\r\n", "\n") == expected_output 101 | 102 | 103 | @click.group(invoke_without_command=True) 104 | @click.argument("argument") 105 | @click.option("--option1", default=1, type=click.STRING) 106 | @click.option("--option2") 107 | @click.pass_context 108 | def cmd(ctx, argument, option1, option2): 109 | print(f"cli({argument}, {option1}, {option2})") 110 | if ctx.invoked_subcommand is None: 111 | click_repl.repl(ctx) 112 | 113 | 114 | @cmd.command("foo") 115 | def foo2(): 116 | print("Foo!") 117 | 118 | 119 | @pytest.mark.parametrize( 120 | "args, expected", 121 | [ 122 | (["hi"], "cli(hi, 1, None)\ncli(hi, 1, None)\nFoo!\n"), 123 | ( 124 | ["--option1", "opt1", "hi"], 125 | "cli(hi, opt1, None)\ncli(hi, opt1, None)\nFoo!\n", 126 | ), 127 | (["--option2", "opt2", "hi"], "cli(hi, 1, opt2)\ncli(hi, 1, opt2)\nFoo!\n"), 128 | ( 129 | ["--option1", "opt1", "--option2", "opt2", "hi"], 130 | "cli(hi, opt1, opt2)\ncli(hi, opt1, opt2)\nFoo!\n", 131 | ), 132 | ], 133 | ) 134 | def test_group_with_multiple_optional_args(capsys, args, expected): 135 | with pytest.raises(SystemExit): 136 | with mock_stdin("foo\n"): 137 | cmd(args=args, prog_name="test_group_with_multiple_args") 138 | assert capsys.readouterr().out.replace("\r\n", "\n") == expected 139 | 140 | 141 | def test_inputs(capsys): 142 | @click.group(invoke_without_command=True) 143 | @click.pass_context 144 | def cli(ctx): 145 | if ctx.invoked_subcommand is None: 146 | ctx.invoke(repl) 147 | 148 | @cli.command() 149 | @click.pass_context 150 | def repl(ctx): 151 | click_repl.repl(ctx) 152 | 153 | try: 154 | cli(args=["repl"], prog_name="test_inputs") 155 | 156 | except (SystemExit, Exception) as e: 157 | if ( 158 | type(e).__name__ == "prompt_toolkit.output.win32.NoConsoleScreenBufferError" 159 | and str(e) == "No Windows console found. Are you running cmd.exe?" 160 | ): 161 | pass 162 | 163 | captured_stdout = capsys.readouterr().out.replace("\r\n", "\n") 164 | assert captured_stdout == "" 165 | -------------------------------------------------------------------------------- /click_repl/_repl.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import Any, MutableMapping, cast 5 | 6 | import click 7 | from prompt_toolkit.history import InMemoryHistory 8 | 9 | from ._completer import ClickCompleter 10 | from .core import ReplContext 11 | from .exceptions import ClickExit # type: ignore[attr-defined] 12 | from .exceptions import CommandLineParserError, ExitReplException, InvalidGroupFormat 13 | from .globals_ import ISATTY, get_current_repl_ctx 14 | from .utils import _execute_internal_and_sys_cmds 15 | 16 | __all__ = ["bootstrap_prompt", "register_repl", "repl"] 17 | 18 | 19 | def bootstrap_prompt( 20 | group: click.MultiCommand, 21 | prompt_kwargs: dict[str, Any], 22 | ctx: click.Context, 23 | ) -> dict[str, Any]: 24 | """ 25 | Bootstrap prompt_toolkit kwargs or use user defined values. 26 | 27 | :param group: click.MultiCommand object 28 | :param prompt_kwargs: The user specified prompt kwargs. 29 | """ 30 | 31 | defaults = { 32 | "history": InMemoryHistory(), 33 | "completer": ClickCompleter(group, ctx=ctx), 34 | "message": "> ", 35 | } 36 | 37 | defaults.update(prompt_kwargs) 38 | return defaults 39 | 40 | 41 | def repl( 42 | old_ctx: click.Context, 43 | prompt_kwargs: dict[str, Any] = {}, 44 | allow_system_commands: bool = True, 45 | allow_internal_commands: bool = True, 46 | ) -> None: 47 | """ 48 | Start an interactive shell. All subcommands are available in it. 49 | 50 | :param old_ctx: The current Click context. 51 | :param prompt_kwargs: Parameters passed to 52 | :py:func:`prompt_toolkit.PromptSession`. 53 | 54 | If stdin is not a TTY, no prompt will be printed, but only commands read 55 | from stdin. 56 | """ 57 | 58 | group_ctx = old_ctx 59 | # Switching to the parent context that has a Group as its command 60 | # as a Group acts as a CLI for all of its subcommands 61 | if old_ctx.parent is not None and not isinstance( 62 | old_ctx.command, click.MultiCommand 63 | ): 64 | group_ctx = old_ctx.parent 65 | 66 | group = cast(click.MultiCommand, group_ctx.command) 67 | 68 | # An Optional click.Argument in the CLI Group, that has no value 69 | # will consume the first word from the REPL input, causing issues in 70 | # executing the command 71 | # So, if there's an empty Optional Argument 72 | for param in group.params: 73 | if ( 74 | isinstance(param, click.Argument) 75 | and group_ctx.params[param.name] is None # type: ignore[index] 76 | and not param.required 77 | ): 78 | raise InvalidGroupFormat( 79 | f"{type(group).__name__} '{group.name}' requires value for " 80 | f"an optional argument '{param.name}' in REPL mode" 81 | ) 82 | 83 | # Delete the REPL command from those available, as we don't want to allow 84 | # nesting REPLs (note: pass `None` to `pop` as we don't want to error if 85 | # REPL command already not present for some reason). 86 | repl_command_name = old_ctx.command.name 87 | 88 | available_commands: MutableMapping[str, click.Command] = {} 89 | 90 | if isinstance(group, click.CommandCollection): 91 | available_commands = { 92 | cmd_name: source.get_command(group_ctx, cmd_name) # type: ignore[misc] 93 | for source in group.sources 94 | for cmd_name in source.list_commands(group_ctx) 95 | } 96 | 97 | elif isinstance(group, click.Group): 98 | available_commands = group.commands 99 | 100 | original_command = available_commands.pop(repl_command_name, None) # type: ignore 101 | 102 | repl_ctx = ReplContext( 103 | group_ctx, 104 | bootstrap_prompt(group, prompt_kwargs, group_ctx), 105 | get_current_repl_ctx(silent=True), 106 | ) 107 | 108 | if ISATTY: 109 | # If stdin is a TTY, prompt the user for input using PromptSession. 110 | def get_command() -> str: 111 | return repl_ctx.session.prompt() # type: ignore 112 | 113 | else: 114 | # If stdin is not a TTY, read input from stdin directly. 115 | def get_command() -> str: 116 | inp = sys.stdin.readline().strip() 117 | repl_ctx._history.append(inp) 118 | return inp 119 | 120 | with repl_ctx: 121 | while True: 122 | try: 123 | command = get_command() 124 | except KeyboardInterrupt: 125 | continue 126 | except EOFError: 127 | break 128 | 129 | if not command: 130 | if ISATTY: 131 | continue 132 | else: 133 | break 134 | 135 | try: 136 | args = _execute_internal_and_sys_cmds( 137 | command, allow_internal_commands, allow_system_commands 138 | ) 139 | if args is None: 140 | continue 141 | 142 | except CommandLineParserError: 143 | continue 144 | 145 | except ExitReplException: 146 | break 147 | 148 | try: 149 | # The group command will dispatch based on args. 150 | old_protected_args = group_ctx.protected_args 151 | try: 152 | group_ctx.protected_args = args 153 | group.invoke(group_ctx) 154 | finally: 155 | group_ctx.protected_args = old_protected_args 156 | except click.ClickException as e: 157 | e.show() 158 | except (ClickExit, SystemExit): 159 | pass 160 | 161 | except ExitReplException: 162 | break 163 | 164 | if original_command is not None: 165 | available_commands[repl_command_name] = original_command # type: ignore[index] 166 | 167 | 168 | def register_repl(group: click.Group, name="repl") -> None: 169 | """Register :func:`repl()` as sub-command *name* of *group*.""" 170 | group.command(name=name)(click.pass_context(repl)) 171 | -------------------------------------------------------------------------------- /click_repl/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core functionalities for managing context of the click_repl app. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from functools import wraps 8 | from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, TypeVar 9 | 10 | from click import Context 11 | from prompt_toolkit import PromptSession 12 | from typing_extensions import Concatenate, Final, ParamSpec, TypeAlias, TypedDict 13 | 14 | from ._ctx_stack import _pop_context, _push_context 15 | from .globals_ import ISATTY, get_current_repl_ctx 16 | 17 | if TYPE_CHECKING: 18 | from prompt_toolkit.formatted_text import AnyFormattedText 19 | 20 | 21 | P = ParamSpec("P") 22 | R = TypeVar("R") 23 | F = TypeVar("F", bound=Callable[..., Any]) 24 | 25 | 26 | __all__ = ["ReplContext", "pass_context"] 27 | 28 | 29 | _PromptSession: TypeAlias = PromptSession[Dict[str, Any]] 30 | 31 | 32 | class ReplContextInfoDict(TypedDict): 33 | group_ctx: Context 34 | prompt_kwargs: dict[str, Any] 35 | session: _PromptSession | None 36 | parent: ReplContext | None 37 | _history: list[str] 38 | 39 | 40 | class ReplContext: 41 | """ 42 | Context object for the REPL sessions. 43 | 44 | This class tracks the depth of nested REPLs, ensuring seamless navigation 45 | between different levels. It facilitates nested REPL scenarios, allowing 46 | multiple levels of interactive REPL sessions. 47 | 48 | Each REPL's properties are stored inside this context class, allowing them to 49 | be accessed and shared with their parent REPL. 50 | 51 | All the settings for each REPL session persist until the session is terminated. 52 | 53 | Parameters 54 | ---------- 55 | group_ctx 56 | The click context object that belong to the CLI/parent Group. 57 | 58 | prompt_kwargs 59 | Extra keyword arguments for 60 | :class:`~prompt_toolkit.shortcuts.PromptSession` class. 61 | 62 | parent 63 | REPL Context object of the parent REPL session, if exists. Otherwise, :obj:`None`. 64 | """ 65 | 66 | __slots__ = ( 67 | "group_ctx", 68 | "prompt_kwargs", 69 | "parent", 70 | "session", 71 | "_history", 72 | ) 73 | 74 | def __init__( 75 | self, 76 | group_ctx: Context, 77 | prompt_kwargs: dict[str, Any] = {}, 78 | parent: ReplContext | None = None, 79 | ) -> None: 80 | """ 81 | Initializes the `ReplContext` class. 82 | """ 83 | session: _PromptSession | None 84 | 85 | if ISATTY: 86 | session = PromptSession(**prompt_kwargs) 87 | 88 | else: 89 | session = None 90 | 91 | self.group_ctx: Final[Context] = group_ctx 92 | """The click context object that belong to the CLI/parent Group.""" 93 | 94 | self.session = session 95 | """Object that's responsible for managing and executing the REPL.""" 96 | 97 | self._history: list[str] = [] 98 | """ 99 | History of past executed commands. 100 | 101 | Used only when :func:`~sys.stdin.isatty` is :obj:`False`. 102 | """ 103 | 104 | self.prompt_kwargs = prompt_kwargs 105 | """ 106 | Extra keyword arguments for 107 | :class:`~prompt_toolkit.shortcuts.PromptSession` class. 108 | """ 109 | 110 | self.parent: Final[ReplContext | None] = parent 111 | """ 112 | REPL Context object of the parent REPL session, if exists. 113 | Otherwise, :obj:`None`. 114 | """ 115 | 116 | def __enter__(self) -> ReplContext: 117 | _push_context(self) 118 | return self 119 | 120 | def __exit__(self, *_: Any) -> None: 121 | _pop_context() 122 | 123 | @property 124 | def prompt(self) -> AnyFormattedText: 125 | """ 126 | The prompt text of the REPL. 127 | 128 | Returns 129 | ------- 130 | prompt_toolkit.formatted_text.AnyFormattedText 131 | The prompt object if :func:`~sys.stdin.isatty` is :obj:`True`, 132 | else :obj:`None`. 133 | """ 134 | if ISATTY and self.session is not None: 135 | return self.session.message 136 | return None 137 | 138 | @prompt.setter 139 | def prompt(self, value: AnyFormattedText) -> None: 140 | if ISATTY and self.session is not None: 141 | self.session.message = value 142 | 143 | def to_info_dict(self) -> ReplContextInfoDict: 144 | """ 145 | Provides a dictionary with minimal info about the current REPL. 146 | 147 | Returns 148 | ------- 149 | ReplContextInfoDict 150 | A dictionary that has the instance variables and their values. 151 | """ 152 | 153 | res: ReplContextInfoDict = { 154 | "group_ctx": self.group_ctx, 155 | "prompt_kwargs": self.prompt_kwargs, 156 | "session": self.session, 157 | "parent": self.parent, 158 | "_history": self._history, 159 | } 160 | 161 | return res 162 | 163 | def session_reset(self) -> None: 164 | """ 165 | Resets values of :class:`~prompt_toolkit.session.PromptSession` to 166 | the provided :attr:`~.prompt_kwargs`, discarding any changes done to the 167 | :class:`~prompt_toolkit.session.PromptSession` object. 168 | """ 169 | 170 | if ISATTY and self.session is not None: 171 | self.session = PromptSession(**self.prompt_kwargs) 172 | 173 | def history(self) -> Generator[str, None, None]: 174 | """ 175 | Generates the history of past executed commands. 176 | 177 | Yields 178 | ------ 179 | str 180 | The executed command string from the history, 181 | in chronological order from most recent to oldest. 182 | """ 183 | 184 | if ISATTY and self.session is not None: 185 | yield from self.session.history.load_history_strings() 186 | 187 | else: 188 | yield from reversed(self._history) 189 | 190 | 191 | def pass_context( 192 | func: Callable[Concatenate[ReplContext | None, P], R], 193 | ) -> Callable[P, R]: 194 | """ 195 | Decorator that marks a callback function to receive the current 196 | REPL context object as its first argument. 197 | 198 | Parameters 199 | ---------- 200 | func 201 | The callback function to pass context as its first parameter. 202 | 203 | Returns 204 | ------- 205 | Callable[P,R] 206 | The decorated callback function that receives the current REPL 207 | context object as its first argument. 208 | """ 209 | 210 | @wraps(func) 211 | def decorator(*args: P.args, **kwargs: P.kwargs) -> R: 212 | return func(get_current_repl_ctx(), *args, **kwargs) 213 | 214 | return decorator 215 | -------------------------------------------------------------------------------- /click_repl/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import shlex 5 | import typing as t 6 | from collections import defaultdict 7 | from typing import Callable, Generator, Iterator, NoReturn, Sequence 8 | 9 | import click 10 | from typing_extensions import TypeAlias 11 | 12 | from .exceptions import CommandLineParserError, ExitReplException 13 | 14 | T = t.TypeVar("T") 15 | InternalCommandCallback: TypeAlias = Callable[[], None] 16 | 17 | 18 | __all__ = [ 19 | "_execute_internal_and_sys_cmds", 20 | "_exit_internal", 21 | "_get_registered_target", 22 | "_help_internal", 23 | "_resolve_context", 24 | "_register_internal_command", 25 | "dispatch_repl_commands", 26 | "handle_internal_commands", 27 | "split_arg_string", 28 | "exit", 29 | ] 30 | 31 | 32 | def _resolve_context(args: list[str], ctx: click.Context) -> click.Context: 33 | """Produce the context hierarchy starting with the command and 34 | traversing the complete arguments. This only follows the commands, 35 | it doesn't trigger input prompts or callbacks. 36 | 37 | :param args: List of complete args before the incomplete value. 38 | :param cli_ctx: `click.Context` object of the CLI group 39 | """ 40 | 41 | while args: 42 | command = ctx.command 43 | 44 | if isinstance(command, click.MultiCommand): 45 | if not command.chain: 46 | name, cmd, args = command.resolve_command(ctx, args) 47 | 48 | if cmd is None: 49 | return ctx 50 | 51 | ctx = cmd.make_context(name, args, parent=ctx, resilient_parsing=True) 52 | args = ctx.protected_args + ctx.args 53 | else: 54 | while args: 55 | name, cmd, args = command.resolve_command(ctx, args) 56 | 57 | if cmd is None: 58 | return ctx 59 | 60 | sub_ctx = cmd.make_context( 61 | name, 62 | args, 63 | parent=ctx, 64 | allow_extra_args=True, 65 | allow_interspersed_args=False, 66 | resilient_parsing=True, 67 | ) 68 | args = sub_ctx.args 69 | 70 | ctx = sub_ctx 71 | args = [*sub_ctx.protected_args, *sub_ctx.args] 72 | else: 73 | break 74 | 75 | return ctx 76 | 77 | 78 | _internal_commands: dict[str, tuple[InternalCommandCallback, str | None]] = {} 79 | 80 | 81 | def split_arg_string(string: str, posix: bool = True) -> list[str]: 82 | """Split an argument string as with :func:`shlex.split`, but don't 83 | fail if the string is incomplete. Ignores a missing closing quote or 84 | incomplete escape sequence and uses the partial token as-is. 85 | .. code-block:: python 86 | split_arg_string("example 'my file") 87 | ["example", "my file"] 88 | split_arg_string("example my\\") 89 | ["example", "my"] 90 | :param string: String to split. 91 | """ 92 | 93 | lex = shlex.shlex(string, posix=posix) 94 | lex.whitespace_split = True 95 | lex.commenters = "" 96 | out = [] 97 | 98 | try: 99 | for token in lex: 100 | out.append(token) 101 | except ValueError: 102 | # Raised when end-of-string is reached in an invalid state. Use 103 | # the partial token as-is. The quote or escape character is in 104 | # lex.state, not lex.token. 105 | out.append(lex.token) 106 | 107 | return out 108 | 109 | 110 | def _register_internal_command( 111 | names: str | Sequence[str] | Generator[str, None, None] | Iterator[str], 112 | target: InternalCommandCallback, 113 | description: str | None = None, 114 | ) -> None: 115 | if not hasattr(target, "__call__"): 116 | raise ValueError("Internal command must be a callable") 117 | 118 | if isinstance(names, str): 119 | names = [names] 120 | 121 | elif not isinstance(names, (Sequence, Generator, Iterator)): 122 | raise ValueError( 123 | '"names" must be a string, or a Sequence of strings, but got "{}"'.format( 124 | type(names).__name__ 125 | ) 126 | ) 127 | 128 | for name in names: 129 | _internal_commands[name] = (target, description) 130 | 131 | 132 | def _get_registered_target( 133 | name: str, default: T | None = None 134 | ) -> InternalCommandCallback | T | None: 135 | target_info = _internal_commands.get(name, None) 136 | if target_info: 137 | return target_info[0] 138 | return default 139 | 140 | 141 | def _exit_internal() -> NoReturn: 142 | raise ExitReplException() 143 | 144 | 145 | def _help_internal() -> None: 146 | formatter = click.HelpFormatter() 147 | formatter.write_heading("REPL help") 148 | formatter.indent() 149 | 150 | with formatter.section("External Commands"): 151 | formatter.write_text('prefix external commands with "!"') 152 | 153 | with formatter.section("Internal Commands"): 154 | formatter.write_text('prefix internal commands with ":"') 155 | info_table = defaultdict(list) 156 | 157 | for mnemonic, target_info in _internal_commands.items(): 158 | info_table[target_info[1]].append(mnemonic) 159 | 160 | formatter.write_dl( # type: ignore[arg-type] 161 | ( # type: ignore[arg-type] 162 | ", ".join(map(":{}".format, sorted(mnemonics))), 163 | description, 164 | ) 165 | for description, mnemonics in info_table.items() 166 | ) 167 | 168 | print(formatter.getvalue()) 169 | 170 | 171 | _register_internal_command(["q", "quit", "exit"], _exit_internal, "exits the repl") 172 | _register_internal_command( 173 | ["?", "h", "help"], _help_internal, "displays general help information" 174 | ) 175 | 176 | 177 | def _execute_internal_and_sys_cmds( 178 | command: str, 179 | allow_internal_commands: bool = True, 180 | allow_system_commands: bool = True, 181 | ) -> list[str] | None: 182 | """ 183 | Executes internal, system, and all the other registered click commands from the input 184 | """ 185 | if allow_system_commands and dispatch_repl_commands(command): 186 | return None 187 | 188 | if allow_internal_commands and command.startswith(":"): 189 | handle_internal_commands(command) 190 | return None 191 | 192 | try: 193 | return split_arg_string(command) 194 | except ValueError as e: 195 | raise CommandLineParserError("{}".format(e)) 196 | 197 | 198 | def exit() -> NoReturn: 199 | """Exit the repl""" 200 | _exit_internal() 201 | 202 | 203 | def dispatch_repl_commands(command: str) -> bool: 204 | """ 205 | Execute system commands entered in the repl. 206 | 207 | System commands are all commands starting with "!". 208 | """ 209 | if command.startswith("!"): 210 | os.system(command[1:]) 211 | return True 212 | 213 | return False 214 | 215 | 216 | def handle_internal_commands(command: str) -> None: 217 | """ 218 | Run repl-internal commands. 219 | 220 | Repl-internal commands are all commands starting with ":". 221 | """ 222 | target = _get_registered_target(command[1:], default=None) 223 | if target: 224 | target() 225 | -------------------------------------------------------------------------------- /click_repl/_completer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import typing as t 5 | from glob import iglob 6 | from typing import Generator 7 | 8 | import click 9 | from prompt_toolkit.completion import CompleteEvent, Completer, Completion 10 | from prompt_toolkit.document import Document 11 | 12 | from .utils import _resolve_context, split_arg_string 13 | 14 | __all__ = ["ClickCompleter"] 15 | 16 | IS_WINDOWS = os.name == "nt" 17 | 18 | 19 | # Handle backwards compatibility between Click<=7.0 and >=8.0 20 | try: 21 | import click.shell_completion 22 | 23 | HAS_CLICK_V8 = True 24 | AUTO_COMPLETION_PARAM = "shell_complete" 25 | except (ImportError, ModuleNotFoundError): 26 | import click._bashcomplete # type: ignore[import] 27 | 28 | HAS_CLICK_V8 = False 29 | AUTO_COMPLETION_PARAM = "autocompletion" 30 | 31 | 32 | class ClickCompleter(Completer): 33 | __slots__ = ("cli", "ctx", "parsed_args", "parsed_ctx", "ctx_command") 34 | 35 | def __init__( 36 | self, 37 | cli: click.MultiCommand, 38 | ctx: click.Context, 39 | show_only_unused: bool = False, 40 | shortest_only: bool = False, 41 | ) -> None: 42 | self.cli = cli 43 | self.ctx = ctx 44 | self.parsed_args: list[str] = [] 45 | self.parsed_ctx = ctx 46 | self.ctx_command = ctx.command 47 | self.show_only_unused = show_only_unused 48 | self.shortest_only = shortest_only 49 | 50 | def _get_completion_from_autocompletion_functions( 51 | self, 52 | param: click.Parameter, 53 | autocomplete_ctx: click.Context, 54 | args: list[str], 55 | incomplete: str, 56 | ) -> list[Completion]: 57 | param_choices: list[Completion] = [] 58 | 59 | if HAS_CLICK_V8: 60 | autocompletions = param.shell_complete(autocomplete_ctx, incomplete) 61 | else: 62 | autocompletions = param.autocompletion( # type: ignore[attr-defined] 63 | autocomplete_ctx, args, incomplete 64 | ) 65 | 66 | for autocomplete in autocompletions: 67 | if isinstance(autocomplete, tuple): 68 | param_choices.append( 69 | Completion( 70 | str(autocomplete[0]), 71 | -len(incomplete), 72 | display_meta=autocomplete[1], 73 | ) 74 | ) 75 | 76 | elif HAS_CLICK_V8 and isinstance( 77 | autocomplete, click.shell_completion.CompletionItem 78 | ): 79 | param_choices.append(Completion(autocomplete.value, -len(incomplete))) 80 | 81 | else: 82 | param_choices.append(Completion(str(autocomplete), -len(incomplete))) 83 | 84 | return param_choices 85 | 86 | def _get_completion_from_choices_click_le_7( 87 | self, param: click.Parameter, incomplete: str 88 | ) -> list[Completion]: 89 | param_type = t.cast(click.Choice, param.type) 90 | 91 | if not getattr(param.type, "case_sensitive", True): 92 | incomplete = incomplete.lower() 93 | return [ 94 | Completion( 95 | choice, 96 | -len(incomplete), 97 | display=repr(choice) if " " in choice else choice, 98 | ) 99 | for choice in param_type.choices # type: ignore[attr-defined] 100 | if choice.lower().startswith(incomplete) 101 | ] 102 | 103 | else: 104 | return [ 105 | Completion( 106 | choice, 107 | -len(incomplete), 108 | display=repr(choice) if " " in choice else choice, 109 | ) 110 | for choice in param_type.choices # type: ignore[attr-defined] 111 | if choice.startswith(incomplete) 112 | ] 113 | 114 | def _get_completion_for_Path_types( 115 | self, param: click.Parameter, args: list[str], incomplete: str 116 | ) -> list[Completion]: 117 | if "*" in incomplete: 118 | return [] 119 | 120 | choices: list[Completion] = [] 121 | _incomplete = os.path.expandvars(incomplete) 122 | search_pattern = _incomplete.strip("'\"\t\n\r\v ").replace("\\\\", "\\") + "*" 123 | quote = "" 124 | 125 | if " " in _incomplete: 126 | for i in incomplete: 127 | if i in ("'", '"'): 128 | quote = i 129 | break 130 | 131 | for path in iglob(search_pattern): 132 | if " " in path: 133 | if quote: 134 | path = quote + path 135 | else: 136 | if IS_WINDOWS: 137 | path = repr(path).replace("\\\\", "\\") 138 | else: 139 | if IS_WINDOWS: 140 | path = path.replace("\\", "\\\\") 141 | 142 | choices.append( 143 | Completion( 144 | path, 145 | -len(incomplete), 146 | display=os.path.basename(path.strip("'\"")), 147 | ) 148 | ) 149 | 150 | return choices 151 | 152 | def _get_completion_for_Boolean_type( 153 | self, param: click.Parameter, incomplete: str 154 | ) -> list[Completion]: 155 | boolean_mapping: dict[str, tuple[str, ...]] = { 156 | "true": ("1", "true", "t", "yes", "y", "on"), 157 | "false": ("0", "false", "f", "no", "n", "off"), 158 | } 159 | 160 | return [ 161 | Completion(k, -len(incomplete), display_meta="/".join(v)) 162 | for k, v in boolean_mapping.items() 163 | if any(i.startswith(incomplete) for i in v) 164 | ] 165 | 166 | def _get_completion_from_params( 167 | self, 168 | autocomplete_ctx: click.Context, 169 | args: list[str], 170 | param: click.Parameter, 171 | incomplete: str, 172 | ) -> list[Completion]: 173 | choices: list[Completion] = [] 174 | param_type = param.type 175 | 176 | # shell_complete method for click.Choice is intorduced in click-v8 177 | if not HAS_CLICK_V8 and isinstance(param_type, click.Choice): 178 | choices.extend( 179 | self._get_completion_from_choices_click_le_7(param, incomplete) 180 | ) 181 | 182 | elif isinstance(param_type, click.types.BoolParamType): 183 | choices.extend(self._get_completion_for_Boolean_type(param, incomplete)) 184 | 185 | elif isinstance(param_type, (click.Path, click.File)): 186 | choices.extend(self._get_completion_for_Path_types(param, args, incomplete)) 187 | 188 | elif getattr(param, AUTO_COMPLETION_PARAM, None) is not None: 189 | choices.extend( 190 | self._get_completion_from_autocompletion_functions( 191 | param, 192 | autocomplete_ctx, 193 | args, 194 | incomplete, 195 | ) 196 | ) 197 | 198 | return choices 199 | 200 | def _get_completion_for_cmd_args( 201 | self, 202 | ctx_command: click.Command, 203 | incomplete: str, 204 | autocomplete_ctx: click.Context, 205 | args: list[str], 206 | ) -> list[Completion]: 207 | choices: list[Completion] = [] 208 | param_called = False 209 | 210 | for param in ctx_command.params: 211 | if isinstance(param.type, click.types.UnprocessedParamType): 212 | return [] 213 | 214 | elif getattr(param, "hidden", False): 215 | continue 216 | 217 | elif isinstance(param, click.Option): 218 | opts = param.opts + param.secondary_opts 219 | previous_args = args[: param.nargs * -1] 220 | current_args = args[param.nargs * -1 :] 221 | 222 | # Show only unused opts 223 | already_present = any([opt in previous_args for opt in opts]) 224 | hide = self.show_only_unused and already_present and not param.multiple 225 | 226 | # Show only shortest opt 227 | if ( 228 | self.shortest_only 229 | and not incomplete # just typed a space 230 | # not selecting a value for a longer version of this option 231 | and args[-1] not in opts 232 | ): 233 | opts = [min(opts, key=len)] 234 | 235 | for option in opts: 236 | # We want to make sure if this parameter was called 237 | # If we are inside a parameter that was called, we want to show only 238 | # relevant choices 239 | if option in current_args: # noqa: E203 240 | param_called = True 241 | break 242 | 243 | elif option.startswith(incomplete) and not hide: 244 | choices.append( 245 | Completion( 246 | option, 247 | -len(incomplete), 248 | display_meta=param.help or "", 249 | ) 250 | ) 251 | 252 | if param_called: 253 | choices = self._get_completion_from_params( 254 | autocomplete_ctx, args, param, incomplete 255 | ) 256 | break 257 | 258 | elif isinstance(param, click.Argument): 259 | choices.extend( 260 | self._get_completion_from_params( 261 | autocomplete_ctx, args, param, incomplete 262 | ) 263 | ) 264 | 265 | return choices 266 | 267 | def get_completions( 268 | self, document: Document, complete_event: CompleteEvent | None = None 269 | ) -> Generator[Completion, None, None]: 270 | # Code analogous to click._bashcomplete.do_complete 271 | 272 | args = split_arg_string(document.text_before_cursor, posix=False) 273 | 274 | choices: list[Completion] = [] 275 | cursor_within_command = ( 276 | document.text_before_cursor.rstrip() == document.text_before_cursor 277 | ) 278 | 279 | if document.text_before_cursor.startswith(("!", ":")): 280 | return 281 | 282 | if args and cursor_within_command: 283 | # We've entered some text and no space, give completions for the 284 | # current word. 285 | incomplete = args.pop() 286 | else: 287 | # We've not entered anything, either at all or for the current 288 | # command, so give all relevant completions for this context. 289 | incomplete = "" 290 | 291 | if self.parsed_args != args: 292 | self.parsed_args = args 293 | try: 294 | self.parsed_ctx = _resolve_context(args, self.ctx) 295 | except Exception: 296 | return # autocompletion for nonexistent cmd can throw here 297 | self.ctx_command = self.parsed_ctx.command 298 | 299 | if getattr(self.ctx_command, "hidden", False): 300 | return 301 | 302 | try: 303 | choices.extend( 304 | self._get_completion_for_cmd_args( 305 | self.ctx_command, incomplete, self.parsed_ctx, args 306 | ) 307 | ) 308 | 309 | if isinstance(self.ctx_command, click.MultiCommand): 310 | incomplete_lower = incomplete.lower() 311 | 312 | for name in self.ctx_command.list_commands(self.parsed_ctx): 313 | command = self.ctx_command.get_command(self.parsed_ctx, name) 314 | if getattr(command, "hidden", False): 315 | continue 316 | 317 | elif name.lower().startswith(incomplete_lower): 318 | choices.append( 319 | Completion( 320 | name, 321 | -len(incomplete), 322 | display_meta=getattr(command, "short_help", ""), 323 | ) 324 | ) 325 | 326 | except Exception as e: 327 | click.echo("{}: {}".format(type(e).__name__, str(e))) 328 | 329 | for item in choices: 330 | yield item 331 | --------------------------------------------------------------------------------