From 384cd0471cdd2b605d796e46e57b51080d52dd7d Mon Sep 17 00:00:00 2001 From: Ghost Ops <72981180+GhostOps77@users.noreply.github.com> Date: Sun, 23 Jun 2024 14:47:33 +0530 Subject: [PATCH 1/3] Changed help_internal function to return None and the way 'handle_internal_commands' checks whether its an internal command or not, to make every callback function for every internal command takes no arguments and return None --- click_repl/utils.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/click_repl/utils.py b/click_repl/utils.py index 9aa9800..6421224 100644 --- a/click_repl/utils.py +++ b/click_repl/utils.py @@ -159,8 +159,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") @@ -180,11 +179,9 @@ def _execute_internal_and_sys_cmds( 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) @@ -216,7 +213,6 @@ def handle_internal_commands(command): 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() From 6cd364530e355e80d1fb00d64db0686c29d0992e Mon Sep 17 00:00:00 2001 From: Ghost Ops <72981180+GhostOps77@users.noreply.github.com> Date: Sun, 23 Jun 2024 14:53:45 +0530 Subject: [PATCH 2/3] Added type hints, and made appropriate changes to all the files to satisfy mypy Using isinstance check with (Sequence, Generator, Iterator) rather than with (Mapping, Iterable) for aliases of an internal command: click_repl/utils.py --- click_repl/_completer.py | 131 +++++++++++++++++++++------------------ click_repl/_repl.py | 58 ++++++++++------- click_repl/globals_.py | 12 +++- click_repl/py.typed | 0 click_repl/utils.py | 60 ++++++++++-------- 5 files changed, 149 insertions(+), 112 deletions(-) create mode 100644 click_repl/py.typed 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 6421224..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() @@ -169,10 +175,10 @@ 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 """ @@ -189,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. @@ -207,7 +213,7 @@ def dispatch_repl_commands(command): return False -def handle_internal_commands(command): +def handle_internal_commands(command: str) -> None: """ Run repl-internal commands. From 8256868c1a5ef524285759f7276527cee6178ff6 Mon Sep 17 00:00:00 2001 From: Ghost Ops <72981180+GhostOps77@users.noreply.github.com> Date: Sun, 23 Jun 2024 14:56:35 +0530 Subject: [PATCH 3/3] Added test env for mypy in tox Updated testing dependencies to include flake8 and mypy: setup.cfg --- setup.cfg | 2 ++ tox.ini | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) 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 =