Repository: xonsh/xonsh

Issue:
Automatic Vox Policies?
One of the re-occuring feature requests for vox is automatic de/activation of venvs, based on various polices (avox is based on a concept of project directory, I think someone did it based on `.venv`, just saw one based on `pyenv-virtualenv` (which is based on the presence of a `.python-version` file).

Wondering if we should refactor `avox` into a generic framework bundled with xonsh, and have these different polices plug in to it.


Discussion:
Yeah, that seems like a nice approach.

Resolving patch:
diff --git a/xontrib/autovox.py b/xontrib/autovox.py
new file mode 100644
--- /dev/null
+++ b/xontrib/autovox.py
@@ -0,0 +1,101 @@
+"""
+A framework for automatic vox.
+
+This coordinates multiple automatic vox policies and deals with some of the
+mechanics of venv searching and chdir handling.
+
+This provides no interface for end users.
+
+Developers should look at events.autovox_policy
+"""
+import itertools
+from pathlib import Path
+import xontrib.voxapi as voxapi
+import warnings
+
+__all__ = ()
+
+
+_policies = []
+
+events.doc(
+    "autovox_policy",
+    """
+autovox_policy(path: pathlib.Path) -> Union[str, pathlib.Path, None]
+
+Register a policy with autovox.
+
+A policy is a function that takes a Path and returns the venv associated with it,
+if any.
+
+NOTE: The policy should only return a venv for this path exactly, not for
+parent paths. Parent walking is handled by autovox so that all policies can
+be queried at each level.
+""",
+)
+
+
+class MultipleVenvsWarning(RuntimeWarning):
+    pass
+
+
+def get_venv(vox, dirpath):
+    # Search up the directory tree until a venv is found, or none
+    for path in itertools.chain((dirpath,), dirpath.parents):
+        venvs = [
+            vox[p]
+            for p in events.autovox_policy.fire(path=path)
+            if p is not None and p in vox  # Filter out venvs that don't exist
+        ]
+        if len(venvs) == 0:
+            continue
+        else:
+            if len(venvs) > 1:
+                warnings.warn(
+                    MultipleVenvsWarning(
+                        f"Found {len(venvs)} venvs for {path}; using the first"
+                    )
+                )
+            return venvs[0]
+
+
+def check_for_new_venv(curdir):
+    vox = voxapi.Vox()
+    try:
+        oldve = vox[...]
+    except KeyError:
+        oldve = None
+    newve = get_venv(vox, curdir)
+
+    if oldve != newve:
+        if newve is None:
+            vox.deactivate()
+        else:
+            vox.activate(newve.env)
+
+
+# Core mechanism: Check for venv when the current directory changes
+@events.on_chdir
+def cd_handler(newdir, **_):
+    check_for_new_venv(Path(newdir))
+
+
+# Recalculate when venvs are created or destroyed
+
+
+@events.vox_on_create
+def create_handler(**_):
+    check_for_new_venv(Path.cwd())
+
+
+@events.vox_on_destroy
+def destroy_handler(**_):
+    check_for_new_venv(Path.cwd())
+
+
+# Initial activation before first prompt
+
+
+@events.on_post_init
+def load_handler(**_):
+    check_for_new_venv(Path.cwd())


Test patch:
diff --git a/tests/test_vox.py b/tests/test_vox.py
--- a/tests/test_vox.py
+++ b/tests/test_vox.py
@@ -6,7 +6,7 @@
 import pytest
 from xontrib.voxapi import Vox
 
-from tools import skip_if_on_conda, skip_if_on_msys
+from tools import skip_if_on_conda, skip_if_on_msys, skip_if_lt_py36
 from xonsh.platform import ON_WINDOWS
 
 
@@ -159,9 +159,9 @@ def test_crud_path(xonsh_builtins, tmpdir):
 
 @skip_if_on_msys
 @skip_if_on_conda
-def test_crud_subdir(xonsh_builtins, tmpdir):
+def test_reserved_names(xonsh_builtins, tmpdir):
     """
-    Creates a virtual environment, gets it, enumerates it, and then deletes it.
+    Tests that reserved words are disallowed.
     """
     xonsh_builtins.__xonsh__.env["VIRTUALENV_HOME"] = str(tmpdir)
 
@@ -177,3 +177,51 @@ def test_crud_subdir(xonsh_builtins, tmpdir):
             vox.create("spameggs/Scripts")
         else:
             vox.create("spameggs/bin")
+
+
+@skip_if_on_msys
+@skip_if_on_conda
+@skip_if_lt_py36
+def test_autovox(xonsh_builtins, tmpdir):
+    """
+    Tests that autovox works
+    """
+    import importlib
+    from xonsh.lib import subprocess
+    import xonsh.dirstack
+
+    # Set up an isolated venv home
+    xonsh_builtins.__xonsh__.env["VIRTUALENV_HOME"] = str(tmpdir)
+
+    # Makes sure that event handlers are registered
+    import xontrib.autovox
+    importlib.reload(xontrib.autovox)
+
+    # Set up enough environment for xonsh to function
+    xonsh_builtins.__xonsh__.env['PWD'] = os.getcwd()
+    xonsh_builtins.__xonsh__.env['DIRSTACK_SIZE'] = 10
+    xonsh_builtins.__xonsh__.env['PATH'] = []
+
+    xonsh_builtins.__xonsh__.env['XONSH_SHOW_TRACEBACK'] = True
+
+    @xonsh_builtins.events.autovox_policy
+    def policy(path, **_):
+        print("Checking", repr(path), vox.active())
+        if str(path) == str(tmpdir):
+            return "myenv"
+
+    vox = Vox()
+
+    print(xonsh_builtins.__xonsh__.env['PWD'])
+    xonsh.dirstack.pushd([str(tmpdir)])
+    print(xonsh_builtins.__xonsh__.env['PWD'])
+    assert vox.active() is None
+    xonsh.dirstack.popd([])
+    print(xonsh_builtins.__xonsh__.env['PWD'])
+
+    vox.create('myenv')
+    xonsh.dirstack.pushd([str(tmpdir)])
+    print(xonsh_builtins.__xonsh__.env['PWD'])
+    assert vox.active() == 'myenv'
+    xonsh.dirstack.popd([])
+    print(xonsh_builtins.__xonsh__.env['PWD'])
diff --git a/tests/tools.py b/tests/tools.py
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -122,12 +122,17 @@ def detype(self):
         return {k: str(v) for k, v in self._d.items()}
 
     def __getitem__(self, k):
-        return self._d[k]
+        if k is ...:
+            return self
+        else:
+            return self._d[k]
 
     def __setitem__(self, k, v):
+        assert k is not ...
         self._d[k] = v
 
     def __delitem__(self, k):
+        assert k is not ...
         del self._d[k]
 
     def __len__(self):
@@ -156,6 +161,9 @@ def swap(self, other=None, **kwargs):
             else:
                 self[k] = v
 
+    def is_manually_set(self, key):
+        return False
+
 
 #
 # Execer tools