Xompletions (#4521)

* feat: add command completers logic

* feat: implement xompleter logic

* fix: handle callable object in exception

* chore: add xompletions package to setup.py

* fix: update tests for changes to command completer logic

* docs:

* fix: qa errors

fixes #4514

* feat: add xonsh completions

* refactor: split module matcher to separate class

* feat: add django-admin completions

* fix: failing tests

* feat: add more properties to completion-context

* refactor: cleanup code

* todo item add
This commit is contained in:
Noorhteen Raja NJ 2022-01-10 21:51:22 +05:30 committed by GitHub
parent 27970af142
commit 039294c362
Failed to generate hash of commit
17 changed files with 267 additions and 179 deletions

24
news/feat-xompletions.rst Normal file
View file

@ -0,0 +1,24 @@
**Added:**
* Python files with command completions can be put inside ``xompletions`` namespace package,
they will get loaded lazily.
**Changed:**
* <news item>
**Deprecated:**
* <news item>
**Removed:**
* <news item>
**Fixed:**
* <news item>
**Security:**
* <news item>

View file

@ -323,10 +323,12 @@ def main():
"xonsh.prompt", "xonsh.prompt",
"xonsh.lib", "xonsh.lib",
"xonsh.webconfig", "xonsh.webconfig",
"xompletions",
], ],
package_dir={ package_dir={
"xonsh": "xonsh", "xonsh": "xonsh",
"xontrib": "xontrib", "xontrib": "xontrib",
"xompletions": "xompletions",
"xonsh.lib": "xonsh/lib", "xonsh.lib": "xonsh/lib",
"xonsh.webconfig": "xonsh/webconfig", "xonsh.webconfig": "xonsh/webconfig",
}, },

View file

@ -1,21 +1,10 @@
import pytest
import tempfile import tempfile
from os import sep from os import sep
from xonsh.completers.tools import RichCompletion import pytest
from xonsh.completers.dirs import complete_cd, complete_rmdir
from xonsh.parsers.completion_context import (
CompletionContext,
CommandContext,
CommandArg,
)
from tests.tools import ON_WINDOWS from tests.tools import ON_WINDOWS
from xonsh.completers.tools import RichCompletion
COMPLETERS = {
"cd": complete_cd,
"rmdir": complete_rmdir,
}
CUR_DIR = "." if ON_WINDOWS else "./" CUR_DIR = "." if ON_WINDOWS else "./"
PARENT_DIR = ".." if ON_WINDOWS else "../" PARENT_DIR = ".." if ON_WINDOWS else "../"
@ -28,52 +17,14 @@ def setup(xession, xonsh_execer):
xession.env["CDPATH"] = set() xession.env["CDPATH"] = set()
@pytest.fixture(params=list(COMPLETERS)) @pytest.fixture(params=["cd", "rmdir"])
def cmd(request): def cmd(request):
return request.param return request.param
def test_not_cmd(cmd): def test_non_dir(cmd, check_completer):
"""Ensure the cd completer doesn't complete other commands"""
assert not COMPLETERS[cmd](
CompletionContext(
CommandContext(
args=(CommandArg(f"not-{cmd}"),),
arg_index=1,
)
)
)
def complete_cmd(cmd, prefix, opening_quote="", closing_quote=""):
result = COMPLETERS[cmd](
CompletionContext(
CommandContext(
args=(CommandArg(cmd),),
arg_index=1,
prefix=prefix,
opening_quote=opening_quote,
closing_quote=closing_quote,
is_after_closing_quote=bool(closing_quote),
)
)
)
assert result and len(result) == 2
completions, lprefix = result
assert lprefix == len(opening_quote) + len(prefix) + len(
closing_quote
) # should override the quotes
return completions
def complete_cmd_dirs(*a, **kw):
return [r.value for r in complete_cmd(*a, **kw)]
def test_non_dir(cmd):
with tempfile.NamedTemporaryFile(dir=".", suffix="_dummySuffix") as tmp: with tempfile.NamedTemporaryFile(dir=".", suffix="_dummySuffix") as tmp:
with pytest.raises(StopIteration): # tmp is a file assert not check_completer(cmd, prefix=tmp.name[:-2])
complete_cmd(cmd, tmp.name[:-2])
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@ -82,16 +33,16 @@ def dir_path():
yield tmp_path yield tmp_path
def test_dirs_only(cmd, dir_path): def test_dirs_only(cmd, dir_path, check_completer):
completions = complete_cmd(cmd, dir_path[:-2]) completions = check_completer(cmd, dir_path[:-2])
assert completions == {dir_path + sep} assert completions == {dir_path + sep}
def test_opening_quotes(cmd, dir_path): def test_opening_quotes(cmd, dir_path, check_completer):
assert complete_cmd(cmd, dir_path, opening_quote="r'") == {f"r'{dir_path}{sep}'"} assert check_completer(cmd, "r'" + dir_path) == {f"r'{dir_path}{sep}'"}
def test_closing_quotes(cmd, dir_path): def test_closing_quotes(cmd, dir_path, check_completer):
prefix = dir_path prefix = dir_path
exp = f"'''{dir_path}{sep}'''" exp = f"'''{dir_path}{sep}'''"
if ON_WINDOWS: if ON_WINDOWS:
@ -99,33 +50,35 @@ def test_closing_quotes(cmd, dir_path):
# the path completer converts to a raw string if there's a backslash # the path completer converts to a raw string if there's a backslash
exp = "r" + exp exp = "r" + exp
completions = complete_cmd(cmd, prefix, opening_quote="'''", closing_quote="'''") values, completions = check_completer(
cmd, "'''" + prefix + "'''", send_original=True
)
assert completions == {exp} assert values == {exp}
completion = completions.pop() completion = list(completions).pop()
assert isinstance(completion, RichCompletion) assert isinstance(completion, RichCompletion)
assert completion.append_closing_quote is False assert completion.append_closing_quote is False
def test_complete_dots(xession): def test_complete_dots(xession, check_completer):
with xession.env.swap(COMPLETE_DOTS="never"): with xession.env.swap(COMPLETE_DOTS="never"):
dirs = complete_cmd_dirs("cd", "") dirs = check_completer("cd")
assert CUR_DIR not in dirs and PARENT_DIR not in dirs assert CUR_DIR not in dirs and PARENT_DIR not in dirs
dirs = complete_cmd_dirs("cd", ".") dirs = check_completer("cd", ".")
assert CUR_DIR not in dirs and PARENT_DIR not in dirs assert CUR_DIR not in dirs and PARENT_DIR not in dirs
with xession.env.swap(COMPLETE_DOTS="matching"): with xession.env.swap(COMPLETE_DOTS="matching"):
dirs = complete_cmd_dirs("cd", "") dirs = check_completer("cd", "")
assert CUR_DIR not in dirs and PARENT_DIR not in dirs assert CUR_DIR not in dirs and PARENT_DIR not in dirs
dirs = complete_cmd_dirs("cd", ".") dirs = check_completer("cd", ".")
assert CUR_DIR in dirs and PARENT_DIR in dirs assert CUR_DIR in dirs and PARENT_DIR in dirs
with xession.env.swap(COMPLETE_DOTS="always"): with xession.env.swap(COMPLETE_DOTS="always"):
dirs = complete_cmd_dirs("cd", "") dirs = check_completer("cd", "")
assert CUR_DIR in dirs and PARENT_DIR in dirs assert CUR_DIR in dirs and PARENT_DIR in dirs
dirs = complete_cmd_dirs("cd", ".") dirs = check_completer("cd", ".")
assert CUR_DIR in dirs and PARENT_DIR in dirs assert CUR_DIR in dirs and PARENT_DIR in dirs

View file

@ -1,23 +1,22 @@
import pytest import pytest
from tests.tools import ON_WINDOWS from xonsh.completers.commands import complete_xompletions
from xonsh.completers.pip import PIP_RE
@pytest.mark.parametrize( regex_cases = [
"line",
[
"pip", "pip",
"pip.exe", "pip.exe",
"pip3.6.exe", "pip3.6.exe",
"xpip", "xpip",
"/usr/bin/pip3", ]
r"C:\Python\Scripts\pip",
r"C:\Python\Scripts\pip.exe",
], @pytest.mark.parametrize(
"line",
regex_cases,
) )
def test_pip_re(line): def test_pip_re(line):
assert PIP_RE.search(line) assert complete_xompletions.matcher.search_completer(line)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -35,7 +34,7 @@ def test_pip_re(line):
], ],
) )
def test_pip_list_re1(line): def test_pip_list_re1(line):
assert PIP_RE.search(line) is None assert complete_xompletions.matcher.search_completer(line) is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -49,8 +48,6 @@ def test_completions(line, prefix, exp, check_completer, xession, os_env, monkey
# use the actual PATH from os. Otherwise subproc will fail on windows. `unintialized python...` # use the actual PATH from os. Otherwise subproc will fail on windows. `unintialized python...`
monkeypatch.setattr(xession, "env", os_env) monkeypatch.setattr(xession, "env", os_env)
if ON_WINDOWS:
line = line.replace("pip", "pip.exe")
comps = check_completer(line, prefix=prefix) comps = check_completer(line, prefix=prefix)
assert comps.intersection(exp) assert comps.intersection(exp)

View file

@ -232,10 +232,15 @@ def completion_context_parse():
return CompletionContextParser().parse return CompletionContextParser().parse
@pytest.fixture(scope="session")
def completer_obj():
return Completer()
@pytest.fixture @pytest.fixture
def check_completer(): def check_completer(completer_obj):
"""Helper function to run completer and parse the results as set of strings""" """Helper function to run completer and parse the results as set of strings"""
completer = Completer() completer = completer_obj
def _factory(line: str, prefix="", send_original=False): def _factory(line: str, prefix="", send_original=False):
completions, _ = completer.complete_line(line, prefix=prefix) completions, _ = completer.complete_line(line, prefix=prefix)

12
xompletions/cd.py Normal file
View file

@ -0,0 +1,12 @@
from xonsh.completers.path import complete_dir
from xonsh.parsers.completion_context import CommandContext
def xonsh_complete(command: CommandContext):
"""
Completion for "cd", includes only valid directory names.
"""
results, lprefix = complete_dir(command)
if len(results) == 0:
raise StopIteration
return results, lprefix

View file

@ -0,0 +1,16 @@
"""Completers for pip."""
from xonsh.completers.tools import comp_based_completer
from xonsh.parsers.completion_context import CommandContext
def xonsh_complete(ctx: CommandContext):
"""Completes python's package manager pip."""
# adapted from https://github.com/django/django/blob/main/extras/django_bash_completion
# todo: find a way to get description for the completions like here
# 1. https://github.com/apie/fish-django-completions/blob/master/fish_django_completions.py
# 2. complete python manage.py invocations
# https://github.com/django/django/blob/main/extras/django_bash_completion
return comp_based_completer(ctx, DJANGO_AUTO_COMPLETE="1")

10
xompletions/pip.py Normal file
View file

@ -0,0 +1,10 @@
"""Completers for pip."""
from xonsh.completers.tools import comp_based_completer
from xonsh.parsers.completion_context import CommandContext
def xonsh_complete(ctx: CommandContext):
"""Completes python's package manager pip."""
return comp_based_completer(ctx, PIP_AUTO_COMPLETE="1")

14
xompletions/rmdir.py Normal file
View file

@ -0,0 +1,14 @@
from xonsh.completers.man import complete_from_man
from xonsh.completers.path import complete_dir
from xonsh.parsers.completion_context import CompletionContext, CommandContext
def xonsh_complete(command: CommandContext):
"""
Completion for "rmdir", includes only valid directory names.
"""
opts = complete_from_man(CompletionContext(command))
comps, lp = complete_dir(command)
if len(comps) == 0 and len(opts) == 0:
raise StopIteration
return comps | opts, lp

17
xompletions/xonsh.py Normal file
View file

@ -0,0 +1,17 @@
from xonsh.cli_utils import ArgparseCompleter
from xonsh.completers.tools import get_filter_function
from xonsh.parsers.completion_context import CommandContext
def xonsh_complete(command: CommandContext):
"""Completer for ``xonsh`` command using its ``argparser``"""
from xonsh.main import parser
completer = ArgparseCompleter(parser, command=command)
fltr = get_filter_function()
for comp in completer.complete():
if fltr(comp, command.prefix):
yield comp
# todo: part of return value will have unfiltered=False/True. based on that we can use fuzzy to rank the results

View file

@ -154,8 +154,9 @@ class Completer:
# completer requested to stop collecting completions # completer requested to stop collecting completions
break break
except Exception as e: except Exception as e:
name = func.__name__ if hasattr(func, "__name__") else str(func)
print_exception( print_exception(
f"Completer {func.__name__} raises exception when gets " f"Completer {name} raises exception when gets "
f"old_args={old_completer_args[:-1]} / completion_context={completion_context!r}:\n" f"old_args={old_completer_args[:-1]} / completion_context={completion_context!r}:\n"
f"{type(e)} - {e}" f"{type(e)} - {e}"
) )

View file

@ -1,4 +1,8 @@
import functools
import importlib.util
import os import os
import re
import typing as tp
import xonsh.tools as xt import xonsh.tools as xt
import xonsh.platform as xp import xonsh.platform as xp
@ -96,3 +100,91 @@ def complete_end_proc_keywords(command_context: CommandContext):
if prefix in END_PROC_KEYWORDS: if prefix in END_PROC_KEYWORDS:
return {RichCompletion(prefix, append_space=True)} return {RichCompletion(prefix, append_space=True)}
return None return None
class ModuleMatcher:
"""Reusable module matcher. Can be used by other completers like Python to find matching script completions"""
def __init__(self, base: str):
# list of pre-defined patterns. More can be added using the public method ``.wrap``
self._patterns: tp.Dict[str, str] = {
r"\bx?pip(?:\d|\.)*(exe)?$": "pip",
}
self._compiled: tp.Dict[str, tp.Pattern] = {}
self.contextual = True
self.base = base
def wrap(self, pattern: str, module: str):
"""For any commands matching the pattern complete from the ``module``"""
self._patterns[pattern] = module
def get_module(self, name):
try:
# todo: not just namespace package,
# add an environment variable to get list of paths to check for completions like fish
# - https://fishshell.com/docs/current/completions.html#where-to-put-completions
return importlib.import_module(f"{self.base}.{name}")
except ModuleNotFoundError:
pass
def search_completer(self, cmd: str, cleaned=False):
if not cleaned:
cmd = CommandCompleter.clean_cmd_name(cmd)
# try any pattern match
for pattern, mod_name in self._patterns.items():
# lazy compile regex
if pattern not in self._compiled:
self._compiled[pattern] = re.compile(pattern, re.IGNORECASE)
regex = self._compiled[pattern]
if regex.match(cmd):
return self.get_module(mod_name)
class CommandCompleter:
"""Lazily complete commands from `xompletions` package
The base-name (case-insensitive) of the executable is used to find the matching completer module
or the regex patterns.
"""
def __init__(self):
self.contextual = True
self.matcher = ModuleMatcher("xompletions")
@staticmethod
@functools.lru_cache(10)
def clean_cmd_name(cmd: str):
cmd_name = os.path.basename(cmd).lower()
exts = XSH.env.get("PATHEXT", [])
for ex in exts:
if cmd_name.endswith(ex.lower()):
# windows handling
cmd_name = cmd_name.rstrip(ex.lower())
break
return cmd_name
def __call__(self, full_ctx: CompletionContext):
"""For the given command load completions lazily"""
# completion for commands only
ctx = full_ctx.command
if not ctx:
return
if ctx.arg_index == 0:
return
cmd_name = self.clean_cmd_name(ctx.command)
module = self.matcher.get_module(cmd_name) or self.matcher.search_completer(
cmd_name, cleaned=True
)
if not module:
return
if hasattr(module, "xonsh_complete"):
func = module.xonsh_complete
return func(ctx)
complete_xompletions = CommandCompleter()

View file

@ -1,30 +0,0 @@
from xonsh.completers.man import complete_from_man
from xonsh.completers.path import complete_dir
from xonsh.completers.tools import contextual_command_completer_for
from xonsh.parsers.completion_context import (
CompletionContext,
CommandContext,
)
@contextual_command_completer_for("cd")
def complete_cd(command: CommandContext):
"""
Completion for "cd", includes only valid directory names.
"""
results, lprefix = complete_dir(command)
if len(results) == 0:
raise StopIteration
return results, lprefix
@contextual_command_completer_for("rmdir")
def complete_rmdir(command: CommandContext):
"""
Completion for "rmdir", includes only valid directory names.
"""
opts = complete_from_man(CompletionContext(command))
comps, lp = complete_dir(command)
if len(comps) == 0 and len(opts) == 0:
raise StopIteration
return comps | opts, lp

View file

@ -1,12 +1,10 @@
"""Constructor for xonsh completer objects.""" """Constructor for xonsh completer objects."""
import collections import collections
from xonsh.completers.pip import complete_pip
from xonsh.completers.man import complete_from_man from xonsh.completers.man import complete_from_man
from xonsh.completers.bash import complete_from_bash from xonsh.completers.bash import complete_from_bash
from xonsh.completers.base import complete_base from xonsh.completers.base import complete_base
from xonsh.completers.path import complete_path from xonsh.completers.path import complete_path
from xonsh.completers.dirs import complete_cd, complete_rmdir
from xonsh.completers.python import ( from xonsh.completers.python import (
complete_python, complete_python,
) )
@ -15,6 +13,7 @@ from xonsh.completers.commands import (
complete_skipper, complete_skipper,
complete_end_proc_tokens, complete_end_proc_tokens,
complete_end_proc_keywords, complete_end_proc_keywords,
complete_xompletions,
) )
from xonsh.completers._aliases import complete_aliases from xonsh.completers._aliases import complete_aliases
from xonsh.completers.environment import complete_environment_vars from xonsh.completers.environment import complete_environment_vars
@ -32,9 +31,7 @@ def default_completers():
("base", complete_base), ("base", complete_base),
("skip", complete_skipper), ("skip", complete_skipper),
("alias", complete_aliases), ("alias", complete_aliases),
("pip", complete_pip), ("xompleter", complete_xompletions),
("cd", complete_cd),
("rmdir", complete_rmdir),
("import", complete_import), ("import", complete_import),
("bash", complete_from_bash), ("bash", complete_from_bash),
("man", complete_from_man), ("man", complete_from_man),

View file

@ -1,56 +0,0 @@
"""Completers for pip."""
import re
import shlex
import subprocess
import xonsh.lazyasd as xl
from xonsh.built_ins import XSH
from xonsh.completers.tools import (
contextual_command_completer,
get_filter_function,
RichCompletion,
)
from xonsh.parsers.completion_context import CommandContext
@xl.lazyobject
def PIP_RE():
return re.compile(r"\bx?pip(?:\d|\.)*(exe)?$")
@contextual_command_completer
def complete_pip(context: CommandContext):
"""Completes python's package manager pip."""
prefix = context.prefix
if context.arg_index == 0 or (not PIP_RE.search(context.args[0].value.lower())):
return None
filter_func = get_filter_function()
args = [arg.raw_value for arg in context.args]
env = XSH.env.detype()
env.update(
{
"PIP_AUTO_COMPLETE": "1",
"COMP_WORDS": " ".join(args),
"COMP_CWORD": str(len(context.args)),
}
)
try:
proc = subprocess.run(
[args[0]],
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
env=env,
)
except FileNotFoundError:
return None
if proc.stdout:
out = shlex.split(proc.stdout.decode())
for cmp in out:
if filter_func(cmp, prefix):
yield RichCompletion(cmp, append_space=True)
return None

View file

@ -1,5 +1,7 @@
"""Xonsh completer tools.""" """Xonsh completer tools."""
import inspect import inspect
import shlex
import subprocess
import textwrap import textwrap
import typing as tp import typing as tp
from functools import wraps from functools import wraps
@ -201,3 +203,35 @@ def apply_lprefix(comps, lprefix):
yield comp yield comp
else: else:
yield RichCompletion(comp, prefix_len=lprefix) yield RichCompletion(comp, prefix_len=lprefix)
def comp_based_completer(ctx: CommandContext, **env: str):
"""Helper function to complete commands such as ``pip``,``django-admin``,... that use bash's ``complete``"""
prefix = ctx.prefix
filter_func = get_filter_function()
args = [arg.raw_value for arg in ctx.args]
env.update(
{
"COMP_WORDS": " ".join(args),
"COMP_CWORD": str(ctx.arg_index),
}
)
env.update(XSH.env.detype())
try:
proc = subprocess.run(
[args[0]],
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
env=env,
)
except FileNotFoundError:
return None
if proc.stdout:
out = shlex.split(proc.stdout.decode())
for cmp in out:
if filter_func(cmp, prefix):
yield RichCompletion(cmp, append_space=True)

View file

@ -71,7 +71,7 @@ class CommandContext(NamedTuple):
def completing_command(self, command: str) -> bool: def completing_command(self, command: str) -> bool:
"""Return whether this context is completing args for a command""" """Return whether this context is completing args for a command"""
return self.arg_index > 0 and self.args[0].value == command return self.arg_index > 0 and self.command == command
@property @property
def raw_prefix(self): def raw_prefix(self):