diff --git a/news/ap-vox.rst b/news/ap-vox.rst new file mode 100644 index 000000000..08a9c5a0f --- /dev/null +++ b/news/ap-vox.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* improve ``vox`` CLI completions + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/tests/conftest.py b/tests/conftest.py index 990c10b20..f01a80263 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -111,6 +111,7 @@ def xonsh_builtins(monkeypatch, xonsh_events, session_vars): cc = XSH.commands_cache monkeypatch.setattr(cc, "locate_binary", types.MethodType(locate_binary, cc)) + monkeypatch.setattr(cc, "_cmds_cache", {}) for attr, val in [ ("evalx", eval), diff --git a/tests/test_vox.py b/tests/test_vox.py index f8c07cc7f..1162b7bc2 100644 --- a/tests/test_vox.py +++ b/tests/test_vox.py @@ -3,6 +3,8 @@ import pathlib import stat import os import subprocess as sp +import types + import pytest import sys from xontrib.voxapi import Vox @@ -282,3 +284,126 @@ def test_autovox(xession, tmpdir, load_vox): assert vox.active() == "myenv" xonsh.dirstack.popd([]) print(xession.env["PWD"]) + + +@pytest.fixture +def venv_home(tmpdir): + """Path where VENVs are created""" + return tmpdir + + +@pytest.fixture +def venvs(venv_home): + """create bin paths in the tmpdir""" + from xonsh.dirstack import pushd, popd + + pushd([str(venv_home)]) + paths = [] + for idx in range(2): + bin_path = venv_home / f"venv{idx}" / "bin" + paths.append(bin_path) + + (bin_path / "python").write("", ensure=True) + (bin_path / "python.exe").write("", ensure=True) + for file in bin_path.listdir(): + st = os.stat(str(file)) + os.chmod(str(file), st.st_mode | stat.S_IEXEC) + yield paths + popd([]) + + +@pytest.fixture +def patched_cmd_cache(xession, load_vox, venvs, monkeypatch): + cc = xession.commands_cache + + def no_change(self, *_): + return False, False, False + + monkeypatch.setattr(cc, "_update_if_changed", types.MethodType(no_change, cc)) + monkeypatch.setattr(cc, "_update_cmds_cache", types.MethodType(no_change, cc)) + monkeypatch.setattr(cc, "cache_file", None) + bins = {path: (path, False) for path in _PY_BINS} + cc._cmds_cache.update(bins) + yield cc + + +_VENV_NAMES = {"venv1", "venv1/", "venv0/", "venv0"} +if ON_WINDOWS: + _VENV_NAMES = {"venv1\\", "venv0\\"} + +_HELP_OPTS = { + "-h", + "--help", +} +_PY_BINS = {"/bin/python2", "/bin/python3"} +_VOX_NEW_OPTS = { + "--copies", + "--help", + "-h", + "--ssp", + "--symlinks", + "--system-site-packages", + "--without-pip", +} +_VOX_NEW_EXP = _PY_BINS.union(_VOX_NEW_OPTS) + + +@pytest.mark.parametrize( + "args, positionals, opts", + [ + ( + "vox", + { + "delete", + "new", + "remove", + "del", + "workon", + "list", + "exit", + "ls", + "rm", + "deactivate", + "activate", + "enter", + "create", + }, + _HELP_OPTS, + ), + ( + "vox create", + set(), + { + "--copies", + "--symlinks", + "--ssp", + "--system-site-packages", + "--activate", + "--without-pip", + "--interpreter", + "-p", + "-a", + "--help", + "-h", + }, + ), + ("vox activate", _VENV_NAMES, _HELP_OPTS), + ("vox rm", _VENV_NAMES, _HELP_OPTS), + ("vox rm venv1", _VENV_NAMES, _HELP_OPTS), # pos nargs: one or more + ("vox rm venv1 venv2", _VENV_NAMES, _HELP_OPTS), # pos nargs: two or more + ("vox new --activate --interpreter", _PY_BINS, set()), # option after option + ("vox new --interpreter", _PY_BINS, set()), # "option: first + ("vox new --activate env1 --interpreter", _PY_BINS, set()), # option after pos + ("vox new env1 --interpreter", _PY_BINS, set()), # "option: at end" + ("vox new env1 --interpreter=", _PY_BINS, set()), # "option: at end with + ], +) +def test_vox_completer( + args, check_completer, positionals, opts, xession, patched_cmd_cache, venv_home +): + xession.env["XONSH_DATA_DIR"] = venv_home + if positionals: + assert check_completer(args) == positionals + xession.env["ALIAS_COMPLETIONS_OPTIONS_BY_DEFAULT"] = True + if opts: + assert check_completer(args) == positionals.union(opts) diff --git a/xontrib/vox.py b/xontrib/vox.py index 66ffd3f9f..e69c53432 100644 --- a/xontrib/vox.py +++ b/xontrib/vox.py @@ -1,48 +1,41 @@ """Python virtual environment manager for xonsh.""" -import sys -import textwrap +import xonsh.cli_utils as xcli import xontrib.voxapi as voxapi -import xonsh.lazyasd as lazyasd +from xonsh.built_ins import XSH __all__ = () -class VoxHandler: +def venv_names_completer(command, alias: "VoxHandler", **_): + envs = alias.vox.keys() + from xonsh.completers.path import complete_dir + + yield from envs + + paths, _ = complete_dir(command) + yield from paths + + +def py_interpreter_path_completer(xsh, **_): + for _, (path, is_alias) in xsh.commands_cache.all_commands.items(): + if not is_alias and ("/python" in path or "/pypy" in path): + yield path + + +class VoxHandler(xcli.ArgParserAlias): """Vox is a virtual environment manager for xonsh.""" - def parser(): - from argparse import ArgumentParser + def build(self): + """lazily called during dispatch""" + self.vox = voxapi.Vox() + parser = self.create_parser(prog="vox") - parser = ArgumentParser(prog="vox", description=__doc__) - subparsers = parser.add_subparsers(dest="command") - - create = subparsers.add_parser( - "new", + # todo: completer for interpreter + create = parser.add_command( + self.new, aliases=["create"], - help="Create a new virtual environment in $VIRTUALENV_HOME", - ) - create.add_argument("name", metavar="ENV", help="The environments to create") - - create.add_argument( - "--system-site-packages", - default=False, - action="store_true", - dest="system_site_packages", - help="Give the virtual environment access to the " - "system site-packages dir.", - ) - - create.add_argument( - "-p", - "--interpreter", - default=None, - help=textwrap.dedent( - """ - The Python interpreter used to create the virtual environment. - Can be configured via the $VOX_DEFAULT_INTERPRETER environment variable. - """ - ).strip(), + args=("name", "interpreter", "system_site_packages", "activate"), ) from xonsh.platform import ON_WINDOWS @@ -75,181 +68,168 @@ class VoxHandler: "virtual environment (pip is bootstrapped " "by default)", ) - create.add_argument( - "-a", - "--activate", - default=False, - action="store_true", - dest="activate", - help="Activate the newly created virtual environment.", - ) - activate = subparsers.add_parser( - "activate", aliases=["workon", "enter"], help="Activate virtual environment" - ) - activate.add_argument( - "name", - metavar="ENV", - help=( - "The environment to activate. ENV can be " - "either a name from the venvs shown by vox" - "list or the path to an arbitrary venv" - ), - ) - deactivate = subparsers.add_parser( - "deactivate", - aliases=["exit"], - help="Deactivate current virtual environment", - ) - deactivate.add_argument( - "--remove", - dest="remove", - default=False, - action="store_true", - help="Remove the virtual environment after leaving it.", - ) - subparsers.add_parser( - "list", - aliases=["ls"], - help=("List environments available in " "$VIRTUALENV_HOME"), - ) - remove = subparsers.add_parser( - "remove", aliases=["rm", "delete", "del"], help="Remove virtual environment" - ) - remove.add_argument( - "names", - metavar="ENV", - nargs="+", - help=( - "The environments to remove. ENV can be " - "either a name from the venvs shown by vox" - "list or the path to an arbitrary venv" - ), - ) - subparsers.add_parser("help", help="Show this help message") + parser.add_command(self.activate, aliases=["workon", "enter"]) + parser.add_command(self.deactivate, aliases=["exit"]) + parser.add_command(self.list, aliases=["ls"]) + parser.add_command(self.remove, aliases=["rm", "delete", "del"]) return parser - parser = lazyasd.LazyObject(parser, locals(), "parser") + def new( + self, + name: xcli.Annotated[str, xcli.Arg(metavar="ENV")], + interpreter: xcli.Annotated[ + str, + xcli.Arg("-p", "--interpreter", completer=py_interpreter_path_completer), + ] = None, + system_site_packages: xcli.Annotated[ + bool, + xcli.Arg("--system-site-packages", "--ssp", action="store_true"), + ] = False, + symlinks: bool = False, + with_pip: bool = True, + activate: xcli.Annotated[ + bool, + xcli.Arg("-a", "--activate", action="store_true"), + ] = False, + ): + """Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``. - aliases = { - "create": "new", - "workon": "activate", - "enter": "activate", - "exit": "deactivate", - "ls": "list", - "rm": "remove", - "delete": "remove", - "del": "remove", - } - - def __init__(self): - self.vox = voxapi.Vox() - - def __call__(self, args, stdin=None): - """Call the right handler method for a given command.""" - - args = self.parser.parse_args(args) - cmd = self.aliases.get(args.command, args.command) - if cmd is None: - self.parser.print_usage() - else: - getattr(self, "cmd_" + cmd)(args, stdin) - - def cmd_new(self, args, stdin=None): - """Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``.""" + Parameters + ---------- + name : str + Virtual environment name + interpreter: str + Python interpreter used to create the virtual environment. + Can be configured via the $VOX_DEFAULT_INTERPRETER environment variable. + system_site_packages : bool + If True, the system (global) site-packages dir is available to + created environments. + symlinks : bool + If True, attempt to symlink rather than copy files into virtual + environment. + with_pip : bool + If True, ensure pip is installed in the virtual environment. (Default is True) + activate + Activate the newly created virtual environment. + """ print("Creating environment...") self.vox.create( - args.name, - system_site_packages=args.system_site_packages, - symlinks=args.symlinks, - with_pip=args.with_pip, - interpreter=args.interpreter, + name, + system_site_packages=system_site_packages, + symlinks=symlinks, + with_pip=with_pip, + interpreter=interpreter, ) - if args.activate: - self.vox.activate(args.name) - print(f"Environment {args.name!r} created and activated.\n") + if activate: + self.vox.activate(name) + print(f"Environment {name!r} created and activated.\n") else: print( - f'Environment {args.name!r} created. Activate it with "vox activate {args.name}".\n' + f'Environment {name!r} created. Activate it with "vox activate {name}".\n' ) - def cmd_activate(self, args, stdin=None): - """Activate a virtual environment.""" + def activate( + self, + name: xcli.Annotated[ + str, + xcli.Arg(metavar="ENV", nargs="?", completer=venv_names_completer), + ] = None, + ): + """Activate a virtual environment. + + Parameters + ---------- + name + The environment to activate. + ENV can be either a name from the venvs shown by ``vox list`` + or the path to an arbitrary venv + """ + + if name is None: + return self.list() try: - self.vox.activate(args.name) + self.vox.activate(name) except KeyError: - print( - 'This environment doesn\'t exist. Create it with "vox new %s".\n' - % args.name, - file=sys.stderr, + self.parser.error( + f'This environment doesn\'t exist. Create it with "vox new {name}".\n', ) return None else: - print('Activated "%s".\n' % args.name) + print(f'Activated "{name}".\n') - def cmd_deactivate(self, args, stdin=None): - """Deactivate the active virtual environment.""" + def deactivate( + self, + remove: xcli.Annotated[ + bool, + xcli.Arg("--remove", action="store_true"), + ] = False, + ): + """Deactivate the active virtual environment. + + Parameters + ---------- + remove + Remove the virtual environment after leaving it. + """ if self.vox.active() is None: - print( + self.parser.error( 'No environment currently active. Activate one with "vox activate".\n', - file=sys.stderr, ) - return None env_name = self.vox.deactivate() - if args.remove: + if remove: del self.vox[env_name] print(f'Environment "{env_name}" deactivated and removed.\n') else: print(f'Environment "{env_name}" deactivated.\n') - def cmd_list(self, args, stdin=None): + def list(self): """List available virtual environments.""" try: envs = sorted(self.vox.keys()) except PermissionError: - print("No permissions on VIRTUALENV_HOME") + self.parser.error("No permissions on VIRTUALENV_HOME") return None if not envs: - print( + self.parser.error( 'No environments available. Create one with "vox new".\n', - file=sys.stderr, ) - return None print("Available environments:") print("\n".join(envs)) - def cmd_remove(self, args, stdin=None): - """Remove virtual environments.""" - for name in args.names: + def remove( + self, + names: xcli.Annotated[ + list, + xcli.Arg(metavar="ENV", nargs="+", completer=venv_names_completer), + ], + ): + """Remove virtual environments. + + Parameters + ---------- + names + The environments to remove. ENV can be either a name from the venvs shown by vox + list or the path to an arbitrary venv + """ + for name in names: try: del self.vox[name] except voxapi.EnvironmentInUse: - print( - 'The "%s" environment is currently active. In order to remove it, deactivate it first with "vox deactivate".\n' - % name, - file=sys.stderr, + self.parser.error( + f'The "{name}" environment is currently active. ' + 'In order to remove it, deactivate it first with "vox deactivate".\n', ) - return except KeyError: - print('"%s" environment doesn\'t exist.\n' % name, file=sys.stderr) - return + self.parser.error(f'"{name}" environment doesn\'t exist.\n') else: - print('Environment "%s" removed.' % name) + print(f'Environment "{name}" removed.') print() - def cmd_help(self, args, stdin=None): - self.parser.print_help() - @classmethod - def handle(cls, args, stdin=None): - """Runs Vox environment manager.""" - vox = cls() - return vox(args, stdin=stdin) - - -aliases["vox"] = VoxHandler.handle +XSH.aliases["vox"] = VoxHandler()