mirror of
https://github.com/xonsh/xonsh.git
synced 2025-03-04 08:24:40 +01:00
Argparser/color+completion (#4391)
* feat: add colored help formatter and alias builder closes #4284 * feat: add auto-completion support to argparser * test: update test for completer * fix: getting doc from params that have annotation * refactor: use filter-function for checking alias completions * doc: add discussion abount check for alias having parser see discussion on https://github.com/xonsh/xonsh/pull/4267 * type fix * refactor: use function based completer * test: fix failing argparser test * docs: update news item * update completion for argparser sub-commands to append_space from comment on https://github.com/xonsh/xonsh/pull/4267#discussion_r676044508 * docs: update docstring typo * refactor: move inspect import to top * feat: support option strings before positionals and add env setting for showing completions for options by default * test: update tests after adding new $ALIAS_COMPLETIONS_OPTIONS_BY_DEFAULT * add suggested completion_context_parse fixture * docs: add suggested doc for dispatch function * refactor: use try/except for import of typing.annotated * refactor: move complete_argparser_aliases to completers/aliases.py * refactor: move argparser completer to its own module * style: * refactor: rename completer to not clash with argparse * fix: expand option's descriptions * fix: add completer/argparser to amalgam
This commit is contained in:
parent
abc98c1bb3
commit
54f5ae7bb2
11 changed files with 690 additions and 137 deletions
24
news/alias-parser-utility.rst
Normal file
24
news/alias-parser-utility.rst
Normal file
|
@ -0,0 +1,24 @@
|
|||
**Added:**
|
||||
|
||||
* added new utility classes ``xonsh.cli_utils.ArgParserAlias``, ``xonsh.cli_utils.ArgCompleter``.
|
||||
These are helper classes, that add coloring and auto-completion support to the alias-commands.
|
||||
|
||||
**Changed:**
|
||||
|
||||
* <news item>
|
||||
|
||||
**Deprecated:**
|
||||
|
||||
* <news item>
|
||||
|
||||
**Removed:**
|
||||
|
||||
* <news item>
|
||||
|
||||
**Fixed:**
|
||||
|
||||
* <news item>
|
||||
|
||||
**Security:**
|
||||
|
||||
* <news item>
|
|
@ -1,34 +1,56 @@
|
|||
from xonsh.parsers.completion_context import (
|
||||
CommandArg,
|
||||
CommandContext,
|
||||
CompletionContext,
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xsh_with_aliases(xession, monkeypatch):
|
||||
from xonsh.aliases import Aliases, make_default_aliases
|
||||
|
||||
xsh = xession
|
||||
monkeypatch.setattr(xsh, "aliases", Aliases(make_default_aliases()))
|
||||
return xsh
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_completer(monkeypatch, xsh_with_aliases):
|
||||
xsh = xsh_with_aliases
|
||||
monkeypatch.setattr(xsh, "completers", {"one": 1, "two": 2})
|
||||
monkeypatch.setattr(xsh, "ctx", {"three": lambda: 1, "four": lambda: 2})
|
||||
return xsh
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args, positionals, options",
|
||||
[
|
||||
("completer", {"add", "remove", "rm", "list", "ls"}, {"--help", "-h"}),
|
||||
(
|
||||
"completer add",
|
||||
set(),
|
||||
{"--help", "-h"},
|
||||
),
|
||||
(
|
||||
"completer add newcompleter",
|
||||
{"three", "four"},
|
||||
{"--help", "-h"},
|
||||
),
|
||||
(
|
||||
"completer add newcompleter three",
|
||||
{"<one", ">two", ">one", "<two", "end", "start"},
|
||||
{"--help", "-h"},
|
||||
),
|
||||
(
|
||||
"completer remove",
|
||||
{"one", "two"},
|
||||
{"--help", "-h"},
|
||||
),
|
||||
(
|
||||
"completer list",
|
||||
set(),
|
||||
{"--help", "-h"},
|
||||
),
|
||||
],
|
||||
)
|
||||
from xonsh.completers.completer import complete_completer
|
||||
def test_completer_command(args, positionals, options, mock_completer, check_completer):
|
||||
assert check_completer(args) == positionals
|
||||
|
||||
|
||||
def test_options():
|
||||
assert (
|
||||
complete_completer(
|
||||
CompletionContext(
|
||||
CommandContext(
|
||||
args=(CommandArg("completer"),),
|
||||
arg_index=1,
|
||||
)
|
||||
)
|
||||
)
|
||||
== {"add", "remove", "list", "help"}
|
||||
)
|
||||
|
||||
|
||||
def test_help_options():
|
||||
assert (
|
||||
complete_completer(
|
||||
CompletionContext(
|
||||
CommandContext(
|
||||
args=(CommandArg("completer"), CommandArg("help")),
|
||||
arg_index=2,
|
||||
)
|
||||
)
|
||||
)
|
||||
== {"add", "remove", "list"}
|
||||
)
|
||||
mock_completer.env["ALIAS_COMPLETIONS_OPTIONS_BY_DEFAULT"] = True
|
||||
assert check_completer(args) == positionals.union(options)
|
||||
|
|
|
@ -9,11 +9,17 @@ import pytest
|
|||
|
||||
from xonsh.aliases import Aliases
|
||||
from xonsh.built_ins import XonshSession, XSH
|
||||
from xonsh.completers._aliases import complete_argparser_aliases
|
||||
from xonsh.execer import Execer
|
||||
from xonsh.jobs import tasks
|
||||
from xonsh.events import events
|
||||
from xonsh.platform import ON_WINDOWS
|
||||
from xonsh.parsers.completion_context import CompletionContextParser
|
||||
from xonsh.parsers.completion_context import (
|
||||
CommandArg,
|
||||
CommandContext,
|
||||
CompletionContext,
|
||||
)
|
||||
|
||||
from xonsh import commands_cache
|
||||
from tools import DummyShell, sp, DummyEnv, DummyHistory
|
||||
|
@ -140,6 +146,19 @@ def completion_context_parse():
|
|||
return CompletionContextParser().parse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def check_completer(xession, completion_context_parse):
|
||||
def _factory(args, **kwargs):
|
||||
cmds = tuple(CommandArg(i) for i in args.split(" "))
|
||||
arg_index = len(cmds)
|
||||
completions = complete_argparser_aliases(
|
||||
CompletionContext(CommandContext(args=cmds, arg_index=arg_index, **kwargs))
|
||||
)
|
||||
return {getattr(i, "value", i) for i in completions}
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ptk_shell(xonsh_execer):
|
||||
from prompt_toolkit.input import create_pipe_input
|
||||
|
|
|
@ -31,5 +31,22 @@ def test_get_doc_param():
|
|||
]
|
||||
assert cli_utils.get_doc(func_with_doc, "multi").splitlines() == [
|
||||
"param doc",
|
||||
" multi line",
|
||||
"multi line",
|
||||
]
|
||||
|
||||
|
||||
def test_generated_parser():
|
||||
from xonsh.completers._aliases import CompleterAlias
|
||||
|
||||
alias = CompleterAlias()
|
||||
|
||||
assert alias.parser.description
|
||||
|
||||
positionals = alias.parser._get_positional_actions()
|
||||
add_cmd = positionals[0].choices["add"]
|
||||
assert "Add a new completer" in add_cmd.description
|
||||
assert (
|
||||
alias.parser.format_usage()
|
||||
== "usage: completer [-h] {add,remove,rm,list,ls} ...\n"
|
||||
)
|
||||
assert add_cmd.format_usage() == "usage: completer add [-h] name func [pos]\n"
|
||||
|
|
|
@ -1,11 +1,61 @@
|
|||
"""
|
||||
small functions to create argparser CLI from functions.
|
||||
helper functions and classes to create argparse CLI from functions.
|
||||
|
||||
Examples
|
||||
please see :py:class:`xonsh.completers.completer.CompleterAlias` class
|
||||
"""
|
||||
|
||||
import argparse as ap
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import typing as tp
|
||||
|
||||
TYPING_ANNOTATED_AVAILABLE = False
|
||||
"""One can import ``Annotated`` from this module
|
||||
which adds a stub when it is not available in ``typing``/``typing_extensions`` modules."""
|
||||
|
||||
try:
|
||||
from typing import Annotated # noqa
|
||||
|
||||
TYPING_ANNOTATED_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
from typing_extensions import Annotated # type: ignore
|
||||
|
||||
TYPING_ANNOTATED_AVAILABLE = True
|
||||
except ImportError:
|
||||
T = tp.TypeVar("T") # Declare type variable
|
||||
|
||||
class _AnnotatedMeta(type):
|
||||
def __getitem__(self, item: tp.Tuple[T, tp.Any]) -> T:
|
||||
if tp.TYPE_CHECKING:
|
||||
return item[0]
|
||||
|
||||
return item[1]
|
||||
|
||||
class Annotated(metaclass=_AnnotatedMeta): # type: ignore
|
||||
pass
|
||||
|
||||
|
||||
class ArgCompleter:
|
||||
"""Gives a structure to the argparse completers"""
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
"""return dynamic completers for the given action."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def Arg(
|
||||
*args: str,
|
||||
completer: tp.Union[ArgCompleter, tp.Callable[..., tp.Iterator[str]]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
# converting to tuple because of limitation with hashing args in py3.6
|
||||
# after dropping py36 support, the dict can be returned
|
||||
kwargs["completer"] = completer
|
||||
return args, tuple(kwargs.items())
|
||||
|
||||
|
||||
def _get_func_doc(doc: str) -> str:
|
||||
lines = doc.splitlines()
|
||||
|
@ -15,34 +65,33 @@ def _get_func_doc(doc: str) -> str:
|
|||
return os.linesep.join(lines)
|
||||
|
||||
|
||||
def _from_index_of(container: tp.Sequence[str], key: str):
|
||||
if key in container:
|
||||
idx = container.index(key)
|
||||
if idx + 1 < len(container):
|
||||
return container[idx + 1 :]
|
||||
return []
|
||||
def _get_param_doc(doc: str, param: str) -> tp.Iterator[str]:
|
||||
section_title = "\nParameters"
|
||||
if section_title not in doc:
|
||||
return
|
||||
_, doc = doc.split(section_title)
|
||||
started = False
|
||||
for lin in doc.splitlines():
|
||||
if not lin:
|
||||
continue
|
||||
if lin.startswith(param):
|
||||
started = True
|
||||
continue
|
||||
if not started:
|
||||
continue
|
||||
|
||||
|
||||
def _get_param_doc(doc: str, param: str) -> str:
|
||||
lines = tuple(doc.splitlines())
|
||||
if "Parameters" not in lines:
|
||||
return ""
|
||||
|
||||
par_doc = []
|
||||
for lin in _from_index_of(lines, param):
|
||||
if lin and not lin.startswith(" "):
|
||||
if not lin.startswith(" "): # new section/parameter
|
||||
break
|
||||
par_doc.append(lin)
|
||||
return os.linesep.join(par_doc).strip()
|
||||
yield lin
|
||||
|
||||
|
||||
def get_doc(func: tp.Callable, parameter: str = None):
|
||||
def get_doc(func: tp.Union[tp.Callable, str], parameter: str = None):
|
||||
"""Parse the function docstring and return its help content
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func
|
||||
a callable object that holds docstring
|
||||
a callable/object that holds docstring
|
||||
parameter
|
||||
name of the function parameter to parse doc for
|
||||
|
||||
|
@ -51,58 +100,294 @@ def get_doc(func: tp.Callable, parameter: str = None):
|
|||
str
|
||||
doc of the parameter/function
|
||||
"""
|
||||
import inspect
|
||||
if isinstance(func, str):
|
||||
return func
|
||||
|
||||
doc = inspect.getdoc(func) or ""
|
||||
if parameter:
|
||||
return _get_param_doc(doc, parameter)
|
||||
par_doc = os.linesep.join(_get_param_doc(doc, parameter))
|
||||
return inspect.cleandoc(par_doc).strip()
|
||||
else:
|
||||
return _get_func_doc(doc)
|
||||
return _get_func_doc(doc).strip()
|
||||
|
||||
|
||||
_FUNC_NAME = "_func_"
|
||||
|
||||
|
||||
def _get_args_kwargs(annot: tp.Any) -> tp.Tuple[tp.Sequence[str], tp.Dict[str, tp.Any]]:
|
||||
args, kwargs = [], {}
|
||||
if isinstance(annot, tuple):
|
||||
args, kwargs = annot
|
||||
elif TYPING_ANNOTATED_AVAILABLE and "Annotated[" in str(annot):
|
||||
if hasattr(annot, "__metadata__"):
|
||||
args, kwargs = annot.__metadata__[0]
|
||||
else:
|
||||
from typing_extensions import get_args
|
||||
|
||||
_, (args, kwargs) = get_args(annot)
|
||||
|
||||
if isinstance(kwargs, tuple):
|
||||
kwargs = dict(kwargs)
|
||||
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def add_args(parser: ap.ArgumentParser, func: tp.Callable, allowed_params=None) -> None:
|
||||
"""Using the function's annotation add arguments to the parser
|
||||
param:Arg(*args, **kw) -> parser.add_argument(*args, *kw)
|
||||
"""
|
||||
|
||||
# call this function when this sub-command is selected
|
||||
parser.set_defaults(**{_FUNC_NAME: func})
|
||||
|
||||
sign = inspect.signature(func)
|
||||
for name, param in sign.parameters.items():
|
||||
if name.startswith("_") or (
|
||||
allowed_params is not None and name not in allowed_params
|
||||
):
|
||||
continue
|
||||
args, kwargs = _get_args_kwargs(param.annotation)
|
||||
|
||||
if args: # optional argument. eg. --option
|
||||
kwargs.setdefault("dest", name)
|
||||
else: # positional argument
|
||||
args = [name]
|
||||
|
||||
if inspect.Parameter.empty != param.default:
|
||||
kwargs.setdefault("default", param.default)
|
||||
|
||||
# help can be set by passing help argument otherwise inferred from docstring
|
||||
kwargs.setdefault("help", get_doc(func, name))
|
||||
|
||||
completer = kwargs.pop("completer", None)
|
||||
action = parser.add_argument(*args, **kwargs)
|
||||
if completer:
|
||||
action.completer = completer # type: ignore
|
||||
action.help = action.help or ""
|
||||
if action.default and "%(default)s" not in action.help:
|
||||
action.help += os.linesep + " (default: %(default)s)"
|
||||
if action.type and "%(type)s" not in action.help:
|
||||
action.help += " (type: %(type)s)"
|
||||
|
||||
|
||||
def make_parser(
|
||||
func: tp.Callable,
|
||||
subparser: ap._SubParsersAction = None,
|
||||
params: tp.Dict[str, tp.Dict[str, tp.Any]] = None,
|
||||
**kwargs
|
||||
) -> "ap.ArgumentParser":
|
||||
func: tp.Union[tp.Callable, str],
|
||||
empty_help=True,
|
||||
**kwargs,
|
||||
) -> "ArgParser":
|
||||
"""A bare-bones argparse builder from functions"""
|
||||
|
||||
doc = get_doc(func)
|
||||
kwargs.setdefault("formatter_class", ap.RawTextHelpFormatter)
|
||||
if subparser is None:
|
||||
kwargs.setdefault("description", doc)
|
||||
parser = ap.ArgumentParser(**kwargs)
|
||||
if "description" not in kwargs:
|
||||
kwargs["description"] = get_doc(func)
|
||||
parser = ArgParser(**kwargs)
|
||||
if empty_help:
|
||||
parser.set_defaults(
|
||||
**{_FUNC_NAME: lambda stdout: parser.print_help(file=stdout)}
|
||||
**{_FUNC_NAME: lambda stdout=None: parser.print_help(file=stdout)}
|
||||
)
|
||||
return parser
|
||||
else:
|
||||
parser = subparser.add_parser(
|
||||
kwargs.pop("prog", func.__name__),
|
||||
help=doc,
|
||||
**kwargs,
|
||||
)
|
||||
parser.set_defaults(**{_FUNC_NAME: func})
|
||||
return parser
|
||||
|
||||
if params:
|
||||
for par, args in params.items():
|
||||
args.setdefault("help", get_doc(func, par))
|
||||
parser.add_argument(par, **args)
|
||||
|
||||
class RstHelpFormatter(ap.RawTextHelpFormatter):
|
||||
"""Highlight help string as rst"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from pygments.formatters.terminal import TerminalFormatter
|
||||
|
||||
self.formatter = TerminalFormatter()
|
||||
|
||||
def start_section(self, heading) -> None:
|
||||
from pygments.token import Generic
|
||||
|
||||
heading = self.colorize((Generic.Heading, heading))
|
||||
return super().start_section(heading)
|
||||
|
||||
def _get_help_string(self, action) -> str:
|
||||
return self.markup_rst(action.help)
|
||||
|
||||
def colorize(self, *tokens: tuple) -> str:
|
||||
from pygments import format
|
||||
|
||||
return format(tokens, self.formatter)
|
||||
|
||||
def markup_rst(self, text):
|
||||
from pygments import highlight
|
||||
from pygments.lexers.markup import RstLexer
|
||||
|
||||
return highlight(text, RstLexer(), self.formatter)
|
||||
|
||||
def _format_text(self, text):
|
||||
text = super()._format_text(text)
|
||||
if text:
|
||||
text = self.markup_rst(text)
|
||||
return text
|
||||
|
||||
def _format_usage(self, usage, actions, groups, prefix):
|
||||
from pygments.token import Name, Generic
|
||||
|
||||
text = super()._format_usage(usage, actions, groups, prefix)
|
||||
parts = text.split(self._prog, maxsplit=1)
|
||||
if len(parts) == 2 and all(parts):
|
||||
text = self.colorize(
|
||||
(Generic.Heading, parts[0]),
|
||||
(Name.Function, self._prog),
|
||||
(Name.Attribute, parts[1]), # from _format_actions_usage
|
||||
)
|
||||
return text
|
||||
|
||||
def _format_action_invocation(self, action):
|
||||
from pygments.token import Name
|
||||
|
||||
text = super()._format_action_invocation(action)
|
||||
return self.colorize((Name.Attribute, text))
|
||||
|
||||
|
||||
def get_argparse_formatter_class():
|
||||
from xonsh.built_ins import XSH
|
||||
from xonsh.platform import HAS_PYGMENTS
|
||||
|
||||
if (
|
||||
hasattr(sys, "stderr")
|
||||
and sys.stderr.isatty()
|
||||
and XSH.env.get("XONSH_INTERACTIVE")
|
||||
and HAS_PYGMENTS
|
||||
):
|
||||
return RstHelpFormatter
|
||||
return ap.RawTextHelpFormatter
|
||||
|
||||
|
||||
class ArgParser(ap.ArgumentParser):
|
||||
"""Sub-class of ArgumentParser with special methods to nest commands"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if "formatter_class" not in kwargs:
|
||||
kwargs["formatter_class"] = get_argparse_formatter_class()
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self.commands = None
|
||||
|
||||
def add_command(
|
||||
self, func: tp.Callable, args: tp.Optional[tp.Iterable[str]] = None, **kwargs
|
||||
):
|
||||
"""
|
||||
create a sub-parser and call this function during dispatch
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func
|
||||
a type-annotated function that will be used to create ArgumentParser instance.
|
||||
All parameters that start with ``_`` will not be added to parser arguments.
|
||||
Use _stdout, _stack ... to receive them from callable-alias/commands.
|
||||
Use _parser to get the generated parser instance.
|
||||
Use _args to get what is passed from sys.argv
|
||||
Use _parsed to get result of ``parser.parse_args``
|
||||
args
|
||||
if given only add these arguments to the parser.
|
||||
Otherwise all parameters to the function without `_` prefixed
|
||||
in their name gets added to the parser.
|
||||
kwargs
|
||||
passed to ``subparser.add_parser`` call
|
||||
|
||||
Returns
|
||||
-------
|
||||
result from ``subparser.add_parser``
|
||||
"""
|
||||
if not self.commands:
|
||||
self.commands = self.add_subparsers(title="commands", dest="command")
|
||||
|
||||
doc = get_doc(func)
|
||||
kwargs.setdefault("description", doc)
|
||||
kwargs.setdefault("help", doc)
|
||||
parser = self.commands.add_parser(kwargs.pop("prog", func.__name__), **kwargs)
|
||||
add_args(parser, func, allowed_params=args)
|
||||
return parser
|
||||
|
||||
|
||||
def dispatch(**ns):
|
||||
def dispatch(parser: ap.ArgumentParser, args=None, **ns):
|
||||
"""call the sub-command selected by user"""
|
||||
import inspect
|
||||
|
||||
parsed = parser.parse_args(args)
|
||||
ns["_parsed"] = parsed
|
||||
ns.update(vars(parsed))
|
||||
|
||||
func = ns[_FUNC_NAME]
|
||||
sign = inspect.signature(func)
|
||||
kwargs = {}
|
||||
for name, _ in sign.parameters.items():
|
||||
kwargs[name] = ns[name]
|
||||
for name, param in sign.parameters.items():
|
||||
default = None
|
||||
# sometimes the args are skipped in the parser.
|
||||
# like ones having _ prefix(private to the function), or some special cases like exclusive group.
|
||||
# it is better to fill the defaults from paramspec when available.
|
||||
if param.default != inspect.Parameter.empty:
|
||||
default = param.default
|
||||
kwargs[name] = ns.get(name, default)
|
||||
return func(**kwargs)
|
||||
|
||||
|
||||
class ArgParserAlias:
|
||||
"""Provides a structure to the Alias. The parser is lazily loaded.
|
||||
|
||||
can help create ``argparse.ArgumentParser`` parser from function
|
||||
signature and dispatch the functions.
|
||||
|
||||
Examples
|
||||
---------
|
||||
For usage please check ``xonsh.completers.completer.py`` module.
|
||||
"""
|
||||
|
||||
def __init__(self, threadable=True, **kwargs) -> None:
|
||||
if not threadable:
|
||||
from xonsh.tools import unthreadable
|
||||
|
||||
unthreadable(self)
|
||||
self._parser = None
|
||||
self.kwargs = kwargs
|
||||
|
||||
def build(self):
|
||||
"""Sub-classes should return constructed ArgumentParser"""
|
||||
if self.kwargs:
|
||||
return self.create_parser(**self.kwargs)
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def parser(self):
|
||||
if self._parser is None:
|
||||
self._parser = self.build()
|
||||
return self._parser
|
||||
|
||||
def create_parser(self, func=None, has_args=False, allowed_params=None, **kwargs):
|
||||
"""create root parser"""
|
||||
func = func or self
|
||||
has_args = has_args or bool(allowed_params)
|
||||
if has_args:
|
||||
kwargs.setdefault("empty_help", False)
|
||||
parser = make_parser(func, **kwargs)
|
||||
if has_args:
|
||||
add_args(parser, func, allowed_params=allowed_params)
|
||||
return parser
|
||||
|
||||
def __call__(
|
||||
self, args, stdin=None, stdout=None, stderr=None, spec=None, stack=None
|
||||
):
|
||||
return dispatch(
|
||||
self.parser,
|
||||
args,
|
||||
_parser=self.parser,
|
||||
_args=args,
|
||||
_stdin=stdin,
|
||||
_stdout=stdout,
|
||||
_stderr=stderr,
|
||||
_spec=spec,
|
||||
_stack=stack,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"Arg",
|
||||
"ArgParserAlias",
|
||||
"Annotated",
|
||||
"ArgParser",
|
||||
"make_parser",
|
||||
"add_args",
|
||||
"get_doc",
|
||||
"dispatch",
|
||||
)
|
||||
|
|
|
@ -13,6 +13,8 @@ else:
|
|||
_sys.modules["xonsh.completers.bash_completion"] = __amalgam__
|
||||
tools = __amalgam__
|
||||
_sys.modules["xonsh.completers.tools"] = __amalgam__
|
||||
argparser = __amalgam__
|
||||
_sys.modules["xonsh.completers.argparser"] = __amalgam__
|
||||
commands = __amalgam__
|
||||
_sys.modules["xonsh.completers.commands"] = __amalgam__
|
||||
completer = __amalgam__
|
||||
|
|
|
@ -8,6 +8,12 @@ from xonsh.completers.completer import (
|
|||
remove_completer,
|
||||
add_one_completer,
|
||||
)
|
||||
from xonsh.completers.argparser import complete_argparser
|
||||
from xonsh.completers.tools import (
|
||||
contextual_command_completer,
|
||||
get_filter_function,
|
||||
)
|
||||
from xonsh.parsers.completion_context import CommandContext
|
||||
|
||||
# for backward compatibility
|
||||
_add_one_completer = add_one_completer
|
||||
|
@ -18,8 +24,30 @@ def _remove_completer(args):
|
|||
return remove_completer(args[0])
|
||||
|
||||
|
||||
def _register_completer(name: str, func: str, pos="start", stack=None):
|
||||
"""adds a new completer to xonsh
|
||||
def complete_func_name_choices(xsh, **_):
|
||||
"""Return all callable names in the current context"""
|
||||
for i, j in xsh.ctx.items():
|
||||
if callable(j):
|
||||
yield i
|
||||
|
||||
|
||||
def complete_completer_pos_choices(xsh, **_):
|
||||
"""Compute possible positions for the new completer"""
|
||||
yield from {"start", "end"}
|
||||
for k in xsh.completers.keys():
|
||||
yield ">" + k
|
||||
yield "<" + k
|
||||
|
||||
|
||||
def _register_completer(
|
||||
name: str,
|
||||
func: xcli.Annotated[str, xcli.Arg(completer=complete_func_name_choices)],
|
||||
pos: xcli.Annotated[
|
||||
str, xcli.Arg(completer=complete_completer_pos_choices, nargs="?")
|
||||
] = "start",
|
||||
_stack=None,
|
||||
):
|
||||
"""Add a new completer to xonsh
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
@ -48,7 +76,6 @@ def _register_completer(name: str, func: str, pos="start", stack=None):
|
|||
be added after the completer named KEY
|
||||
* "<KEY", where KEY is a pre-existing name, indicates that this should
|
||||
be added before the completer named KEY
|
||||
(Default value: "start")
|
||||
"""
|
||||
err = None
|
||||
func_name = func
|
||||
|
@ -61,7 +88,7 @@ def _register_completer(name: str, func: str, pos="start", stack=None):
|
|||
if not callable(func):
|
||||
err = f"{func_name} is not callable"
|
||||
else:
|
||||
for frame_info in stack:
|
||||
for frame_info in _stack:
|
||||
frame = frame_info[0]
|
||||
if func_name in frame.f_locals:
|
||||
func = frame.f_locals[func_name]
|
||||
|
@ -104,8 +131,41 @@ def _parser() -> ap.ArgumentParser:
|
|||
return parser
|
||||
|
||||
|
||||
def completer_alias(args, stdin=None, stdout=None, stderr=None, spec=None, stack=None):
|
||||
class CompleterAlias(xcli.ArgParserAlias):
|
||||
"""CLI to add/remove/list xonsh auto-complete functions"""
|
||||
ns = _parser.parse_args(args)
|
||||
kwargs = vars(ns)
|
||||
return xcli.dispatch(**kwargs, stdin=stdin, stdout=stdout, stack=stack)
|
||||
|
||||
def build(self):
|
||||
parser = self.create_parser(prog="completer")
|
||||
parser.add_command(_register_completer, prog="add")
|
||||
parser.add_command(remove_completer, prog="remove", aliases=["rm"])
|
||||
parser.add_command(list_completers, prog="list", aliases=["ls"])
|
||||
return parser
|
||||
|
||||
|
||||
completer_alias = CompleterAlias()
|
||||
|
||||
|
||||
@contextual_command_completer
|
||||
def complete_argparser_aliases(command: CommandContext):
|
||||
"""Completer for any alias command that has ``argparser`` in ``parser`` attribute"""
|
||||
|
||||
if not command.args:
|
||||
return
|
||||
cmd = command.args[0].value
|
||||
|
||||
alias = XSH.aliases.get(cmd) # type: ignore
|
||||
# todo: checking isinstance(alias, ArgParserAlias) fails when amalgamated.
|
||||
# see https://github.com/xonsh/xonsh/pull/4267#discussion_r676066853
|
||||
if not hasattr(alias, "parser"):
|
||||
return
|
||||
|
||||
if command.suffix:
|
||||
# completing in a middle of a word
|
||||
# (e.g. "completer some<TAB>thing")
|
||||
return
|
||||
|
||||
possible = complete_argparser(alias.parser, command=command, alias=alias)
|
||||
fltr = get_filter_function()
|
||||
for comp in possible:
|
||||
if fltr(comp, command.prefix):
|
||||
yield comp
|
||||
|
|
149
xonsh/completers/argparser.py
Normal file
149
xonsh/completers/argparser.py
Normal file
|
@ -0,0 +1,149 @@
|
|||
import argparse as ap
|
||||
import typing as tp
|
||||
|
||||
from xonsh.built_ins import XSH
|
||||
from xonsh.completers.tools import RichCompletion
|
||||
from xonsh.parsers.completion_context import CommandContext
|
||||
|
||||
|
||||
class ArgparseCompleter:
|
||||
"""A completer function for ArgParserAlias commands"""
|
||||
|
||||
def __init__(self, parser: ap.ArgumentParser, command: CommandContext, **kwargs):
|
||||
args = tuple(c.value for c in command.args[: command.arg_index])
|
||||
|
||||
self.parser, self.remaining_args = self.get_parser(parser, args[1:])
|
||||
|
||||
self.command = command
|
||||
kwargs["command"] = command
|
||||
self.kwargs = kwargs
|
||||
"""will be sent to completer function"""
|
||||
|
||||
@staticmethod
|
||||
def get_parser(parser, args) -> tp.Tuple[ap.ArgumentParser, tp.Tuple[str, ...]]:
|
||||
"""Check for sub-parsers"""
|
||||
sub_parsers = {}
|
||||
for act in parser._get_positional_actions():
|
||||
if act.nargs == ap.PARSER:
|
||||
sub_parsers = act.choices # there should be only one subparser
|
||||
if sub_parsers:
|
||||
for idx, pos in enumerate(args):
|
||||
if pos in sub_parsers:
|
||||
# get the correct parser
|
||||
return ArgparseCompleter.get_parser(
|
||||
sub_parsers[pos], args[idx + 1 :]
|
||||
)
|
||||
# base scenario
|
||||
return parser, args
|
||||
|
||||
def filled(self, act: ap.Action) -> int:
|
||||
"""Consume remaining_args for the given action"""
|
||||
args_len = 0
|
||||
for arg in self.remaining_args:
|
||||
if arg and arg[0] in self.parser.prefix_chars:
|
||||
# stop when other --option explicitly given
|
||||
break
|
||||
args_len += 1
|
||||
nargs = (
|
||||
act.nargs
|
||||
if isinstance(act.nargs, int)
|
||||
else args_len + 1
|
||||
if act.nargs in {ap.ONE_OR_MORE, ap.ZERO_OR_MORE}
|
||||
else 1
|
||||
)
|
||||
if len(self.remaining_args) >= nargs:
|
||||
# consume n-number of args
|
||||
self.remaining_args = self.remaining_args[nargs:]
|
||||
# complete for next action
|
||||
return True
|
||||
return False
|
||||
|
||||
def _complete(self, act: ap.Action, **kwargs):
|
||||
if act.choices:
|
||||
yield from act.choices
|
||||
elif hasattr(act, "completer") and callable(act.completer): # type: ignore
|
||||
# call the completer function
|
||||
from xonsh.built_ins import XSH
|
||||
|
||||
kwargs.update(self.kwargs)
|
||||
yield from act.completer(xsh=XSH, action=act, completer=self, **kwargs) # type: ignore
|
||||
|
||||
def _complete_pos(self, act):
|
||||
if isinstance(act.choices, dict): # sub-parsers
|
||||
for choice, sub_parser in act.choices.items():
|
||||
yield RichCompletion(
|
||||
choice,
|
||||
description=sub_parser.description or "",
|
||||
append_space=True,
|
||||
)
|
||||
else:
|
||||
yield from self._complete(act)
|
||||
|
||||
def complete(self):
|
||||
# options will come before/after positionals
|
||||
options = {act: None for act in self.parser._get_optional_actions()}
|
||||
|
||||
# remove options that are already filled
|
||||
opt_completions = self._complete_options(options)
|
||||
if opt_completions:
|
||||
yield from opt_completions
|
||||
return
|
||||
|
||||
for act in self.parser._get_positional_actions():
|
||||
# number of arguments it consumes
|
||||
if self.filled(act):
|
||||
continue
|
||||
yield from self._complete_pos(act)
|
||||
# close after a valid positional arg completion
|
||||
break
|
||||
|
||||
opt_completions = self._complete_options(options)
|
||||
if opt_completions:
|
||||
yield from opt_completions
|
||||
return
|
||||
|
||||
# complete remaining options only if requested or enabled
|
||||
show_opts = XSH.env.get("ALIAS_COMPLETIONS_OPTIONS_BY_DEFAULT", False)
|
||||
if not show_opts:
|
||||
if not (
|
||||
self.command.prefix
|
||||
and self.command.prefix[0] in self.parser.prefix_chars
|
||||
):
|
||||
return
|
||||
|
||||
# in the end after positionals show remaining unfilled options
|
||||
for act in options:
|
||||
for flag in act.option_strings:
|
||||
desc = ""
|
||||
if act.help:
|
||||
formatter = self.parser._get_formatter()
|
||||
try:
|
||||
desc = formatter._expand_help(act)
|
||||
except KeyError:
|
||||
desc = act.help
|
||||
yield RichCompletion(flag, description=desc)
|
||||
|
||||
def _complete_options(self, options):
|
||||
while self.remaining_args:
|
||||
arg = self.remaining_args[0]
|
||||
act_res = self.parser._parse_optional(arg)
|
||||
if not act_res:
|
||||
# it is not a option string: pass
|
||||
break
|
||||
# it is a valid option and advance
|
||||
self.remaining_args = self.remaining_args[1:]
|
||||
act, _, value = act_res
|
||||
|
||||
# remove the found option
|
||||
# todo: not remove if append/extend
|
||||
options.pop(act, None)
|
||||
|
||||
if self.filled(act):
|
||||
continue
|
||||
# stop suggestion until current option is complete
|
||||
return self._complete(act)
|
||||
|
||||
|
||||
def complete_argparser(parser, command: CommandContext, **kwargs):
|
||||
completer = ArgparseCompleter(parser, command=command, **kwargs)
|
||||
yield from completer.complete()
|
|
@ -1,53 +1,14 @@
|
|||
import collections
|
||||
from xonsh.parsers.completion_context import CommandContext
|
||||
|
||||
from xonsh.built_ins import XSH
|
||||
from xonsh.cli_utils import Arg, Annotated, get_doc
|
||||
from xonsh.completers.tools import (
|
||||
contextual_command_completer_for,
|
||||
justify,
|
||||
is_exclusive_completer,
|
||||
RichCompletion,
|
||||
)
|
||||
|
||||
|
||||
@contextual_command_completer_for("completer")
|
||||
def complete_completer(command: CommandContext):
|
||||
"""
|
||||
Completion for "completer"
|
||||
"""
|
||||
if command.suffix:
|
||||
# completing in a middle of a word
|
||||
# (e.g. "completer some<TAB>thing")
|
||||
return None
|
||||
|
||||
curix = command.arg_index
|
||||
|
||||
compnames = set(XSH.completers.keys())
|
||||
if curix == 1:
|
||||
possible = {"list", "help", "add", "remove"}
|
||||
elif curix == 2:
|
||||
first_arg = command.args[1].value
|
||||
if first_arg == "help":
|
||||
possible = {"list", "add", "remove"}
|
||||
elif first_arg == "remove":
|
||||
possible = compnames
|
||||
else:
|
||||
raise StopIteration
|
||||
else:
|
||||
if command.args[1].value != "add":
|
||||
raise StopIteration
|
||||
if curix == 3:
|
||||
possible = {i for i, j in XSH.ctx.items() if callable(j)}
|
||||
elif curix == 4:
|
||||
possible = (
|
||||
{"start", "end"}
|
||||
| {">" + n for n in compnames}
|
||||
| {"<" + n for n in compnames}
|
||||
)
|
||||
else:
|
||||
raise StopIteration
|
||||
return {i for i in possible if i.startswith(command.prefix)}
|
||||
|
||||
|
||||
def add_one_completer(name, func, loc="end"):
|
||||
new = collections.OrderedDict()
|
||||
if loc == "start":
|
||||
|
@ -108,8 +69,16 @@ def list_completers():
|
|||
return o + "\n".join(_strs) + "\n"
|
||||
|
||||
|
||||
def remove_completer(name: str):
|
||||
"""removes a completer from xonsh
|
||||
def complete_completer_names(xsh, **_):
|
||||
"""Complete all loaded completer names"""
|
||||
for name, comp in xsh.completers.items():
|
||||
yield RichCompletion(name, description=get_doc(comp))
|
||||
|
||||
|
||||
def remove_completer(
|
||||
name: Annotated[str, Arg(completer=complete_completer_names)],
|
||||
):
|
||||
"""Removes a completer from xonsh
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
|
|
@ -16,8 +16,8 @@ from xonsh.completers.commands import (
|
|||
complete_end_proc_tokens,
|
||||
complete_end_proc_keywords,
|
||||
)
|
||||
from xonsh.completers.completer import complete_completer
|
||||
from xonsh.completers.xompletions import complete_xonfig, complete_xontrib
|
||||
from xonsh.completers._aliases import complete_argparser_aliases
|
||||
from xonsh.completers.environment import complete_environment_vars
|
||||
|
||||
|
||||
|
@ -31,8 +31,8 @@ def default_completers():
|
|||
("environment_vars", complete_environment_vars),
|
||||
# exclusive completers:
|
||||
("base", complete_base),
|
||||
("completer", complete_completer),
|
||||
("skip", complete_skipper),
|
||||
("argparser_aliases", complete_argparser_aliases),
|
||||
("pip", complete_pip),
|
||||
("cd", complete_cd),
|
||||
("rmdir", complete_rmdir),
|
||||
|
|
|
@ -1577,6 +1577,12 @@ class AsyncPromptSetting(PTKSetting):
|
|||
class AutoCompletionSetting(Xettings):
|
||||
"""Tab-completion behavior."""
|
||||
|
||||
ALIAS_COMPLETIONS_OPTIONS_BY_DEFAULT = Var.with_default(
|
||||
doc="If True, Argparser based alias completions will show options (e.g. -h, ...) without "
|
||||
"requesting explicitly with option prefix (-).",
|
||||
default=False,
|
||||
type_str="bool",
|
||||
)
|
||||
BASH_COMPLETIONS = Var.with_default(
|
||||
doc="This is a list (or tuple) of strings that specifies where the "
|
||||
"``bash_completion`` script may be found. "
|
||||
|
|
Loading…
Add table
Reference in a new issue