diff --git a/news/4245.feature.rst b/news/4245.feature.rst new file mode 100644 index 00000000..a78e2e66 --- /dev/null +++ b/news/4245.feature.rst @@ -0,0 +1 @@ +Pyenv/asdf can now be used whether or not they are available on PATH. Setting PYENV_ROOT/ASDF_DIR in a Pipenv's .env allows Pipenv to install an interpreter without any shell customizations, so long as pyenv/asdf is installed. diff --git a/pipenv/core.py b/pipenv/core.py index 096fd37e..1ab68bb3 100644 --- a/pipenv/core.py +++ b/pipenv/core.py @@ -164,7 +164,7 @@ def load_dot_env(): err=True, ) dotenv.load_dotenv(dotenv_file, override=True) - + six.moves.reload_module(environments) def add_to_path(p): """Adds a given path to the PATH.""" @@ -352,15 +352,16 @@ def find_a_system_python(line): def ensure_python(three=None, python=None): - # Support for the PIPENV_PYTHON environment variable. - from .environments import PIPENV_PYTHON + # Runtime import is necessary due to the possibility that the environments module may have been reloaded. + from .environments import PIPENV_PYTHON, PIPENV_YES if PIPENV_PYTHON and python is False and three is None: python = PIPENV_PYTHON - def abort(): + def abort(msg=''): click.echo( - "You can specify specific versions of Python with:\n {0}".format( + "{0}\nYou can specify specific versions of Python with:\n{1}".format( + crayons.red(msg), crayons.red( "$ pipenv --python {0}".format( os.sep.join(("path", "to", "python")) @@ -395,21 +396,25 @@ def ensure_python(three=None, python=None): err=True, ) # check for python installers - from .vendor.pythonfinder.environment import PYENV_INSTALLED, ASDF_INSTALLED - from .installers import Pyenv, Asdf, InstallerError + from .installers import Pyenv, Asdf, InstallerError, InstallerNotFound # prefer pyenv if both pyenv and asdf are installed as it's # dedicated to python installs so probably the preferred # method of the user for new python installs. - if PYENV_INSTALLED and not PIPENV_DONT_USE_PYENV: - installer = Pyenv("pyenv") - elif ASDF_INSTALLED and not PIPENV_DONT_USE_ASDF: - installer = Asdf("asdf") - else: - installer = None + installer = None + if not PIPENV_DONT_USE_PYENV: + try: + installer = Pyenv() + except InstallerNotFound: + pass + if installer is None and not PIPENV_DONT_USE_ASDF: + try: + installer = Asdf() + except InstallerNotFound: + pass if not installer: - abort() + abort("Neither 'pyenv' nor 'asdf' could be found to install Python.") else: if SESSION_IS_INTERACTIVE or PIPENV_YES: try: @@ -417,9 +422,7 @@ def ensure_python(three=None, python=None): except ValueError: abort() except InstallerError as e: - click.echo(fix_utf8("Something went wrong…")) - click.echo(crayons.blue(e.err), err=True) - abort() + abort('Something went wrong while installing Python:\n{}'.format(e.err)) s = "{0} {1} {2}".format( "Would you like us to install", crayons.green("CPython {0}".format(version)), @@ -434,7 +437,7 @@ def ensure_python(three=None, python=None): u"{0} {1} {2} {3}{4}".format( crayons.normal(u"Installing", bold=True), crayons.green(u"CPython {0}".format(version), bold=True), - crayons.normal(u"with {0}".format(installer), bold=True), + crayons.normal(u"with {0}".format(installer.cmd), bold=True), crayons.normal(u"(this may take a few minutes)"), crayons.normal(fix_utf8("…"), bold=True), ) diff --git a/pipenv/installers.py b/pipenv/installers.py index cd8e49a2..f16cdacd 100644 --- a/pipenv/installers.py +++ b/pipenv/installers.py @@ -1,8 +1,12 @@ +import os import operator import re +from abc import ABCMeta, abstractmethod + from .environments import PIPENV_INSTALL_TIMEOUT from .vendor import attr, delegator +from .utils import find_windows_executable @attr.s @@ -48,6 +52,10 @@ class Version(object): return (self.major, self.minor) == (other.major, other.minor) +class InstallerNotFound(RuntimeError): + pass + + class InstallerError(RuntimeError): def __init__(self, desc, c): super(InstallerError, self).__init__(desc) @@ -56,29 +64,70 @@ class InstallerError(RuntimeError): class Installer(object): + __metaclass__ = ABCMeta - def __init__(self, cmd): - self._cmd = cmd + def __init__(self): + self.cmd = self._find_installer() + super(Installer, self).__init__() - def __str__(self): - return self._cmd + @abstractmethod + def _find_installer(self): + pass + + @staticmethod + def _find_python_installer_by_name_and_env(name, env_var): + """ + Given a python installer (pyenv or asdf), try to locate the binary for that + installer. + + pyenv/asdf are not always present on PATH. Both installers also support a + custom environment variable (PYENV_ROOT or ASDF_DIR) which alows them to + be installed into a non-default location (the default/suggested source + install location is in ~/.pyenv or ~/.asdf). + + For systems without the installers on PATH, and with a custom location + (e.g. /opt/pyenv), Pipenv can use those installers without modifications to + PATH, if an installer's respective environment variable is present in an + environment's .env file. + + This function searches for installer binaries in the following locations, + by precedence: + 1. On PATH, equivalent to which(1). + 2. In the "bin" subdirectory of PYENV_ROOT or ASDF_DIR, depending on the + installer. + 3. In ~/.pyenv/bin or ~/.asdf/bin, depending on the installer. + """ + for candidate in ( + # Look for the Python installer using the equivalent of 'which'. On + # Homebrew-installed systems, the env var may not be set, but this + # strategy will work. + find_windows_executable('', name), + # Check for explicitly set install locations (e.g. PYENV_ROOT, ASDF_DIR). + os.path.join(os.path.expanduser(os.getenv(env_var, '/dev/null')), 'bin', name), + # Check the pyenv/asdf-recommended from-source install locations + os.path.join(os.path.expanduser('~/.{}'.format(name)), 'bin', name), + ): + if candidate is not None and os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + raise InstallerNotFound() def _run(self, *args, **kwargs): timeout = kwargs.pop('timeout', delegator.TIMEOUT) if kwargs: k = list(kwargs.keys())[0] raise TypeError('unexpected keyword argument {0!r}'.format(k)) - args = (self._cmd,) + tuple(args) + args = (self.cmd,) + tuple(args) c = delegator.run(args, block=False, timeout=timeout) c.block() if c.return_code != 0: - raise InstallerError('faild to run {0}'.format(args), c) + raise InstallerError('failed to run {0}'.format(args), c) return c + @abstractmethod def iter_installable_versions(self): """Iterate through CPython versions available for Pipenv to install. """ - raise NotImplementedError + pass def find_version_to_install(self, name): """Find a version in the installer from the version supplied. @@ -100,6 +149,7 @@ class Installer(object): ) return best_match + @abstractmethod def install(self, version): """Install the given version with runner implementation. @@ -109,11 +159,14 @@ class Installer(object): A ValueError is raised if the given version does not have a match in the runner. A InstallerError is raised if the runner command fails. """ - raise NotImplementedError + pass class Pyenv(Installer): + def _find_installer(self): + return self._find_python_installer_by_name_and_env('pyenv', 'PYENV_ROOT') + def iter_installable_versions(self): """Iterate through CPython versions available for Pipenv to install. """ @@ -140,6 +193,9 @@ class Pyenv(Installer): class Asdf(Installer): + def _find_installer(self): + return self._find_python_installer_by_name_and_env('asdf', 'ASDF_DIR') + def iter_installable_versions(self): """Iterate through CPython versions available for asdf to install. """