Repository: xonsh/xonsh

Issue:
Add `upgrade` to vox
When there's a major version update to Python, virtualenvs need to be updated (possibly depending on how they were created and how Python is compiled). Add an option to do this.

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**


Discussion:
jnoortheen: @anki-code could you review this ?
codecov-commenter: # [Codecov](https://codecov.io/gh/xonsh/xonsh/pull/4610?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) Report
> Merging [#4610](https://codecov.io/gh/xonsh/xonsh/pull/4610?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) (fd316ca) into [main](https://codecov.io/gh/xonsh/xonsh/commit/c524028a78d33bbb7ea7f9cd544c5def4d6e890e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) (c524028) will **decrease** coverage by `0.29%`.
> The diff coverage is `58.69%`.

[![Impacted file tree graph](https://codecov.io/gh/xonsh/xonsh/pull/4610/graphs/tree.svg?width=650&height=150&src=pr&token=GXLt8EOk8Z&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh)](https://codecov.io/gh/xonsh/xonsh/pull/4610?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh)

```diff
@@            Coverage Diff             @@
##             main    #4610      +/-   ##
==========================================
- Coverage   60.47%   60.18%   -0.30%     
==========================================
  Files         133      133              
  Lines       22698    22724      +26     
  Branches     4404     4411       +7     
==========================================
- Hits        13727    13676      -51     
- Misses       7844     7915      +71     
- Partials     1127     1133       +6     
```

| Flag | Coverage Δ | |
|---|---|---|
| linux | `60.18% <58.69%> (+0.05%)` | :arrow_up: |
| macos | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/xonsh/xonsh/pull/4610?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | Coverage Δ | |
|---|---|---|
| [xontrib/voxapi.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9udHJpYi92b3hhcGkucHk=) | `27.36% <38.88%> (+1.25%)` | :arrow_up: |
| [xonsh/history/main.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvaGlzdG9yeS9tYWluLnB5) | `58.10% <50.00%> (-3.61%)` | :arrow_down: |
| [xontrib/vox.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9udHJpYi92b3gucHk=) | `30.95% <56.25%> (+0.64%)` | :arrow_up: |
| [xonsh/cli\_utils.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvY2xpX3V0aWxzLnB5) | `81.68% <100.00%> (+0.21%)` | :arrow_up: |
| [xonsh/commands\_cache.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvY29tbWFuZHNfY2FjaGUucHk=) | `75.43% <100.00%> (ø)` | |
| [xonsh/macutils.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvbWFjdXRpbHMucHk=) | `0.00% <0.00%> (-80.00%)` | :arrow_down: |
| [xonsh/lazyimps.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvbGF6eWltcHMucHk=) | `55.73% <0.00%> (-4.92%)` | :arrow_down: |
| [xonsh/xoreutils/uptime.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gveG9yZXV0aWxzL3VwdGltZS5weQ==) | `25.50% <0.00%> (-3.36%)` | :arrow_down: |
| [xonsh/platform.py](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvcGxhdGZvcm0ucHk=) | `44.00% <0.00%> (-2.16%)` | :arrow_down: |
| ... and [11 more](https://codecov.io/gh/xonsh/xonsh/pull/4610/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/xonsh/xonsh/pull/4610?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/xonsh/xonsh/pull/4610?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh). Last update [c524028...fd316ca](https://codecov.io/gh/xonsh/xonsh/pull/4610?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh).


Patch:
diff --git a/.coveragerc b/.coveragerc
index 52bf449b..42f3b220 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -24,6 +24,7 @@ precision = 2
 exclude_lines =
     pragma: no cover
     raise NotImplementedError
+    = __amalgam__
 skip_covered = true
 skip_empty = true
 show_missing = true
diff --git a/news/vox-upgrade.rst b/news/vox-upgrade.rst
new file mode 100644
index 00000000..5640d3d6
--- /dev/null
+++ b/news/vox-upgrade.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* new command ``vox upgrade``
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* <news item>
+
+**Security:**
+
+* <news item>
diff --git a/setup.cfg b/setup.cfg
index 22f934c7..214d6ab5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -122,3 +122,5 @@ ignore_missing_imports = True
 cache_dir = .cache/pytest
 markers =
     news: check changelog unit is valid rst
+testpaths =
+    tests
diff --git a/xonsh/cli_utils.py b/xonsh/cli_utils.py
index 6e9118b3..cad81dbb 100644
--- a/xonsh/cli_utils.py
+++ b/xonsh/cli_utils.py
@@ -214,16 +214,31 @@ def add_args(
         kwargs.setdefault("help", doc.params.get(name))
 
         completer = kwargs.pop("completer", None)
+        cls_inst = getattr(func, "__self__", None)
+
+        if cls_inst and isinstance(cls_inst, ArgParserAlias):
+            flags, kwargs = cls_inst.hook_pre_add_argument(
+                param=name, func=func, flags=flags, kwargs=kwargs
+            )
+
         action = parser.add_argument(*flags, **kwargs)
+
         if completer:
             action.completer = completer  # type: ignore
 
-        cls_inst = getattr(func, "__self__", None)
-        if cls_inst and hasattr(cls_inst, "hook_add_argument"):
-            cls_inst.hook_add_argument(parser=parser, action=action, param=name)
+        if cls_inst and isinstance(cls_inst, ArgParserAlias):
+            cls_inst.hook_post_add_argument(
+                parser=parser, action=action, param=name, func=func
+            )
+
         action.help = action.help or ""
-        if (action.default or action.default is False) and (
-            "%(default)s" not in action.help
+        # Don't show default when
+        # 1. None : No value is given for the option
+        # 2. bool : in case of flags the default is opposite of the flag's meaning
+        if (
+            action.default
+            and (not isinstance(action.default, bool))
+            and ("%(default)s" not in action.help)
         ):
             action.help += os.linesep + " (default: '%(default)s')"
         if action.type and "%(type)s" not in action.help:
@@ -583,7 +598,17 @@ class ArgParserAlias:
             return self.create_parser(**self.kwargs)
         raise NotImplementedError
 
-    def hook_add_argument(self, parser: "ArgParser", action: "ap.Action", param: str):
+    def hook_pre_add_argument(self, param: str, func, flags, kwargs):
+        """Hook to update arguments that are passed to parser.add_argument"""
+        return flags, kwargs
+
+    def hook_post_add_argument(
+        self,
+        parser: "ArgParser|ap.ArgumentParser",
+        action: "ap.Action",
+        param: str,
+        func: "tp.Callable",
+    ):
         """Hook into parser.add_argument step.
 
         Can be used to update action's attributes
diff --git a/xonsh/commands_cache.py b/xonsh/commands_cache.py
index 558c4aba..b7c2e509 100644
--- a/xonsh/commands_cache.py
+++ b/xonsh/commands_cache.py
@@ -69,7 +69,7 @@ class CommandsCache(cabc.Mapping):
     def __len__(self):
         return len(self.all_commands)
 
-    def __getitem__(self, key):
+    def __getitem__(self, key) -> "tuple[str, bool]":
         self.update_cache()
         return self.lazyget(key)
 
@@ -123,7 +123,10 @@ class CommandsCache(cabc.Mapping):
         env = XSH.env
         path = [] if env is None else XSH.env.get("PATH", [])
         path_immut = tuple(CommandsCache.remove_dups(path))
-        alss = getattr(XSH, "aliases", dict())
+
+        # in case it is empty or unset
+        alss = {} if XSH.aliases is None else XSH.aliases
+
         (
             has_path_changed,
             has_alias_changed,
diff --git a/xonsh/history/main.py b/xonsh/history/main.py
index a063ab36..35efe625 100644
--- a/xonsh/history/main.py
+++ b/xonsh/history/main.py
@@ -183,8 +183,8 @@ _XH_HISTORY_SESSIONS = {
 class HistoryAlias(xcli.ArgParserAlias):
     """Try 'history <command> --help' for more info"""
 
-    def hook_add_argument(self, parser, action, param):
-        if any(map(lambda x: parser.prog.endswith(x), ("show", "transfer"))):
+    def hook_post_add_argument(self, action, param, func, **_):
+        if func.__name__ in {"show", "transfer"}:
             if param in {"session", "source", "target"}:
                 action.choices = tuple(_XH_HISTORY_SESSIONS)
 
diff --git a/xonsh/tools.py b/xonsh/tools.py
index 1a097ede..3a739c90 100644
--- a/xonsh/tools.py
+++ b/xonsh/tools.py
@@ -2643,11 +2643,11 @@ def _deprecated_message_suffix(deprecated_in, removed_in):
 
 
 def _deprecated_error_on_expiration(name, removed_in):
-    from distutils.version import LooseVersion
+    from packaging.version import Version
 
     if not removed_in:
         return
-    elif LooseVersion(__version__) >= LooseVersion(removed_in):
+    elif Version(__version__) >= Version(removed_in):
         raise AssertionError(f"{name} has passed its version {removed_in} expiry date!")
 
 
diff --git a/xontrib/vox.py b/xontrib/vox.py
index 059763e6..05cf202b 100644
--- a/xontrib/vox.py
+++ b/xontrib/vox.py
@@ -45,31 +45,7 @@ class VoxHandler(xcli.ArgParserAlias):
         self.vox = voxapi.Vox()
         parser = self.create_parser(prog="vox")
 
-        create = parser.add_command(
-            self.new,
-            aliases=["create"],
-        )
-
-        group = create.add_mutually_exclusive_group()
-        group.add_argument(
-            "--symlinks",
-            default=not ON_WINDOWS,
-            action="store_true",
-            dest="_symlinks",
-            help="Try to use symlinks rather than copies, "
-            "when symlinks are not the default for "
-            "the platform.",
-        )
-        group.add_argument(
-            "--copies",
-            default=ON_WINDOWS,
-            action="store_false",
-            dest="_symlinks",
-            help="Try to use copies rather than symlinks, "
-            "even when symlinks are the default for "
-            "the platform.",
-        )
-
+        parser.add_command(self.new, aliases=["create"])
         parser.add_command(self.activate, aliases=["workon", "enter"])
         parser.add_command(self.deactivate, aliases=["exit"])
         parser.add_command(self.list, aliases=["ls"])
@@ -81,17 +57,30 @@ class VoxHandler(xcli.ArgParserAlias):
         parser.add_command(self.wipe)
         parser.add_command(self.project_set)
         parser.add_command(self.project_get)
+        parser.add_command(self.upgrade)
 
         return parser
 
+    def hook_pre_add_argument(self, param: str, func, flags, kwargs):
+        if func.__name__ in {"new", "upgrade"}:
+            if ON_WINDOWS and param == "symlinks":
+                # copies by default on windows
+                kwargs["default"] = False
+                kwargs["action"] = "store_true"
+                kwargs["help"] = "Try to use symlinks rather than copies"
+                flags = ["--symlinks"]
+        return flags, kwargs
+
+    def hook_post_add_argument(self, action, param: str, **_):
+        if param == "interpreter":
+            action.completer = py_interpreter_path_completer
+
     def new(
         self,
         name: xcli.Annotated[str, xcli.Arg(metavar="ENV")],
-        interpreter: xcli.Annotated[
-            str, xcli.Arg(completer=py_interpreter_path_completer)
-        ] = None,
+        interpreter: "str|None" = None,
         system_site_packages=False,
-        _symlinks=False,
+        symlinks=True,
         without_pip=False,
         activate=False,
         temporary=False,
@@ -111,9 +100,8 @@ class VoxHandler(xcli.ArgParserAlias):
         system_site_packages : --system-site-packages, --ssp
             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.
+        symlinks : --copies
+            Try to use copies rather than symlinks.
         without_pip : --without-pip, --wp
             Skips installing or upgrading pip in the virtual environment
         activate : -a, --activate
@@ -137,7 +125,7 @@ class VoxHandler(xcli.ArgParserAlias):
         self.vox.create(
             name,
             system_site_packages=system_site_packages,
-            symlinks=_symlinks,
+            symlinks=symlinks,
             with_pip=(not without_pip),
             interpreter=interpreter,
         )
@@ -449,7 +437,7 @@ class VoxHandler(xcli.ArgParserAlias):
         Parameters
         ----------
         venv
-            name of the venv
+            name of the venv. Defaults to currently active venv
         """
         env_dir = self._get_env_dir(venv)
         pip_bin = self.vox.get_binary_path("pip", env_dir)
@@ -478,5 +466,35 @@ class VoxHandler(xcli.ArgParserAlias):
         """
         print(self.vox[venv or ...])
 
+    def upgrade(
+        self,
+        name: _venv_option = None,
+        interpreter: "str|None" = None,
+        symlinks=True,
+        with_pip=False,
+    ):
+        """Upgrade the environment directory to use this version
+        of Python, assuming Python has been upgraded in-place.
+
+        WARNING: If a virtual environment was created with symlinks or without PIP, you must
+        specify these options again on upgrade.
+
+        Parameters
+        ----------
+        name
+            Name or the path to the virtual environment
+        interpreter: -p, --interpreter
+            Python interpreter used to create the virtual environment.
+            Can be configured via the $VOX_DEFAULT_INTERPRETER environment variable.
+        symlinks : --copies
+            Try to use copies rather than symlinks.
+        with_pip : --without-pip, --wp
+            Skips installing or upgrading pip in the virtual environment
+        """
+        venv = self.vox.upgrade(
+            name or ..., symlinks=symlinks, with_pip=with_pip, interpreter=interpreter
+        )
+        print(venv)
+
 
 XSH.aliases["vox"] = VoxHandler()
diff --git a/xontrib/voxapi.py b/xontrib/voxapi.py
index dc86a1d7..d6bd7f1a 100644
--- a/xontrib/voxapi.py
+++ b/xontrib/voxapi.py
@@ -14,6 +14,7 @@ import shutil
 import logging
 import collections.abc
 import subprocess as sp
+import typing
 
 from xonsh.built_ins import XSH
 from xonsh.platform import ON_POSIX, ON_WINDOWS
@@ -61,9 +62,11 @@ Fired after an environment is deleted (through vox).
 )
 
 
-VirtualEnvironment = collections.namedtuple(
-    "VirtualEnvironment", ["env", "bin", "lib", "inc"]
-)
+class VirtualEnvironment(typing.NamedTuple):
+    env: str
+    bin: str
+    lib: str
+    inc: str
 
 
 def _subdir_names():
@@ -157,9 +160,11 @@ class Vox(collections.abc.Mapping):
         with_pip : bool
             If True, ensure pip is installed in the virtual environment. (Default is True)
         """
-        interpreter = (
-            _get_vox_default_interpreter() if interpreter is None else interpreter
-        )
+
+        if interpreter is None:
+            interpreter = _get_vox_default_interpreter()
+            print(f"Using Interpreter: {interpreter}")
+
         # NOTE: clear=True is the same as delete then create.
         # NOTE: upgrade=True is its own method
         if isinstance(name, os.PathLike):
@@ -194,13 +199,15 @@ class Vox(collections.abc.Mapping):
         with_pip : bool
             If True, ensure pip is installed in the virtual environment.
         """
-        interpreter = (
-            _get_vox_default_interpreter() if interpreter is None else interpreter
-        )
+
+        if interpreter is None:
+            interpreter = _get_vox_default_interpreter()
+            print(f"Using Interpreter: {interpreter}")
+
         # venv doesn't reload this, so we have to do it ourselves.
         # Is there a bug for this in Python? There should be.
-        env_path, bin_path = self[name]
-        cfgfile = os.path.join(env_path, "pyvenv.cfg")
+        venv = self[name]
+        cfgfile = os.path.join(venv.env, "pyvenv.cfg")
         cfgops = {}
         with open(cfgfile) as cfgfile:
             for line in cfgfile:
@@ -217,7 +224,8 @@ class Vox(collections.abc.Mapping):
         # END things we shouldn't be doing.
 
         # Ok, do what we came here to do.
-        self._create(env_path, interpreter, upgrade=True, **flags)
+        self._create(venv.env, interpreter, upgrade=True, **flags)
+        return venv
 
     @staticmethod
     def _create(
@@ -229,7 +237,7 @@ class Vox(collections.abc.Mapping):
         upgrade=False,
     ):
         version_output = sp.check_output(
-            [interpreter, "--version"], stderr=sp.STDOUT, universal_newlines=True
+            [interpreter, "--version"], stderr=sp.STDOUT, text=True
         )
 
         interpreter_major_version = int(version_output.split()[-1].split(".")[0])
@@ -263,7 +271,7 @@ class Vox(collections.abc.Mapping):
             os.path.basename(name) not in self.sub_dirs
         )  # FIXME: Check the middle components, too
 
-    def __getitem__(self, name):
+    def __getitem__(self, name) -> "VirtualEnvironment":
         """Get information about a virtual environment.
 
         Parameters
@@ -428,7 +436,7 @@ def _get_vox_default_interpreter():
     """Return the interpreter set by the $VOX_DEFAULT_INTERPRETER if set else sys.executable"""
     default = "python"
     if default in XSH.commands_cache:
-        default = XSH.commands_cache[default]
+        default = XSH.commands_cache.locate_binary(default)
     else:
         default = sys.executable
     return XSH.env.get("VOX_DEFAULT_INTERPRETER", default)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 86d1d445..933b3477 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -212,3 +212,24 @@ def pytest_configure(config):
     """Abort test run if --flake8 requested, since it would hang on parser_test.py"""
     if config.getoption("--flake8", ""):
         pytest.exit("pytest-flake8 no longer supported, use flake8 instead.")
+
+
+@pytest.fixture
+def load_xontrib():
+    to_unload = []
+
+    def wrapper(*names: str):
+        from xonsh.xontribs import xontribs_load
+
+        for name in names:
+            module = f"xontrib.{name}"
+            if module not in sys.modules:
+                to_unload.append(module)
+
+            xontribs_load([name])
+        return
+
+    yield wrapper
+
+    for mod in to_unload:
+        del sys.modules[mod]
diff --git a/tests/test_vox.py b/tests/test_vox.py
index e3b68e9b..3353057b 100644
--- a/tests/test_vox.py
+++ b/tests/test_vox.py
@@ -11,11 +11,15 @@ from xontrib.voxapi import Vox
 
 from tools import skip_if_on_conda, skip_if_on_msys
 from xonsh.platform import ON_WINDOWS
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from xontrib.vox import VoxHandler
 
 
 @pytest.fixture
-def load_vox(xession, tmpdir):
-    """load vox into session"""
+def vox(xession, tmpdir, load_xontrib) -> "VoxHandler":
+    """vox Alias function"""
 
     # Set up an isolated venv home
     xession.env["VIRTUALENV_HOME"] = str(tmpdir)
@@ -26,152 +30,114 @@ def load_vox(xession, tmpdir):
     xession.env["PATH"] = []
     xession.env["XONSH_SHOW_TRACEBACK"] = True
 
-    module = "xontrib.vox"
-    has_vox = module in sys.modules
-
-    from xonsh.xontribs import xontribs_load
-
-    xontribs_load(["vox"])
-    yield
-    if not has_vox:
-        del sys.modules["xontrib.vox"]
-
-
-@skip_if_on_msys
-@skip_if_on_conda
-def test_crud(xession, tmpdir):
-    """
-    Creates a virtual environment, gets it, enumerates it, and then deletes it.
-    """
-    xession.env["VIRTUALENV_HOME"] = str(tmpdir)
-
-    last_event = None
+    load_xontrib("vox")
 
-    @xession.builtins.events.vox_on_create
-    def create(name, **_):
-        nonlocal last_event
-        last_event = "create", name
+    yield xession.aliases["vox"]
 
-    @xession.builtins.events.vox_on_delete
-    def delete(name, **_):
-        nonlocal last_event
-        last_event = "delete", name
 
-    vox = Vox(force_removals=True)
-    vox.create("spam")
-    vox.create("spam")
-    assert stat.S_ISDIR(tmpdir.join("spam").stat().mode)
-    assert last_event == ("create", "spam")
+@pytest.fixture
+def record_events(xession):
+    class Listener:
+        def __init__(self):
+            self.last = None
 
-    ve = vox["spam"]
-    assert ve.env == str(tmpdir.join("spam"))
-    assert os.path.isdir(ve.bin)
+        def listener(self, name):
+            def _wrapper(**kwargs):
+                self.last = (name,) + tuple(kwargs.values())
 
-    assert "spam" in vox
-    assert "spam" in list(vox)
+            return _wrapper
 
-    del vox["spam"]
+        def __call__(self, *events: str):
+            for name in events:
+                event = getattr(xession.builtins.events, name)
+                event(self.listener(name))
 
-    assert not tmpdir.join("spam").check()
-    assert last_event == ("delete", "spam")
+    yield Listener()
 
 
 @skip_if_on_msys
 @skip_if_on_conda
-def test_activate(xession, tmpdir):
+def test_vox_flow(xession, vox, record_events, tmpdir):
     """
     Creates a virtual environment, gets it, enumerates it, and then deletes it.
     """
     xession.env["VIRTUALENV_HOME"] = str(tmpdir)
-    # I consider the case that the user doesn't have a PATH set to be unreasonable
-    xession.env.setdefault("PATH", [])
 
-    last_event = None
+    record_events(
+        "vox_on_create", "vox_on_delete", "vox_on_activate", "vox_on_deactivate"
+    )
 
-    @xession.builtins.events.vox_on_activate
-    def activate(name, **_):
-        nonlocal last_event
-        last_event = "activate", name
+    vox(["create", "spam"])
+    assert stat.S_ISDIR(tmpdir.join("spam").stat().mode)
+    assert record_events.last == ("vox_on_create", "spam")
 
-    @xession.builtins.events.vox_on_deactivate
-    def deactivate(name, **_):
-        nonlocal last_event
-        last_event = "deactivate", name
+    ve = vox.vox["spam"]
+    assert ve.env == str(tmpdir.join("spam"))
+    assert os.path.isdir(ve.bin)
 
-    vox = Vox()
-    vox.create("spam")
-    vox.activate("spam")
-    assert xession.env["VIRTUAL_ENV"] == vox["spam"].env
-    assert last_event == ("activate", "spam")
+    assert "spam" in vox.vox
+    assert "spam" in list(vox.vox)
+
+    # activate/deactivate
+    vox(["activate", "spam"])
+    assert xession.env["VIRTUAL_ENV"] == vox.vox["spam"].env
+    assert record_events.last == ("vox_on_activate", "spam", str(ve.env))
     vox.deactivate()
     assert "VIRTUAL_ENV" not in xession.env
-    assert last_event == ("deactivate", "spam")
+    assert record_events.last == ("vox_on_deactivate", "spam", str(ve.env))
+
+    # removal
+    vox(["rm", "spam", "--force"])
+    assert not tmpdir.join("spam").check()
+    assert record_events.last == ("vox_on_delete", "spam")
 
 
 @skip_if_on_msys
 @skip_if_on_conda
-def test_activate_non_vox_venv(xession, tmpdir):
+def test_activate_non_vox_venv(xession, vox, record_events, tmpdir):
     """
     Create a virtual environment using Python's built-in venv module
     (not in VIRTUALENV_HOME) and verify that vox can activate it correctly.
     """
     xession.env.setdefault("PATH", [])
 
-    last_event = None
-
-    @xession.builtins.events.vox_on_activate
-    def activate(name, path, **_):
-        nonlocal last_event
-        last_event = "activate", name, path
-
-    @xession.builtins.events.vox_on_deactivate
-    def deactivate(name, path, **_):
-        nonlocal last_event
-        last_event = "deactivate", name, path
+    record_events("vox_on_activate", "vox_on_deactivate")
 
     with tmpdir.as_cwd():
         venv_dirname = "venv"
         sp.run([sys.executable, "-m", "venv", venv_dirname])
-        vox = Vox()
-        vox.activate(venv_dirname)
-        vxv = vox[venv_dirname]
+        vox(["activate", venv_dirname])
+        vxv = vox.vox[venv_dirname]
 
     env = xession.env
     assert os.path.isabs(vxv.bin)
     assert env["PATH"][0] == vxv.bin
     assert os.path.isabs(vxv.env)
     assert env["VIRTUAL_ENV"] == vxv.env
-    assert last_event == (
-        "activate",
+    assert record_events.last == (
+        "vox_on_activate",
         venv_dirname,
-        str(pathlib.Path(str(tmpdir)) / "venv"),
+        str(pathlib.Path(str(tmpdir)) / venv_dirname),
     )
 
-    vox.deactivate()
+    vox(["deactivate"])
     assert not env["PATH"]
     assert "VIRTUAL_ENV" not in env
-    assert last_event == (
-        "deactivate",
-        tmpdir.join(venv_dirname),
-        str(pathlib.Path(str(tmpdir)) / "venv"),
+    assert record_events.last == (
+        "vox_on_deactivate",
+        venv_dirname,
+        str(pathlib.Path(str(tmpdir)) / venv_dirname),
     )
 
 
 @skip_if_on_msys
 @skip_if_on_conda
-def test_path(xession, tmpdir):
+def test_path(xession, vox, tmpdir, a_venv):
     """
     Test to make sure Vox properly activates and deactivates by examining $PATH
     """
-    xession.env["VIRTUALENV_HOME"] = str(tmpdir)
-    # I consider the case that the user doesn't have a PATH set to be unreasonable
-    xession.env.setdefault("PATH", [])
-
     oldpath = list(xession.env["PATH"])
-    vox = Vox()
-    vox.create("eggs")
-
-    vox.activate("eggs")
+    vox(["activate", a_venv.basename])
 
     assert oldpath != xession.env["PATH"]
 
@@ -250,41 +216,35 @@ def test_reserved_names(xession, tmpdir):
             vox.create("spameggs/bin")
 
 
+@pytest.mark.parametrize("registered", [False, True])
 @skip_if_on_msys
 @skip_if_on_conda
-def test_autovox(xession, tmpdir, load_vox):
+def test_autovox(xession, tmpdir, vox, a_venv, load_xontrib, registered):
     """
     Tests that autovox works
     """
-    import importlib
-    import xonsh.dirstack
+    from xonsh.dirstack import pushd, popd
 
     # Makes sure that event handlers are registered
-    import xontrib.autovox
+    load_xontrib("autovox")
 
-    importlib.reload(xontrib.autovox)
+    env_name = a_venv.basename
+    env_path = str(a_venv)
 
-    @xession.builtins.events.autovox_policy
-    def policy(path, **_):
-        print("Checking", repr(path), vox.active())
-        if str(path) == str(tmpdir):
-            return "myenv"
+    # init properly
+    assert vox.parser
 
-    vox = Vox()
+    def policy(path, **_):
+        if str(path) == env_path:
+            return env_name
 
-    print(xession.env["PWD"])
-    xonsh.dirstack.pushd([str(tmpdir)])
-    print(xession.env["PWD"])
-    assert vox.active() is None
-    xonsh.dirstack.popd([])
-    print(xession.env["PWD"])
+    if registered:
+        xession.builtins.events.autovox_policy(policy)
 
-    vox.create("myenv")
-    xonsh.dirstack.pushd([str(tmpdir)])
-    print(xession.env["PWD"])
-    assert vox.active() == "myenv"
-    xonsh.dirstack.popd([])
-    print(xession.env["PWD"])
+    pushd([env_path])
+    value = env_name if registered else None
+    assert vox.vox.active() == value
+    popd([])
 
 
 @pytest.fixture
@@ -295,14 +255,15 @@ def venv_home(tmpdir):
 
 @pytest.fixture
 def venvs(venv_home):
-    """create bin paths in the tmpdir"""
+    """Create virtualenv with names venv0, venv1"""
     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)
+        env_path = venv_home / f"venv{idx}"
+        bin_path = env_path / "bin"
+        paths.append(env_path)
 
         (bin_path / "python").write("", ensure=True)
         (bin_path / "python.exe").write("", ensure=True)
@@ -314,7 +275,12 @@ def venvs(venv_home):
 
 
 @pytest.fixture
-def patched_cmd_cache(xession, load_vox, venvs, monkeypatch):
+def a_venv(venvs):
+    return venvs[0]
+
+
+@pytest.fixture
+def patched_cmd_cache(xession, vox, venvs, monkeypatch):
     cc = xession.commands_cache
 
     def no_change(self, *_):
@@ -337,14 +303,19 @@ _HELP_OPTS = {
     "--help",
 }
 _PY_BINS = {"/bin/python2", "/bin/python3"}
+
 _VOX_NEW_OPTS = {
-    "--copies",
     "--ssp",
-    "--symlinks",
     "--system-site-packages",
     "--without-pip",
 }.union(_HELP_OPTS)
 _VOX_NEW_EXP = _PY_BINS.union(_VOX_NEW_OPTS)
+
+if ON_WINDOWS:
+    _VOX_NEW_OPTS.add("--symlinks")
+else:
+    _VOX_NEW_OPTS.add("--copies")
+
 _VOX_RM_OPTS = {"-f", "--force"}.union(_HELP_OPTS)
 
 
@@ -374,6 +345,7 @@ _VOX_RM_OPTS = {"-f", "--force"}.union(_HELP_OPTS)
                 "runin-all",
                 "toggle-ssp",
                 "wipe",
+                "upgrade",
             },
             _HELP_OPTS,
         ),