diff --git a/click_repl/_completer.py b/click_repl/_completer.py index 1f64fa0..73a769c 100644 --- a/click_repl/_completer.py +++ b/click_repl/_completer.py @@ -1,10 +1,13 @@ -from __future__ import unicode_literals +from __future__ import annotations import os +import typing as t from glob import iglob +from typing import Generator import click -from prompt_toolkit.completion import Completion, Completer +from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.document import Document from .utils import _resolve_context, split_arg_string @@ -26,17 +29,19 @@ AUTO_COMPLETION_PARAM = "autocompletion" -def text_type(text): - return "{}".format(text) - - class ClickCompleter(Completer): __slots__ = ("cli", "ctx", "parsed_args", "parsed_ctx", "ctx_command") - def __init__(self, cli, ctx, show_only_unused=False, shortest_only=False): + def __init__( + self, + cli: click.MultiCommand, + ctx: click.Context, + show_only_unused: bool = False, + shortest_only: bool = False, + ) -> None: self.cli = cli self.ctx = ctx - self.parsed_args = [] + self.parsed_args: list[str] = [] self.parsed_ctx = ctx self.ctx_command = ctx.command self.show_only_unused = show_only_unused @@ -44,12 +49,12 @@ def __init__(self, cli, ctx, show_only_unused=False, shortest_only=False): def _get_completion_from_autocompletion_functions( self, - param, - autocomplete_ctx, - args, - incomplete, - ): - param_choices = [] + param: click.Parameter, + autocomplete_ctx: click.Context, + args: list[str], + incomplete: str, + ) -> list[Completion]: + param_choices: list[Completion] = [] if HAS_CLICK_V8: autocompletions = param.shell_complete(autocomplete_ctx, incomplete) @@ -62,7 +67,7 @@ def _get_completion_from_autocompletion_functions( if isinstance(autocomplete, tuple): param_choices.append( Completion( - text_type(autocomplete[0]), + str(autocomplete[0]), -len(incomplete), display_meta=autocomplete[1], ) @@ -71,46 +76,48 @@ def _get_completion_from_autocompletion_functions( elif HAS_CLICK_V8 and isinstance( autocomplete, click.shell_completion.CompletionItem ): - param_choices.append( - Completion(text_type(autocomplete.value), -len(incomplete)) - ) + param_choices.append(Completion(autocomplete.value, -len(incomplete))) else: - param_choices.append( - Completion(text_type(autocomplete), -len(incomplete)) - ) + param_choices.append(Completion(str(autocomplete), -len(incomplete))) return param_choices - def _get_completion_from_choices_click_le_7(self, param, incomplete): + def _get_completion_from_choices_click_le_7( + self, param: click.Parameter, incomplete: str + ) -> list[Completion]: + param_type = t.cast(click.Choice, param.type) + if not getattr(param.type, "case_sensitive", True): incomplete = incomplete.lower() return [ Completion( - text_type(choice), + choice, -len(incomplete), - display=text_type(repr(choice) if " " in choice else choice), + display=repr(choice) if " " in choice else choice, ) - for choice in param.type.choices # type: ignore[attr-defined] + for choice in param_type.choices # type: ignore[attr-defined] if choice.lower().startswith(incomplete) ] else: return [ Completion( - text_type(choice), + choice, -len(incomplete), - display=text_type(repr(choice) if " " in choice else choice), + display=repr(choice) if " " in choice else choice, ) - for choice in param.type.choices # type: ignore[attr-defined] + for choice in param_type.choices # type: ignore[attr-defined] if choice.startswith(incomplete) ] - def _get_completion_for_Path_types(self, param, args, incomplete): + def _get_completion_for_Path_types( + self, param: click.Parameter, args: list[str], incomplete: str + ) -> list[Completion]: if "*" in incomplete: return [] - choices = [] + choices: list[Completion] = [] _incomplete = os.path.expandvars(incomplete) search_pattern = _incomplete.strip("'\"\t\n\r\v ").replace("\\\\", "\\") + "*" quote = "" @@ -134,29 +141,36 @@ def _get_completion_for_Path_types(self, param, args, incomplete): choices.append( Completion( - text_type(path), + path, -len(incomplete), - display=text_type(os.path.basename(path.strip("'\""))), + display=os.path.basename(path.strip("'\"")), ) ) return choices - def _get_completion_for_Boolean_type(self, param, incomplete): + def _get_completion_for_Boolean_type( + self, param: click.Parameter, incomplete: str + ) -> list[Completion]: + boolean_mapping: dict[str, tuple[str, ...]] = { + "true": ("1", "true", "t", "yes", "y", "on"), + "false": ("0", "false", "f", "no", "n", "off"), + } + return [ - Completion( - text_type(k), -len(incomplete), display_meta=text_type("/".join(v)) - ) - for k, v in { - "true": ("1", "true", "t", "yes", "y", "on"), - "false": ("0", "false", "f", "no", "n", "off"), - }.items() + Completion(k, -len(incomplete), display_meta="/".join(v)) + for k, v in boolean_mapping.items() if any(i.startswith(incomplete) for i in v) ] - def _get_completion_from_params(self, autocomplete_ctx, args, param, incomplete): - - choices = [] + def _get_completion_from_params( + self, + autocomplete_ctx: click.Context, + args: list[str], + param: click.Parameter, + incomplete: str, + ) -> list[Completion]: + choices: list[Completion] = [] param_type = param.type # shell_complete method for click.Choice is intorduced in click-v8 @@ -185,12 +199,12 @@ def _get_completion_from_params(self, autocomplete_ctx, args, param, incomplete) def _get_completion_for_cmd_args( self, - ctx_command, - incomplete, - autocomplete_ctx, - args, - ): - choices = [] + ctx_command: click.Command, + incomplete: str, + autocomplete_ctx: click.Context, + args: list[str], + ) -> list[Completion]: + choices: list[Completion] = [] param_called = False for param in ctx_command.params: @@ -229,9 +243,9 @@ def _get_completion_for_cmd_args( elif option.startswith(incomplete) and not hide: choices.append( Completion( - text_type(option), + option, -len(incomplete), - display_meta=text_type(param.help or ""), + display_meta=param.help or "", ) ) @@ -250,12 +264,14 @@ def _get_completion_for_cmd_args( return choices - def get_completions(self, document, complete_event=None): + def get_completions( + self, document: Document, complete_event: CompleteEvent | None = None + ) -> Generator[Completion, None, None]: # Code analogous to click._bashcomplete.do_complete args = split_arg_string(document.text_before_cursor, posix=False) - choices = [] + choices: list[Completion] = [] cursor_within_command = ( document.text_before_cursor.rstrip() == document.text_before_cursor ) @@ -277,7 +293,7 @@ def get_completions(self, document, complete_event=None): try: self.parsed_ctx = _resolve_context(args, self.ctx) except Exception: - return [] # autocompletion for nonexistent cmd can throw here + return # autocompletion for nonexistent cmd can throw here self.ctx_command = self.parsed_ctx.command if getattr(self.ctx_command, "hidden", False): @@ -301,7 +317,7 @@ def get_completions(self, document, complete_event=None): elif name.lower().startswith(incomplete_lower): choices.append( Completion( - text_type(name), + name, -len(incomplete), display_meta=getattr(command, "short_help", ""), ) @@ -310,10 +326,5 @@ def get_completions(self, document, complete_event=None): except Exception as e: click.echo("{}: {}".format(type(e).__name__, str(e))) - # If we are inside a parameter that was called, we want to show only - # relevant choices - # if param_called: - # choices = param_choices - for item in choices: yield item diff --git a/click_repl/_repl.py b/click_repl/_repl.py index 0445182..4199add 100644 --- a/click_repl/_repl.py +++ b/click_repl/_repl.py @@ -1,29 +1,30 @@ -from __future__ import with_statement +from __future__ import annotations -import click import sys +from typing import Any, MutableMapping, cast + +import click from prompt_toolkit.history import InMemoryHistory from ._completer import ClickCompleter +from .core import ReplContext from .exceptions import ClickExit # type: ignore[attr-defined] from .exceptions import CommandLineParserError, ExitReplException, InvalidGroupFormat -from .utils import _execute_internal_and_sys_cmds -from .core import ReplContext from .globals_ import ISATTY, get_current_repl_ctx - +from .utils import _execute_internal_and_sys_cmds __all__ = ["bootstrap_prompt", "register_repl", "repl"] def bootstrap_prompt( - group, - prompt_kwargs, - ctx=None, -): + group: click.MultiCommand, + prompt_kwargs: dict[str, Any], + ctx: click.Context, +) -> dict[str, Any]: """ Bootstrap prompt_toolkit kwargs or use user defined values. - :param group: click Group + :param group: click.MultiCommand object :param prompt_kwargs: The user specified prompt kwargs. """ @@ -38,8 +39,11 @@ def bootstrap_prompt( def repl( - old_ctx, prompt_kwargs={}, allow_system_commands=True, allow_internal_commands=True -): + old_ctx: click.Context, + prompt_kwargs: dict[str, Any] = {}, + allow_system_commands: bool = True, + allow_internal_commands: bool = True, +) -> None: """ Start an interactive shell. All subcommands are available in it. @@ -54,10 +58,12 @@ def repl( group_ctx = old_ctx # Switching to the parent context that has a Group as its command # as a Group acts as a CLI for all of its subcommands - if old_ctx.parent is not None and not isinstance(old_ctx.command, click.Group): + if old_ctx.parent is not None and not isinstance( + old_ctx.command, click.MultiCommand + ): group_ctx = old_ctx.parent - group = group_ctx.command + group = cast(click.MultiCommand, group_ctx.command) # An Optional click.Argument in the CLI Group, that has no value # will consume the first word from the REPL input, causing issues in @@ -66,7 +72,7 @@ def repl( for param in group.params: if ( isinstance(param, click.Argument) - and group_ctx.params[param.name] is None + and group_ctx.params[param.name] is None # type: ignore[index] and not param.required ): raise InvalidGroupFormat( @@ -78,16 +84,20 @@ def repl( # nesting REPLs (note: pass `None` to `pop` as we don't want to error if # REPL command already not present for some reason). repl_command_name = old_ctx.command.name - if isinstance(group_ctx.command, click.CommandCollection): + + available_commands: MutableMapping[str, click.Command] = {} + + if isinstance(group, click.CommandCollection): available_commands = { - cmd_name: cmd_obj - for source in group_ctx.command.sources - for cmd_name, cmd_obj in source.commands.items() + cmd_name: source.get_command(group_ctx, cmd_name) # type: ignore[misc] + for source in group.sources + for cmd_name in source.list_commands(group_ctx) } - else: - available_commands = group_ctx.command.commands - original_command = available_commands.pop(repl_command_name, None) + elif isinstance(group, click.Group): + available_commands = group.commands + + original_command = available_commands.pop(repl_command_name, None) # type: ignore repl_ctx = ReplContext( group_ctx, @@ -152,9 +162,9 @@ def get_command() -> str: break if original_command is not None: - available_commands[repl_command_name] = original_command + available_commands[repl_command_name] = original_command # type: ignore[index] -def register_repl(group, name="repl"): +def register_repl(group: click.Group, name="repl") -> None: """Register :func:`repl()` as sub-command *name* of *group*.""" group.command(name=name)(click.pass_context(repl)) diff --git a/click_repl/globals_.py b/click_repl/globals_.py index 6a73652..3e1f49b 100644 --- a/click_repl/globals_.py +++ b/click_repl/globals_.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, NoReturn +from typing import TYPE_CHECKING, NoReturn, overload from ._ctx_stack import _context_stack @@ -12,6 +12,16 @@ ISATTY = sys.stdin.isatty() +@overload +def get_current_repl_ctx() -> ReplContext | NoReturn: + ... + + +@overload +def get_current_repl_ctx(silent: bool = False) -> ReplContext | NoReturn | None: + ... + + def get_current_repl_ctx(silent: bool = False) -> ReplContext | NoReturn | None: """ Retrieves the current click-repl context. diff --git a/click_repl/py.typed b/click_repl/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/click_repl/utils.py b/click_repl/utils.py index 9aa9800..9f029f7 100644 --- a/click_repl/utils.py +++ b/click_repl/utils.py @@ -1,11 +1,19 @@ -import click +from __future__ import annotations + import os import shlex -import sys +import typing as t from collections import defaultdict +from typing import Callable, Generator, Iterator, NoReturn, Sequence + +import click +from typing_extensions import TypeAlias from .exceptions import CommandLineParserError, ExitReplException +T = t.TypeVar("T") +InternalCommandCallback: TypeAlias = Callable[[], None] + __all__ = [ "_execute_internal_and_sys_cmds", @@ -21,15 +29,7 @@ ] -# Abstract datatypes in collections module are moved to collections.abc -# module in Python 3.3 -if sys.version_info >= (3, 3): - from collections.abc import Iterable, Mapping # noqa: F811 -else: - from collections import Iterable, Mapping - - -def _resolve_context(args, ctx=None): +def _resolve_context(args: list[str], ctx: click.Context) -> click.Context: """Produce the context hierarchy starting with the command and traversing the complete arguments. This only follows the commands, it doesn't trigger input prompts or callbacks. @@ -75,10 +75,10 @@ def _resolve_context(args, ctx=None): return ctx -_internal_commands = {} +_internal_commands: dict[str, tuple[InternalCommandCallback, str | None]] = {} -def split_arg_string(string, posix=True): +def split_arg_string(string: str, posix: bool = True) -> list[str]: """Split an argument string as with :func:`shlex.split`, but don't fail if the string is incomplete. Ignores a missing closing quote or incomplete escape sequence and uses the partial token as-is. @@ -107,16 +107,20 @@ def split_arg_string(string, posix=True): return out -def _register_internal_command(names, target, description=None): +def _register_internal_command( + names: str | Sequence[str] | Generator[str, None, None] | Iterator[str], + target: InternalCommandCallback, + description: str | None = None, +) -> None: if not hasattr(target, "__call__"): raise ValueError("Internal command must be a callable") if isinstance(names, str): names = [names] - elif isinstance(names, Mapping) or not isinstance(names, Iterable): + elif not isinstance(names, (Sequence, Generator, Iterator)): raise ValueError( - '"names" must be a string, or an iterable object, but got "{}"'.format( + '"names" must be a string, or a Sequence of strings, but got "{}"'.format( type(names).__name__ ) ) @@ -125,18 +129,20 @@ def _register_internal_command(names, target, description=None): _internal_commands[name] = (target, description) -def _get_registered_target(name, default=None): - target_info = _internal_commands.get(name) +def _get_registered_target( + name: str, default: T | None = None +) -> InternalCommandCallback | T | None: + target_info = _internal_commands.get(name, None) if target_info: return target_info[0] return default -def _exit_internal(): +def _exit_internal() -> NoReturn: raise ExitReplException() -def _help_internal(): +def _help_internal() -> None: formatter = click.HelpFormatter() formatter.write_heading("REPL help") formatter.indent() @@ -159,8 +165,7 @@ def _help_internal(): for description, mnemonics in info_table.items() ) - val = formatter.getvalue() # type: str - return val + print(formatter.getvalue()) _register_internal_command(["q", "quit", "exit"], _exit_internal, "exits the repl") @@ -170,21 +175,19 @@ def _help_internal(): def _execute_internal_and_sys_cmds( - command, - allow_internal_commands=True, - allow_system_commands=True, -): + command: str, + allow_internal_commands: bool = True, + allow_system_commands: bool = True, +) -> list[str] | None: """ Executes internal, system, and all the other registered click commands from the input """ if allow_system_commands and dispatch_repl_commands(command): return None - if allow_internal_commands: - result = handle_internal_commands(command) - if isinstance(result, str): - click.echo(result) - return None + if allow_internal_commands and command.startswith(":"): + handle_internal_commands(command) + return None try: return split_arg_string(command) @@ -192,12 +195,12 @@ def _execute_internal_and_sys_cmds( raise CommandLineParserError("{}".format(e)) -def exit(): +def exit() -> NoReturn: """Exit the repl""" _exit_internal() -def dispatch_repl_commands(command): +def dispatch_repl_commands(command: str) -> bool: """ Execute system commands entered in the repl. @@ -210,13 +213,12 @@ def dispatch_repl_commands(command): return False -def handle_internal_commands(command): +def handle_internal_commands(command: str) -> None: """ Run repl-internal commands. Repl-internal commands are all commands starting with ":". """ - if command.startswith(":"): - target = _get_registered_target(command[1:], default=None) - if target: - return target() + target = _get_registered_target(command[1:], default=None) + if target: + target() diff --git a/setup.cfg b/setup.cfg index b8b3a2d..bd1522e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,8 @@ testing = pytest>=7.2.1 pytest-cov>=4.0.0 tox>=4.4.3 + flake8>=6.0.0 + mypy>=1.9.0 [flake8] ignore = E203, E266, W503, E402, E731, C901 diff --git a/tox.ini b/tox.ini index 92b95cc..4172362 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ isolated_build = true [gh-actions] python = 3.7: py37, click7, flake8 - 3.8: py38 + 3.8: py38, mypy 3.9: py39 3.10: py310 3.11: py311 @@ -33,6 +33,11 @@ basepython = python3.7 deps = flake8 commands = flake8 click_repl tests +[testenv:mypy] +basepython = python3.8 +deps = mypy +commands = mypy click_repl + [testenv:click7] basepython = python3.10 deps =