xonsh/xontrib/voxapi.py

451 lines
14 KiB
Python
Raw Normal View History

2016-08-28 13:43:08 -04:00
"""
API for Vox, the Python virtual environment manager for xonsh.
Vox defines several events related to the life cycle of virtual environments:
* ``vox_on_create(env: str) -> None``
* ``vox_on_activate(env: str, path: pathlib.Path) -> None``
* ``vox_on_deactivate(env: str, path: pathlib.Path) -> None``
2016-08-28 13:43:08 -04:00
* ``vox_on_delete(env: str) -> None``
"""
2022-01-31 21:26:34 +05:30
import collections.abc
import logging
import os
import shutil
import subprocess as sp
2022-01-31 21:26:34 +05:30
import sys
2021-12-22 03:20:18 +05:30
import typing
from xonsh.built_ins import XSH
2016-08-28 13:37:23 -04:00
# This is because builtins aren't globally created during testing.
# FIXME: Is there a better way?
from xonsh.events import events
2022-01-31 21:26:34 +05:30
from xonsh.platform import ON_POSIX, ON_WINDOWS
2016-08-28 13:37:23 -04:00
2019-04-26 11:11:11 -04:00
events.doc(
"vox_on_create",
"""
2016-08-28 13:37:23 -04:00
vox_on_create(env: str) -> None
Fired after an environment is created.
2019-04-26 11:11:11 -04:00
""",
)
2016-08-28 13:37:23 -04:00
2019-04-26 11:11:11 -04:00
events.doc(
"vox_on_activate",
"""
vox_on_activate(env: str, path: pathlib.Path) -> None
2016-08-28 13:37:23 -04:00
Fired after an environment is activated.
2019-04-26 11:11:11 -04:00
""",
)
2016-08-28 13:37:23 -04:00
2019-04-26 11:11:11 -04:00
events.doc(
"vox_on_deactivate",
"""
vox_on_deactivate(env: str, path: pathlib.Path) -> None
2016-08-28 13:37:23 -04:00
Fired after an environment is deactivated.
2019-04-26 11:11:11 -04:00
""",
)
2016-08-28 13:37:23 -04:00
2019-04-26 11:11:11 -04:00
events.doc(
"vox_on_delete",
"""
2016-08-28 13:37:23 -04:00
vox_on_delete(env: str) -> None
Fired after an environment is deleted (through vox).
2019-04-26 11:11:11 -04:00
""",
)
2016-08-28 13:37:23 -04:00
2016-08-04 22:28:21 -04:00
2021-12-22 03:20:18 +05:30
class VirtualEnvironment(typing.NamedTuple):
env: str
bin: str
lib: str
inc: str
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:
2019-04-26 11:11:11 -04:00
return "Scripts", "Lib", "Include"
elif ON_POSIX:
2019-04-26 11:11:11 -04:00
return "bin", "lib", "include"
else:
2019-04-26 11:11:11 -04:00
raise OSError("This OS is not supported.")
2016-09-13 21:01:50 -04:00
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.abspath(env_dir)
if ON_WINDOWS:
2019-04-26 11:11:11 -04:00
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:
2019-04-26 11:11:11 -04:00
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:
2019-04-26 11:11:11 -04:00
raise OSError("This OS is not supported.")
return VirtualEnvironment(env_dir, binname, libpath, incpath)
class EnvironmentInUse(Exception):
2016-08-28 13:43:08 -04:00
"""The given environment is currently activated, and the operation cannot be performed."""
class NoEnvironmentActive(Exception):
2016-08-28 13:43:08 -04:00
"""No environment is currently activated, and the operation cannot be performed."""
class Vox(collections.abc.Mapping):
"""API access to Vox and virtual environments, in a dict-like format.
Makes use of the VirtualEnvironment namedtuple:
1. ``env``: The full path to the environment
2. ``bin``: The full path to the bin/Scripts directory of the environment
"""
def __init__(self, force_removals=False):
if not XSH.env.get("VIRTUALENV_HOME"):
2019-04-26 11:11:11 -04:00
home_path = os.path.expanduser("~")
self.venvdir = os.path.join(home_path, ".virtualenvs")
XSH.env["VIRTUALENV_HOME"] = self.venvdir
else:
self.venvdir = XSH.env["VIRTUALENV_HOME"]
self.force_removals = force_removals
self.sub_dirs = _subdir_names()
def create(
self,
name,
2018-12-21 14:40:31 -08:00
interpreter=None,
system_site_packages=False,
symlinks=False,
with_pip=True,
prompt=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.
2016-07-20 21:31:10 -04:00
system_site_packages : bool
2016-07-21 09:48:59 +02:00
If True, the system (global) site-packages dir is available to
created environments.
2016-07-20 21:31:10 -04:00
symlinks : bool
2016-07-21 09:48:59 +02:00
If True, attempt to symlink rather than copy files into virtual
environment.
2016-07-20 21:31:10 -04:00
with_pip : bool
If True, ensure pip is installed in the virtual environment. (Default is True)
prompt: str
Provides an alternative prompt prefix for this environment.
"""
2021-12-22 03:20:18 +05:30
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):
env_path = os.fspath(name)
2016-12-09 23:14:41 -05:00
else:
env_path = os.path.join(self.venvdir, name)
if not self._check_reserved(env_path):
raise ValueError(
"venv can't contain reserved names ({})".format(
", ".join(self.sub_dirs)
)
)
self._create(
env_path,
interpreter,
system_site_packages,
symlinks,
with_pip,
prompt=prompt,
)
2017-01-14 19:06:49 -05:00
events.vox_on_create.fire(name=name)
2018-12-21 18:39:26 -08:00
def upgrade(self, name, symlinks=False, with_pip=True, interpreter=None):
"""Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``.
2016-07-21 09:48:59 +02:00
WARNING: If a virtual environment was created with symlinks or without PIP, you must
specify these options again on upgrade.
Parameters
----------
name : str
Virtual environment name
interpreter: str
The Python interpreter used to create the virtualenv
2016-07-20 21:31:10 -04:00
symlinks : bool
2016-07-21 09:48:59 +02:00
If True, attempt to symlink rather than copy files into virtual
environment.
2016-07-20 21:31:10 -04:00
with_pip : bool
If True, ensure pip is installed in the virtual environment.
"""
2021-12-22 03:20:18 +05:30
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.
2021-12-22 03:20:18 +05:30
venv = self[name]
cfgfile = os.path.join(venv.env, "pyvenv.cfg")
cfgops = {}
with open(cfgfile) as cfgfile:
for line in cfgfile:
line = line.strip()
if "=" not in line:
2016-07-21 09:48:59 +02:00
continue
k, v = line.split("=", 1)
cfgops[k.strip()] = v.strip()
flags = {
"system_site_packages": cfgops["include-system-site-packages"] == "true",
"symlinks": symlinks,
"with_pip": with_pip,
}
prompt = cfgops.get("prompt")
if prompt:
flags["prompt"] = prompt.lstrip("'\"").rstrip("'\"")
# END things we shouldn't be doing.
# Ok, do what we came here to do.
2021-12-22 03:20:18 +05:30
self._create(venv.env, interpreter, upgrade=True, **flags)
return venv
@staticmethod
def _create(
env_path,
interpreter,
system_site_packages=False,
symlinks=False,
with_pip=True,
upgrade=False,
prompt=None,
):
2018-12-21 14:40:31 -08:00
version_output = sp.check_output(
2021-12-22 03:20:18 +05:30
[interpreter, "--version"], stderr=sp.STDOUT, text=True
)
2018-12-21 14:40:31 -08:00
interpreter_major_version = int(version_output.split()[-1].split(".")[0])
module = "venv" if interpreter_major_version >= 3 else "virtualenv"
system_site_packages = "--system-site-packages" if system_site_packages else ""
symlinks = "--symlinks" if symlinks and interpreter_major_version >= 3 else ""
with_pip = "" if with_pip else "--without-pip"
upgrade = "--upgrade" if upgrade else ""
cmd = [
interpreter,
"-m",
module,
env_path,
system_site_packages,
symlinks,
with_pip,
upgrade,
]
if prompt and module == "venv":
cmd.extend(["--prompt", prompt])
cmd = [arg for arg in cmd if arg] # remove empty args
logging.debug(cmd)
sp.check_call(cmd)
def _check_reserved(self, name):
2019-04-26 11:11:11 -04:00
return (
os.path.basename(name) not in self.sub_dirs
2019-04-26 11:11:11 -04:00
) # FIXME: Check the middle components, too
2021-12-22 03:20:18 +05:30
def __getitem__(self, name) -> "VirtualEnvironment":
"""Get information about a virtual environment.
Parameters
----------
name : str or Ellipsis
Virtual environment name or absolute path. If ... is given, return
the current one (throws a KeyError if there isn't one).
"""
if name is ...:
env = XSH.env
env_paths = [env["VIRTUAL_ENV"]]
elif isinstance(name, os.PathLike):
env_paths = [os.fspath(name)]
else:
if not self._check_reserved(name):
# Don't allow a venv that could be a venv special dir
raise KeyError()
env_paths = []
if os.path.isdir(name):
env_paths += [name]
env_paths += [os.path.join(self.venvdir, name)]
for ep in env_paths:
ve = _mkvenv(ep)
# 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(ve.bin):
continue
return ve
else:
raise KeyError()
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 get_binary_path(self, binary: str, *dirs: str):
bin_, _, _ = self.sub_dirs
python_exec = binary
if ON_WINDOWS and not python_exec.endswith(".exe"):
python_exec += ".exe"
return os.path.join(*dirs, bin_, python_exec)
def __iter__(self):
"""List available virtual environments found in $VIRTUALENV_HOME."""
for dirpath, dirnames, _ in os.walk(self.venvdir):
python_exec = self.get_binary_path("python", dirpath)
if os.access(python_exec, os.X_OK):
2019-04-26 11:11:11 -04:00
yield dirpath[len(self.venvdir) + 1 :] # +1 is to remove the separator
2018-08-25 11:15:32 +05:30
dirnames.clear()
def __len__(self):
"""Counts known virtual environments, using the same rules as iter()."""
line = 0
for _ in self:
line += 1
return line
def active(self):
"""Get the name of the active virtual environment.
You can use this as a key to get further information.
Returns None if no environment is active.
"""
env = XSH.env
if "VIRTUAL_ENV" not in env:
return
env_path = env["VIRTUAL_ENV"]
if env_path.startswith(self.venvdir):
2019-04-26 11:11:11 -04:00
name = env_path[len(self.venvdir) :]
if name[0] in "/\\":
name = name[1:]
return name
else:
return env_path
def activate(self, name):
"""
Activate a virtual environment.
Parameters
----------
name : str
Virtual environment name or absolute path.
"""
env = XSH.env
ve = self[name]
if "VIRTUAL_ENV" in env:
self.deactivate()
2019-04-26 11:11:11 -04:00
type(self).oldvars = {"PATH": list(env["PATH"])}
env["PATH"].insert(0, ve.bin)
2019-04-26 11:11:11 -04:00
env["VIRTUAL_ENV"] = ve.env
if "PYTHONHOME" in env:
type(self).oldvars["PYTHONHOME"] = env.pop("PYTHONHOME")
events.vox_on_activate.fire(name=name, path=ve.env)
2016-08-28 13:37:23 -04:00
def deactivate(self):
"""
Deactivate the active virtual environment. Returns its name.
"""
env = XSH.env
if "VIRTUAL_ENV" not in env:
2019-04-26 11:11:11 -04:00
raise NoEnvironmentActive("No environment currently active.")
env_name = self.active()
2019-04-26 11:11:11 -04:00
if hasattr(type(self), "oldvars"):
for k, v in type(self).oldvars.items():
env[k] = v
del type(self).oldvars
del env["VIRTUAL_ENV"]
events.vox_on_deactivate.fire(name=env_name, path=self[env_name].env)
return env_name
def __delitem__(self, name):
"""
Permanently deletes a virtual environment.
Parameters
----------
name : str
Virtual environment name or absolute path.
"""
env_path = self[name].env
try:
if self[...].env == env_path:
2019-04-26 11:11:11 -04:00
raise EnvironmentInUse(
'The "%s" environment is currently active.' % name
)
except KeyError:
# No current venv, ... fails
pass
env_path = os.path.abspath(env_path)
if not self.force_removals:
print(f"The directory {env_path}")
print("and all of its content will be deleted.")
answer = input("Do you want to continue? [Y/n]")
if "n" in answer:
return
shutil.rmtree(env_path)
2016-08-28 13:37:23 -04:00
2017-01-14 19:06:49 -05:00
events.vox_on_delete.fire(name=name)
2018-12-21 14:40:31 -08:00
def _get_vox_default_interpreter():
"""Return the interpreter set by the $VOX_DEFAULT_INTERPRETER if set else sys.executable"""
update test xsh usage (#4581) * todo * test: remove usage of DummyEnv and setting .env attribute on xession fixture one step closer to making too much of tweaking to xession during tests * test: fix tests vox and gitstatus-prompt * docs: update test-fixture usage * fix: import flake8 error * test: remove direct access to XSH in tests * test: remove usage of XSH in test files * todo * test: use tmp-dir to create stubs * refactor: use fixture factory to mock out XonshSession * refactor: remove direct access of XSH from functions * refactor: remove direct access of XSH from functions * fix: qa checks * refactor: rename variables to match their values * test: update failing tests because it had no PATH set previously * fix: remove builtins usage from pyghooks.py * style: * refactor: update tests to use fixtures * fix: env varialbe is setup per function some tests accidentally update the env variables and that is leaking into next tests * fix: failing vox tests * test: set commands_cache per test * test: fix failing tests * fix: failing tests on linux ptk-highlight * fix: failing tests on Windows cwd-prompt * test: copy env as to not affect original object * fix: lazily evaluate cmds-cache in pyghooks * test: fix failing tests * fix: qa errors import * test: set commands-cache per test while caching path results * test: speedup test_thread_local_swap * fix: failing tests on windows * refactor: Execer doesn't control session * refactor: XSH.unload will take care of reversing builtins attrs set * test: use env.update over monkeypatch * Revert "test: use env.update over monkeypatch" This reverts commit 010a5022247a098f1741966b8af1bf758663480e.
2022-01-08 04:03:22 +05:30
default = "python3"
if default in XSH.commands_cache:
2021-12-22 03:20:18 +05:30
default = XSH.commands_cache.locate_binary(default)
else:
default = sys.executable
return XSH.env.get("VOX_DEFAULT_INTERPRETER", default)