239 lines
6.7 KiB
Python
239 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import shlex
|
|
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",
|
|
"_exit_internal",
|
|
"_get_registered_target",
|
|
"_help_internal",
|
|
"_resolve_context",
|
|
"_register_internal_command",
|
|
"dispatch_repl_commands",
|
|
"handle_internal_commands",
|
|
"split_arg_string",
|
|
"exit",
|
|
]
|
|
|
|
|
|
def _get_protected_args(ctx: click.Context) -> list[str]:
|
|
# In click >= 8.2, protected_args is a read-only property over _protected_args.
|
|
if hasattr(ctx, "_protected_args"):
|
|
return t.cast(list[str], getattr(ctx, "_protected_args"))
|
|
return ctx.protected_args
|
|
|
|
|
|
def _set_protected_args(ctx: click.Context, args: list[str]) -> None:
|
|
if hasattr(ctx, "_protected_args"):
|
|
setattr(ctx, "_protected_args", args)
|
|
else:
|
|
ctx.protected_args = args
|
|
|
|
|
|
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.
|
|
|
|
: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 = _get_protected_args(ctx) + 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 = [*_get_protected_args(sub_ctx), *sub_ctx.args]
|
|
else:
|
|
break
|
|
|
|
return ctx
|
|
|
|
|
|
_internal_commands: dict[str, tuple[InternalCommandCallback, str | None]] = {}
|
|
|
|
|
|
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.
|
|
.. 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: 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 not isinstance(names, (Sequence, Generator, Iterator)):
|
|
raise ValueError(
|
|
'"names" must be a string, or a Sequence of strings, but got "{}"'.format(
|
|
type(names).__name__
|
|
)
|
|
)
|
|
|
|
for name in names:
|
|
_internal_commands[name] = (target, description)
|
|
|
|
|
|
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() -> NoReturn:
|
|
raise ExitReplException()
|
|
|
|
|
|
def _help_internal() -> None:
|
|
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()
|
|
)
|
|
|
|
print(formatter.getvalue())
|
|
|
|
|
|
_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: 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 and command.startswith(":"):
|
|
handle_internal_commands(command)
|
|
return None
|
|
|
|
try:
|
|
return split_arg_string(command)
|
|
except ValueError as e:
|
|
raise CommandLineParserError("{}".format(e))
|
|
|
|
|
|
def exit() -> NoReturn:
|
|
"""Exit the repl"""
|
|
_exit_internal()
|
|
|
|
|
|
def dispatch_repl_commands(command: str) -> bool:
|
|
"""
|
|
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: str) -> None:
|
|
"""
|
|
Run repl-internal commands.
|
|
|
|
Repl-internal commands are all commands starting with ":".
|
|
"""
|
|
target = _get_registered_target(command[1:], default=None)
|
|
if target:
|
|
target()
|