This commit is contained in:
Iliyan Angelov
2025-09-19 11:58:53 +03:00
parent 306b20e24a
commit 6b247e5b9f
11423 changed files with 1500615 additions and 778 deletions

View File

@@ -0,0 +1,11 @@
from ._completer import ClickCompleter as ClickCompleter # noqa: F401
from ._repl import register_repl as register_repl # noqa: F401
from ._repl import repl as repl # noqa: F401
from .exceptions import CommandLineParserError as CommandLineParserError # noqa: F401
from .exceptions import ExitReplException as ExitReplException # noqa: F401
from .exceptions import ( # noqa: F401
InternalCommandException as InternalCommandException,
)
from .utils import exit as exit # noqa: F401
__version__ = "0.3.0"

View File

@@ -0,0 +1,296 @@
from __future__ import unicode_literals
import os
from glob import iglob
import click
from prompt_toolkit.completion import Completion, Completer
from .utils import _resolve_context, split_arg_string
__all__ = ["ClickCompleter"]
IS_WINDOWS = os.name == "nt"
# Handle backwards compatibility between Click<=7.0 and >=8.0
try:
import click.shell_completion
HAS_CLICK_V8 = True
AUTO_COMPLETION_PARAM = "shell_complete"
except (ImportError, ModuleNotFoundError):
import click._bashcomplete # type: ignore[import]
HAS_CLICK_V8 = False
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):
self.cli = cli
self.ctx = ctx
self.parsed_args = []
self.parsed_ctx = ctx
self.ctx_command = ctx.command
def _get_completion_from_autocompletion_functions(
self,
param,
autocomplete_ctx,
args,
incomplete,
):
param_choices = []
if HAS_CLICK_V8:
autocompletions = param.shell_complete(autocomplete_ctx, incomplete)
else:
autocompletions = param.autocompletion( # type: ignore[attr-defined]
autocomplete_ctx, args, incomplete
)
for autocomplete in autocompletions:
if isinstance(autocomplete, tuple):
param_choices.append(
Completion(
text_type(autocomplete[0]),
-len(incomplete),
display_meta=autocomplete[1],
)
)
elif HAS_CLICK_V8 and isinstance(
autocomplete, click.shell_completion.CompletionItem
):
param_choices.append(
Completion(text_type(autocomplete.value), -len(incomplete))
)
else:
param_choices.append(
Completion(text_type(autocomplete), -len(incomplete))
)
return param_choices
def _get_completion_from_choices_click_le_7(self, param, incomplete):
if not getattr(param.type, "case_sensitive", True):
incomplete = incomplete.lower()
return [
Completion(
text_type(choice),
-len(incomplete),
display=text_type(repr(choice) if " " in choice else choice),
)
for choice in param.type.choices # type: ignore[attr-defined]
if choice.lower().startswith(incomplete)
]
else:
return [
Completion(
text_type(choice),
-len(incomplete),
display=text_type(repr(choice) if " " in choice else choice),
)
for choice in param.type.choices # type: ignore[attr-defined]
if choice.startswith(incomplete)
]
def _get_completion_for_Path_types(self, param, args, incomplete):
if "*" in incomplete:
return []
choices = []
_incomplete = os.path.expandvars(incomplete)
search_pattern = _incomplete.strip("'\"\t\n\r\v ").replace("\\\\", "\\") + "*"
quote = ""
if " " in _incomplete:
for i in incomplete:
if i in ("'", '"'):
quote = i
break
for path in iglob(search_pattern):
if " " in path:
if quote:
path = quote + path
else:
if IS_WINDOWS:
path = repr(path).replace("\\\\", "\\")
else:
if IS_WINDOWS:
path = path.replace("\\", "\\\\")
choices.append(
Completion(
text_type(path),
-len(incomplete),
display=text_type(os.path.basename(path.strip("'\""))),
)
)
return choices
def _get_completion_for_Boolean_type(self, param, incomplete):
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()
if any(i.startswith(incomplete) for i in v)
]
def _get_completion_from_params(self, autocomplete_ctx, args, param, incomplete):
choices = []
param_type = param.type
# shell_complete method for click.Choice is intorduced in click-v8
if not HAS_CLICK_V8 and isinstance(param_type, click.Choice):
choices.extend(
self._get_completion_from_choices_click_le_7(param, incomplete)
)
elif isinstance(param_type, click.types.BoolParamType):
choices.extend(self._get_completion_for_Boolean_type(param, incomplete))
elif isinstance(param_type, (click.Path, click.File)):
choices.extend(self._get_completion_for_Path_types(param, args, incomplete))
elif getattr(param, AUTO_COMPLETION_PARAM, None) is not None:
choices.extend(
self._get_completion_from_autocompletion_functions(
param,
autocomplete_ctx,
args,
incomplete,
)
)
return choices
def _get_completion_for_cmd_args(
self,
ctx_command,
incomplete,
autocomplete_ctx,
args,
):
choices = []
param_called = False
for param in ctx_command.params:
if isinstance(param.type, click.types.UnprocessedParamType):
return []
elif getattr(param, "hidden", False):
continue
elif isinstance(param, click.Option):
for option in param.opts + param.secondary_opts:
# We want to make sure if this parameter was called
# If we are inside a parameter that was called, we want to show only
# relevant choices
if option in args[param.nargs * -1 :]: # noqa: E203
param_called = True
break
elif option.startswith(incomplete):
choices.append(
Completion(
text_type(option),
-len(incomplete),
display_meta=text_type(param.help or ""),
)
)
if param_called:
choices = self._get_completion_from_params(
autocomplete_ctx, args, param, incomplete
)
elif isinstance(param, click.Argument):
choices.extend(
self._get_completion_from_params(
autocomplete_ctx, args, param, incomplete
)
)
return choices
def get_completions(self, document, complete_event=None):
# Code analogous to click._bashcomplete.do_complete
args = split_arg_string(document.text_before_cursor, posix=False)
choices = []
cursor_within_command = (
document.text_before_cursor.rstrip() == document.text_before_cursor
)
if document.text_before_cursor.startswith(("!", ":")):
return
if args and cursor_within_command:
# We've entered some text and no space, give completions for the
# current word.
incomplete = args.pop()
else:
# We've not entered anything, either at all or for the current
# command, so give all relevant completions for this context.
incomplete = ""
if self.parsed_args != args:
self.parsed_args = args
self.parsed_ctx = _resolve_context(args, self.ctx)
self.ctx_command = self.parsed_ctx.command
if getattr(self.ctx_command, "hidden", False):
return
try:
choices.extend(
self._get_completion_for_cmd_args(
self.ctx_command, incomplete, self.parsed_ctx, args
)
)
if isinstance(self.ctx_command, click.MultiCommand):
incomplete_lower = incomplete.lower()
for name in self.ctx_command.list_commands(self.parsed_ctx):
command = self.ctx_command.get_command(self.parsed_ctx, name)
if getattr(command, "hidden", False):
continue
elif name.lower().startswith(incomplete_lower):
choices.append(
Completion(
text_type(name),
-len(incomplete),
display_meta=getattr(command, "short_help", ""),
)
)
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

View File

@@ -0,0 +1,152 @@
from __future__ import with_statement
import click
import sys
from prompt_toolkit import PromptSession
from prompt_toolkit.history import InMemoryHistory
from ._completer import ClickCompleter
from .exceptions import ClickExit # type: ignore[attr-defined]
from .exceptions import CommandLineParserError, ExitReplException, InvalidGroupFormat
from .utils import _execute_internal_and_sys_cmds
__all__ = ["bootstrap_prompt", "register_repl", "repl"]
def bootstrap_prompt(
group,
prompt_kwargs,
ctx=None,
):
"""
Bootstrap prompt_toolkit kwargs or use user defined values.
:param group: click Group
:param prompt_kwargs: The user specified prompt kwargs.
"""
defaults = {
"history": InMemoryHistory(),
"completer": ClickCompleter(group, ctx=ctx),
"message": "> ",
}
defaults.update(prompt_kwargs)
return defaults
def repl(
old_ctx, prompt_kwargs={}, allow_system_commands=True, allow_internal_commands=True
):
"""
Start an interactive shell. All subcommands are available in it.
:param old_ctx: The current Click context.
:param prompt_kwargs: Parameters passed to
:py:func:`prompt_toolkit.PromptSession`.
If stdin is not a TTY, no prompt will be printed, but only commands read
from stdin.
"""
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):
group_ctx = old_ctx.parent
group = 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
# executing the command
# So, if there's an empty Optional Argument
for param in group.params:
if (
isinstance(param, click.Argument)
and group_ctx.params[param.name] is None
and not param.required
):
raise InvalidGroupFormat(
f"{type(group).__name__} '{group.name}' requires value for "
f"an optional argument '{param.name}' in REPL mode"
)
isatty = sys.stdin.isatty()
# Delete the REPL command from those available, as we don't want to allow
# 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 = {
cmd_name: cmd_obj
for source in group_ctx.command.sources
for cmd_name, cmd_obj in source.commands.items()
}
else:
available_commands = group_ctx.command.commands
original_command = available_commands.pop(repl_command_name, None)
if isatty:
prompt_kwargs = bootstrap_prompt(group, prompt_kwargs, group_ctx)
session = PromptSession(**prompt_kwargs)
def get_command():
return session.prompt()
else:
get_command = sys.stdin.readline
while True:
try:
command = get_command()
except KeyboardInterrupt:
continue
except EOFError:
break
if not command:
if isatty:
continue
else:
break
try:
args = _execute_internal_and_sys_cmds(
command, allow_internal_commands, allow_system_commands
)
if args is None:
continue
except CommandLineParserError:
continue
except ExitReplException:
break
try:
# The group command will dispatch based on args.
old_protected_args = group_ctx.protected_args
try:
group_ctx.protected_args = args
group.invoke(group_ctx)
finally:
group_ctx.protected_args = old_protected_args
except click.ClickException as e:
e.show()
except (ClickExit, SystemExit):
pass
except ExitReplException:
break
if original_command is not None:
available_commands[repl_command_name] = original_command
def register_repl(group, name="repl"):
"""Register :func:`repl()` as sub-command *name* of *group*."""
group.command(name=name)(click.pass_context(repl))

View File

@@ -0,0 +1,23 @@
class InternalCommandException(Exception):
pass
class ExitReplException(InternalCommandException):
pass
class CommandLineParserError(Exception):
pass
class InvalidGroupFormat(Exception):
pass
# Handle click.exceptions.Exit introduced in Click 7.0
try:
from click.exceptions import Exit as ClickExit
except (ImportError, ModuleNotFoundError):
class ClickExit(RuntimeError): # type: ignore[no-redef]
pass

View File

@@ -0,0 +1,222 @@
import click
import os
import shlex
import sys
from collections import defaultdict
from .exceptions import CommandLineParserError, ExitReplException
__all__ = [
"_execute_internal_and_sys_cmds",
"_exit_internal",
"_get_registered_target",
"_help_internal",
"_resolve_context",
"_register_internal_command",
"dispatch_repl_commands",
"handle_internal_commands",
"split_arg_string",
"exit",
]
# 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):
"""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.
:param args: List of complete args before the incomplete value.
:param cli_ctx: `click.Context` object of the CLI group
"""
while args:
command = ctx.command
if isinstance(command, click.MultiCommand):
if not command.chain:
name, cmd, args = command.resolve_command(ctx, args)
if cmd is None:
return ctx
ctx = cmd.make_context(name, args, parent=ctx, resilient_parsing=True)
args = ctx.protected_args + ctx.args
else:
while args:
name, cmd, args = command.resolve_command(ctx, args)
if cmd is None:
return ctx
sub_ctx = cmd.make_context(
name,
args,
parent=ctx,
allow_extra_args=True,
allow_interspersed_args=False,
resilient_parsing=True,
)
args = sub_ctx.args
ctx = sub_ctx
args = [*sub_ctx.protected_args, *sub_ctx.args]
else:
break
return ctx
_internal_commands = {}
def split_arg_string(string, posix=True):
"""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.
.. code-block:: python
split_arg_string("example 'my file")
["example", "my file"]
split_arg_string("example my\\")
["example", "my"]
:param string: String to split.
"""
lex = shlex.shlex(string, posix=posix)
lex.whitespace_split = True
lex.commenters = ""
out = []
try:
for token in lex:
out.append(token)
except ValueError:
# Raised when end-of-string is reached in an invalid state. Use
# the partial token as-is. The quote or escape character is in
# lex.state, not lex.token.
out.append(lex.token)
return out
def _register_internal_command(names, target, description=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):
raise ValueError(
'"names" must be a string, or an iterable object, but got "{}"'.format(
type(names).__name__
)
)
for name in names:
_internal_commands[name] = (target, description)
def _get_registered_target(name, default=None):
target_info = _internal_commands.get(name)
if target_info:
return target_info[0]
return default
def _exit_internal():
raise ExitReplException()
def _help_internal():
formatter = click.HelpFormatter()
formatter.write_heading("REPL help")
formatter.indent()
with formatter.section("External Commands"):
formatter.write_text('prefix external commands with "!"')
with formatter.section("Internal Commands"):
formatter.write_text('prefix internal commands with ":"')
info_table = defaultdict(list)
for mnemonic, target_info in _internal_commands.items():
info_table[target_info[1]].append(mnemonic)
formatter.write_dl( # type: ignore[arg-type]
( # type: ignore[arg-type]
", ".join(map(":{}".format, sorted(mnemonics))),
description,
)
for description, mnemonics in info_table.items()
)
val = formatter.getvalue() # type: str
return val
_register_internal_command(["q", "quit", "exit"], _exit_internal, "exits the repl")
_register_internal_command(
["?", "h", "help"], _help_internal, "displays general help information"
)
def _execute_internal_and_sys_cmds(
command,
allow_internal_commands=True,
allow_system_commands=True,
):
"""
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
try:
return split_arg_string(command)
except ValueError as e:
raise CommandLineParserError("{}".format(e))
def exit():
"""Exit the repl"""
_exit_internal()
def dispatch_repl_commands(command):
"""
Execute system commands entered in the repl.
System commands are all commands starting with "!".
"""
if command.startswith("!"):
os.system(command[1:])
return True
return False
def handle_internal_commands(command):
"""
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()