diff --git a/docs/python_virtual_environments.rst b/docs/python_virtual_environments.rst index b744c1101..9ff824b3f 100644 --- a/docs/python_virtual_environments.rst +++ b/docs/python_virtual_environments.rst @@ -102,3 +102,19 @@ Simply add the ``'{env_name}'`` variable to your ``$PROMPT``:: Note that you do **not** need to load the ``vox`` xontrib for this to work. For more details see :ref:`customprompt`. + + +Automatically Switching Environments +------------------------------------ + +Automatic environment switching based on the current directory is managed with the ``autovox`` xontrib (``xontrib load autovox``). Third-party xontribs may register various policies for use with autovox. Pick and choose xontribs that implement policies that match your work style. + +Implementing policies is easy! Just register with the ``autovox_policy`` event and return a ``Path`` if there is a matching venv. For example, this policy implements handling if there is a ``.venv`` directory in the project:: + + @events.autovox_policy + def dotvenv_policy(path, **_): + venv = path / '.venv' + if venv.exists(): + return venv + +Note that you should only return if there is an environment for this directory exactly. Scanning parent directories is managed by autovox. You should also make the policy check relatively cheap. (Local IO is ok, but probably shouldn't call out to network services.) diff --git a/news/autovox.rst b/news/autovox.rst new file mode 100644 index 000000000..b7db759c4 --- /dev/null +++ b/news/autovox.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``autovox`` xontrib + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/tests/test_vox.py b/tests/test_vox.py index 9d84f390b..bcb0ee14d 100644 --- a/tests/test_vox.py +++ b/tests/test_vox.py @@ -7,7 +7,7 @@ import pytest import sys 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 @@ -209,9 +209,9 @@ else: @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) @@ -227,3 +227,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 index 1956862e6..77de738e7 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -122,12 +122,17 @@ class DummyEnv(MutableMapping): 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 @@ class DummyEnv(MutableMapping): else: self[k] = v + def is_manually_set(self, key): + return False + # # Execer tools diff --git a/xonsh/xontribs.json b/xonsh/xontribs.json index 02c54da8a..46cf5b16b 100644 --- a/xonsh/xontribs.json +++ b/xonsh/xontribs.json @@ -9,6 +9,11 @@ "url": "https://github.com/sagartewari01/autojump-xonsh", "description": ["autojump support for xonsh"] }, + {"name": "autovox", + "package": "xonsh", + "url": "http://xon.sh", + "description": ["Manages automatic activation of virtual environments."] + }, {"name": "autoxsh", "package": "xonsh-autoxsh", "url": "https://github.com/Granitas/xonsh-autoxsh", diff --git a/xontrib/autovox.py b/xontrib/autovox.py new file mode 100644 index 000000000..2e7621765 --- /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())