add
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
from ._completer import ClickCompleter as ClickCompleter # noqa: F401
|
||||
from .core import pass_context as pass_context # 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.4.1"
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,359 @@
|
||||
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 CompleteEvent, Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class ClickCompleter(Completer):
|
||||
__slots__ = ("cli", "ctx", "parsed_args", "parsed_ctx", "ctx_command")
|
||||
|
||||
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: list[str] = []
|
||||
self.parsed_ctx = ctx
|
||||
self.ctx_command = ctx.command
|
||||
self.show_only_unused = show_only_unused
|
||||
self.shortest_only = shortest_only
|
||||
|
||||
def _get_completion_from_autocompletion_functions(
|
||||
self,
|
||||
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)
|
||||
else:
|
||||
autocompletions = param.autocompletion( # type: ignore[attr-defined]
|
||||
autocomplete_ctx, args, incomplete
|
||||
)
|
||||
|
||||
for autocomplete in autocompletions:
|
||||
if isinstance(autocomplete, tuple):
|
||||
param_choices.append(
|
||||
Completion(
|
||||
str(autocomplete[0]),
|
||||
-len(incomplete),
|
||||
display_meta=autocomplete[1],
|
||||
)
|
||||
)
|
||||
|
||||
elif HAS_CLICK_V8 and isinstance(
|
||||
autocomplete, click.shell_completion.CompletionItem
|
||||
):
|
||||
param_choices.append(Completion(autocomplete.value, -len(incomplete)))
|
||||
|
||||
else:
|
||||
param_choices.append(Completion(str(autocomplete), -len(incomplete)))
|
||||
|
||||
return param_choices
|
||||
|
||||
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(
|
||||
choice,
|
||||
-len(incomplete),
|
||||
display=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(
|
||||
choice,
|
||||
-len(incomplete),
|
||||
display=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: click.Parameter, args: list[str], incomplete: str
|
||||
) -> list[Completion]:
|
||||
if "*" in incomplete:
|
||||
return []
|
||||
|
||||
choices: list[Completion] = []
|
||||
_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(
|
||||
path,
|
||||
-len(incomplete),
|
||||
display=os.path.basename(path.strip("'\"")),
|
||||
)
|
||||
)
|
||||
|
||||
return choices
|
||||
|
||||
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(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: click.Context,
|
||||
args: list[str],
|
||||
param: click.Parameter,
|
||||
incomplete: str,
|
||||
) -> list[Completion]:
|
||||
choices: list[Completion] = []
|
||||
param_type = param.type
|
||||
|
||||
# Flags (is_flag=True, incl. secondary opts like --foo/--no-foo) do not
|
||||
# consume a value, so we must not offer any value completions for them.
|
||||
# See https://github.com/click-contrib/click-repl/issues/116
|
||||
if getattr(param, "is_flag", False):
|
||||
return choices
|
||||
|
||||
# shell_complete method for click.Choice was introduced 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: 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:
|
||||
if isinstance(param.type, click.types.UnprocessedParamType):
|
||||
return []
|
||||
|
||||
elif getattr(param, "hidden", False):
|
||||
continue
|
||||
|
||||
elif isinstance(param, click.Option):
|
||||
opts = param.opts + param.secondary_opts
|
||||
previous_args = args[: param.nargs * -1]
|
||||
current_args = args[param.nargs * -1 :]
|
||||
|
||||
# Show only unused opts
|
||||
already_present = any([opt in previous_args for opt in opts])
|
||||
hide = self.show_only_unused and already_present and not param.multiple
|
||||
|
||||
# Show only shortest opt
|
||||
if (
|
||||
self.shortest_only
|
||||
and not incomplete # just typed a space
|
||||
# not selecting a value for a longer version of this option
|
||||
and (not args or args[-1] not in opts)
|
||||
):
|
||||
opts = [min(opts, key=len)]
|
||||
|
||||
for option in 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 current_args: # noqa: E203
|
||||
param_called = True
|
||||
break
|
||||
|
||||
elif option.startswith(incomplete) and not hide:
|
||||
choices.append(
|
||||
Completion(
|
||||
option,
|
||||
-len(incomplete),
|
||||
display_meta=param.help or "",
|
||||
)
|
||||
)
|
||||
|
||||
if param_called:
|
||||
choices = self._get_completion_from_params(
|
||||
autocomplete_ctx, args, param, incomplete
|
||||
)
|
||||
break
|
||||
|
||||
elif isinstance(param, click.Argument):
|
||||
# Show only unused arguments. A variadic argument (nargs=-1)
|
||||
# always accepts more values, so it is never hidden.
|
||||
already_present = False
|
||||
get_source = getattr(autocomplete_ctx, "get_parameter_source", None)
|
||||
source = None
|
||||
if get_source is not None:
|
||||
try:
|
||||
source = get_source(param.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if source is not None:
|
||||
already_present = getattr(source, "name", None) == "COMMANDLINE"
|
||||
else:
|
||||
already_present = autocomplete_ctx.params.get(param.name) not in (
|
||||
None,
|
||||
(),
|
||||
[],
|
||||
)
|
||||
|
||||
if self.show_only_unused and already_present and param.nargs != -1:
|
||||
continue
|
||||
|
||||
choices.extend(
|
||||
self._get_completion_from_params(
|
||||
autocomplete_ctx, args, param, incomplete
|
||||
)
|
||||
)
|
||||
|
||||
return choices
|
||||
|
||||
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: list[Completion] = []
|
||||
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
|
||||
try:
|
||||
self.parsed_ctx = _resolve_context(args, self.ctx)
|
||||
except Exception:
|
||||
return # autocompletion for nonexistent cmd can throw here
|
||||
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(
|
||||
name,
|
||||
-len(incomplete),
|
||||
display_meta=getattr(command, "short_help", ""),
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
click.echo("{}: {}".format(type(e).__name__, str(e)))
|
||||
|
||||
for item in choices:
|
||||
yield item
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core import ReplContext
|
||||
|
||||
|
||||
# To store the ReplContext objects generated throughout the Runtime.
|
||||
_context_stack: list[ReplContext] = []
|
||||
|
||||
|
||||
def _push_context(ctx: ReplContext) -> None:
|
||||
"""
|
||||
Pushes a new REPL context onto the current stack.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx
|
||||
The :class:`~click_repl.core.ReplContext` object that should be
|
||||
added to the REPL context stack.
|
||||
"""
|
||||
_context_stack.append(ctx)
|
||||
|
||||
|
||||
def _pop_context() -> None:
|
||||
"""Removes the top-level REPL context from the stack."""
|
||||
_context_stack.pop()
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 .globals_ import ISATTY, get_current_repl_ctx
|
||||
from .utils import (
|
||||
_execute_internal_and_sys_cmds,
|
||||
_get_protected_args,
|
||||
_set_protected_args,
|
||||
)
|
||||
|
||||
__all__ = ["bootstrap_prompt", "register_repl", "repl"]
|
||||
|
||||
|
||||
def bootstrap_prompt(
|
||||
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.MultiCommand object
|
||||
: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: 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.
|
||||
|
||||
: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.MultiCommand
|
||||
):
|
||||
group_ctx = old_ctx.parent
|
||||
|
||||
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
|
||||
# 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 # type: ignore[index]
|
||||
and not param.required
|
||||
):
|
||||
raise InvalidGroupFormat(
|
||||
f"{type(group).__name__} '{group.name}' requires value for "
|
||||
f"an optional argument '{param.name}' in REPL mode"
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
available_commands: MutableMapping[str, click.Command] = {}
|
||||
|
||||
if isinstance(group, click.CommandCollection):
|
||||
available_commands = {
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
bootstrap_prompt(group, prompt_kwargs, group_ctx),
|
||||
get_current_repl_ctx(silent=True),
|
||||
)
|
||||
|
||||
if ISATTY:
|
||||
# If stdin is a TTY, prompt the user for input using PromptSession.
|
||||
def get_command() -> str:
|
||||
return repl_ctx.session.prompt() # type: ignore
|
||||
|
||||
else:
|
||||
# If stdin is not a TTY, read input from stdin directly.
|
||||
def get_command() -> str:
|
||||
inp = sys.stdin.readline().strip()
|
||||
repl_ctx._history.append(inp)
|
||||
return inp
|
||||
|
||||
with repl_ctx:
|
||||
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 = _get_protected_args(group_ctx)
|
||||
try:
|
||||
_set_protected_args(group_ctx, args)
|
||||
group.invoke(group_ctx)
|
||||
finally:
|
||||
_set_protected_args(group_ctx, 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 # type: ignore[index]
|
||||
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Core functionalities for managing context of the click_repl app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, TypeVar
|
||||
|
||||
from click import Context
|
||||
from prompt_toolkit import PromptSession
|
||||
from typing_extensions import Concatenate, Final, ParamSpec, TypeAlias, TypedDict
|
||||
|
||||
from ._ctx_stack import _pop_context, _push_context
|
||||
from .globals_ import ISATTY, get_current_repl_ctx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit.formatted_text import AnyFormattedText
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
__all__ = ["ReplContext", "pass_context"]
|
||||
|
||||
|
||||
_PromptSession: TypeAlias = PromptSession[Dict[str, Any]]
|
||||
|
||||
|
||||
class ReplContextInfoDict(TypedDict):
|
||||
group_ctx: Context
|
||||
prompt_kwargs: dict[str, Any]
|
||||
session: _PromptSession | None
|
||||
parent: ReplContext | None
|
||||
_history: list[str]
|
||||
|
||||
|
||||
class ReplContext:
|
||||
"""
|
||||
Context object for the REPL sessions.
|
||||
|
||||
This class tracks the depth of nested REPLs, ensuring seamless navigation
|
||||
between different levels. It facilitates nested REPL scenarios, allowing
|
||||
multiple levels of interactive REPL sessions.
|
||||
|
||||
Each REPL's properties are stored inside this context class, allowing them to
|
||||
be accessed and shared with their parent REPL.
|
||||
|
||||
All the settings for each REPL session persist until the session is terminated.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group_ctx
|
||||
The click context object that belong to the CLI/parent Group.
|
||||
|
||||
prompt_kwargs
|
||||
Extra keyword arguments for
|
||||
:class:`~prompt_toolkit.shortcuts.PromptSession` class.
|
||||
|
||||
parent
|
||||
REPL Context object of the parent REPL session, if exists. Otherwise, :obj:`None`.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"group_ctx",
|
||||
"prompt_kwargs",
|
||||
"parent",
|
||||
"session",
|
||||
"_history",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group_ctx: Context,
|
||||
prompt_kwargs: dict[str, Any] = {},
|
||||
parent: ReplContext | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the `ReplContext` class.
|
||||
"""
|
||||
session: _PromptSession | None
|
||||
|
||||
if ISATTY:
|
||||
session = PromptSession(**prompt_kwargs)
|
||||
|
||||
else:
|
||||
session = None
|
||||
|
||||
self.group_ctx: Final[Context] = group_ctx
|
||||
"""The click context object that belong to the CLI/parent Group."""
|
||||
|
||||
self.session = session
|
||||
"""Object that's responsible for managing and executing the REPL."""
|
||||
|
||||
self._history: list[str] = []
|
||||
"""
|
||||
History of past executed commands.
|
||||
|
||||
Used only when :func:`~sys.stdin.isatty` is :obj:`False`.
|
||||
"""
|
||||
|
||||
self.prompt_kwargs = prompt_kwargs
|
||||
"""
|
||||
Extra keyword arguments for
|
||||
:class:`~prompt_toolkit.shortcuts.PromptSession` class.
|
||||
"""
|
||||
|
||||
self.parent: Final[ReplContext | None] = parent
|
||||
"""
|
||||
REPL Context object of the parent REPL session, if exists.
|
||||
Otherwise, :obj:`None`.
|
||||
"""
|
||||
|
||||
def __enter__(self) -> ReplContext:
|
||||
_push_context(self)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
_pop_context()
|
||||
|
||||
@property
|
||||
def prompt(self) -> AnyFormattedText:
|
||||
"""
|
||||
The prompt text of the REPL.
|
||||
|
||||
Returns
|
||||
-------
|
||||
prompt_toolkit.formatted_text.AnyFormattedText
|
||||
The prompt object if :func:`~sys.stdin.isatty` is :obj:`True`,
|
||||
else :obj:`None`.
|
||||
"""
|
||||
if ISATTY and self.session is not None:
|
||||
return self.session.message
|
||||
return None
|
||||
|
||||
@prompt.setter
|
||||
def prompt(self, value: AnyFormattedText) -> None:
|
||||
if ISATTY and self.session is not None:
|
||||
self.session.message = value
|
||||
|
||||
def to_info_dict(self) -> ReplContextInfoDict:
|
||||
"""
|
||||
Provides a dictionary with minimal info about the current REPL.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ReplContextInfoDict
|
||||
A dictionary that has the instance variables and their values.
|
||||
"""
|
||||
|
||||
res: ReplContextInfoDict = {
|
||||
"group_ctx": self.group_ctx,
|
||||
"prompt_kwargs": self.prompt_kwargs,
|
||||
"session": self.session,
|
||||
"parent": self.parent,
|
||||
"_history": self._history,
|
||||
}
|
||||
|
||||
return res
|
||||
|
||||
def session_reset(self) -> None:
|
||||
"""
|
||||
Resets values of :class:`~prompt_toolkit.session.PromptSession` to
|
||||
the provided :attr:`~.prompt_kwargs`, discarding any changes done to the
|
||||
:class:`~prompt_toolkit.session.PromptSession` object.
|
||||
"""
|
||||
|
||||
if ISATTY and self.session is not None:
|
||||
self.session = PromptSession(**self.prompt_kwargs)
|
||||
|
||||
def history(self) -> Generator[str, None, None]:
|
||||
"""
|
||||
Generates the history of past executed commands.
|
||||
|
||||
Yields
|
||||
------
|
||||
str
|
||||
The executed command string from the history,
|
||||
in chronological order from most recent to oldest.
|
||||
"""
|
||||
|
||||
if ISATTY and self.session is not None:
|
||||
yield from self.session.history.load_history_strings()
|
||||
|
||||
else:
|
||||
yield from reversed(self._history)
|
||||
|
||||
|
||||
def pass_context(
|
||||
func: Callable[Concatenate[ReplContext | None, P], R],
|
||||
) -> Callable[P, R]:
|
||||
"""
|
||||
Decorator that marks a callback function to receive the current
|
||||
REPL context object as its first argument.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func
|
||||
The callback function to pass context as its first parameter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable[P,R]
|
||||
The decorated callback function that receives the current REPL
|
||||
context object as its first argument.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def decorator(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return func(get_current_repl_ctx(), *args, **kwargs)
|
||||
|
||||
return decorator
|
||||
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, NoReturn, overload
|
||||
|
||||
from ._ctx_stack import _context_stack
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core import ReplContext
|
||||
|
||||
|
||||
ISATTY = sys.stdin is not None and 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.
|
||||
|
||||
This function provides a way to access the context from anywhere
|
||||
in the code. This function serves as a more implicit alternative to the
|
||||
:func:`~click.core.pass_context` decorator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
silent
|
||||
If set to :obj:`True`, the function returns :obj:`None` if no context
|
||||
is available. The default behavior is to raise a :exc:`~RuntimeError`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
:class:`~click_repl.core.ReplContext` | None
|
||||
REPL context object if available, or :obj:`None` if ``silent`` is :obj:`True`.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If there's no context object in the stack and ``silent`` is :obj:`False`.
|
||||
"""
|
||||
|
||||
try:
|
||||
return _context_stack[-1]
|
||||
except IndexError:
|
||||
if not silent:
|
||||
raise RuntimeError("There is no active click-repl context.")
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,238 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user