Repository: xonsh/xonsh

Issue:
Vox lists organization directories
When using organized venvs (that is, venv names with slashes in them), `vox list` will show the organizational directory as a venv.

(Also, it doesn't show envrionments under it.)

```
$ vox create spam/eggs
$ vox list
spam
```



Discussion:
Should we even allow names like these?

I would say yes, so that big, complex, multi-part projects can map relatively cleanly to vox venvs.

conda and vox don't get along, so I can't test this -- is there any difference if you first create `spam` and then `spam/test`?  
And what is the desired behavior of the venv name with a slash?  It should create a directory or no?

> is there any difference if you first create spam and then spam/test?

I don't think there is, but it's a case to be careful of. Doesn't help that different platforms use different content names. I feel like this should be discouraged, but it's not technically disallowed.

> And what is the desired behavior of the venv name with a slash? It should create a directory or no?

It does create a directory. `spam/eggs` will create the directories `~/.virtualenvs/spam`, `~/.virtualenvs/spam/eggs`, `~/.virtualenvs/spam/eggs/{bin,lib}`, etc.


Resolving patch:
diff --git a/xontrib/voxapi.py b/xontrib/voxapi.py
--- a/xontrib/voxapi.py
+++ b/xontrib/voxapi.py
@@ -9,12 +9,13 @@
 * ``vox_on_delete(env: str) -> None``
 """
 import os
+import sys
 import venv
 import shutil
 import builtins
 import collections.abc
 
-from xonsh.platform import ON_POSIX, ON_WINDOWS, scandir
+from xonsh.platform import ON_POSIX, ON_WINDOWS
 
 # This is because builtins aren't globally created during testing.
 # FIXME: Is there a better way?
@@ -46,7 +47,45 @@
 """)
 
 
-VirtualEnvironment = collections.namedtuple('VirtualEnvironment', ['env', 'bin'])
+VirtualEnvironment = collections.namedtuple('VirtualEnvironment', ['env', 'bin', 'lib', 'inc'])
+
+
+def _subdir_names():
+    """
+    Gets the names of the special dirs in a venv.
+
+    This is not necessarily exhaustive of all the directories that could be in a venv, and there
+    may additional logic to get to useful places.
+    """
+    if ON_WINDOWS:
+        return 'Scripts', 'Lib', 'Include'
+    elif ON_POSIX:
+        return 'bin', 'lib', 'include'
+    else:
+        raise OSError('This OS is not supported.')
+
+
+def _mkvenv(env_dir):
+    """
+    Constructs a VirtualEnvironment based on the given base path.
+
+    This only cares about the platform. No filesystem calls are made.
+    """
+    env_dir = os.path.normpath(env_dir)
+    if ON_WINDOWS:
+        binname = os.path.join(env_dir, 'Scripts')
+        incpath = os.path.join(env_dir, 'Include')
+        libpath = os.path.join(env_dir, 'Lib', 'site-packages')
+    elif ON_POSIX:
+        binname = os.path.join(env_dir, 'bin')
+        incpath = os.path.join(env_dir, 'include')
+        libpath = os.path.join(env_dir, 'lib',
+                               'python%d.%d' % sys.version_info[:2],
+                               'site-packages')
+    else:
+        raise OSError('This OS is not supported.')
+
+    return VirtualEnvironment(env_dir, binname, libpath, incpath)
 
 
 class EnvironmentInUse(Exception):
@@ -138,15 +177,6 @@ def upgrade(self, name, *, symlinks=False, with_pip=True):
         # Ok, do what we came here to do.
         venv.create(env_path, upgrade=True, **flags)
 
-    @staticmethod
-    def _binname():
-        if ON_WINDOWS:
-            return 'Scripts'
-        elif ON_POSIX:
-            return 'bin'
-        else:
-            raise OSError('This OS is not supported.')
-
     def __getitem__(self, name):
         """Get information about a virtual environment.
 
@@ -162,21 +192,39 @@ def __getitem__(self, name):
             env_path = name
         else:
             env_path = os.path.join(self.venvdir, name)
-        bin_dir = self._binname()
-        bin_path = os.path.join(env_path, bin_dir)
+
+        ve = _mkvenv(env_path)
+
+        if os.path.basename(ve.env) in _subdir_names():  # FIXME: Check the inner components, too
+            # Don't allow a venv that could be a venv special dir
+            raise KeyError()
+
         # Actually check if this is an actual venv or just a organizational directory
         # eg, if 'spam/eggs' is a venv, reject 'spam'
-        if not os.path.exists(bin_path):
+        if not os.path.exists(ve.bin):
             raise KeyError()
-        return VirtualEnvironment(env_path, bin_path)
+        return ve
+
+    def __contains__(self, name):
+        # For some reason, MutableMapping seems to do this against iter, which is just silly.
+        try:
+            self[name]
+        except KeyError:
+            return False
+        else:
+            return True
 
     def __iter__(self):
         """List available virtual environments found in $VIRTUALENV_HOME.
         """
-        # FIXME: Handle subdirs--this won't discover eg ``spam/eggs``
-        for x in scandir(self.venvdir):
-            if x.is_dir():
-                yield x.name
+        bin_, lib, inc = _subdir_names()
+        for dirpath, dirnames, _ in os.walk(self.venvdir):
+            if bin_ in dirnames and lib in dirnames:
+                yield dirpath[len(self.venvdir)+1:]  # +1 is to remove the separator
+                # Don't recurse in to the special dirs
+                dirnames.remove(bin_)
+                dirnames.remove(lib)  # This one in particular is likely to be quite large.
+                dirnames.remove(inc)
 
     def __len__(self):
         """Counts known virtual environments, using the same rules as iter().
@@ -214,13 +262,13 @@ def activate(self, name):
             Virtual environment name or absolute path.
         """
         env = builtins.__xonsh_env__
-        env_path, bin_path = self[name]
+        ve = self[name]
         if 'VIRTUAL_ENV' in env:
             self.deactivate()
 
         type(self).oldvars = {'PATH': list(env['PATH'])}
-        env['PATH'].insert(0, bin_path)
-        env['VIRTUAL_ENV'] = env_path
+        env['PATH'].insert(0, ve.bin)
+        env['VIRTUAL_ENV'] = ve.env
         if 'PYTHONHOME' in env:
             type(self).oldvars['PYTHONHOME'] = env.pop('PYTHONHOME')
 
@@ -234,7 +282,6 @@ def deactivate(self):
         if 'VIRTUAL_ENV' not in env:
             raise NoEnvironmentActive('No environment currently active.')
 
-        env_path, bin_path = self[...]
         env_name = self.active()
 
         if hasattr(type(self), 'oldvars'):


Test patch:
diff --git a/tests/test_vox.py b/tests/test_vox.py
--- a/tests/test_vox.py
+++ b/tests/test_vox.py
@@ -32,11 +32,12 @@ def delete(name):
     assert stat.S_ISDIR(tmpdir.join('spam').stat().mode)
     assert last_event == ('create', 'spam')
 
-    env, bin = vox['spam']
-    assert env == str(tmpdir.join('spam'))
-    assert os.path.isdir(bin)
+    ve = vox['spam']
+    assert ve.env == str(tmpdir.join('spam'))
+    assert os.path.isdir(ve.bin)
 
     assert 'spam' in vox
+    assert 'spam' in list(vox)
 
     del vox['spam']
 
@@ -95,3 +96,28 @@ def test_path(xonsh_builtins, tmpdir):
     vox.deactivate()
     
     assert oldpath == xonsh_builtins.__xonsh_env__['PATH']
+
+@skip_if_on_conda
+def test_crud_subdir(xonsh_builtins, tmpdir):
+    """
+    Creates a virtual environment, gets it, enumerates it, and then deletes it.
+    """
+    xonsh_builtins.__xonsh_env__['VIRTUALENV_HOME'] = str(tmpdir)
+
+    vox = Vox()
+    vox.create('spam/eggs')
+    assert stat.S_ISDIR(tmpdir.join('spam', 'eggs').stat().mode)
+
+    ve = vox['spam/eggs']
+    assert ve.env == str(tmpdir.join('spam', 'eggs'))
+    assert os.path.isdir(ve.bin)
+
+    assert 'spam/eggs' in vox
+    assert 'spam' not in vox
+
+    #assert 'spam/eggs' in list(vox)  # This is NOT true on Windows
+    assert 'spam' not in list(vox)
+
+    del vox['spam/eggs']
+
+    assert not tmpdir.join('spam', 'eggs').check()