diff --git a/pipenv/core.py b/pipenv/core.py index af4c9761..ffe86cfa 100644 --- a/pipenv/core.py +++ b/pipenv/core.py @@ -458,27 +458,15 @@ def ensure_python(three=None, python=None): abort() else: if (not PIPENV_DONT_USE_PYENV) and (SESSION_IS_INTERACTIVE or PIPENV_YES): - version_map = { - # TODO: Keep this up to date! - # These versions appear incompatible with virtualenv: - # '2.5': '2.5.6', - "2.6": "2.6.9", - "2.7": "2.7.15", - # '3.1': '3.1.5', - # '3.2': '3.2.6', - "3.3": "3.3.7", - "3.4": "3.4.8", - "3.5": "3.5.5", - "3.6": "3.6.6", - "3.7": "3.7.0", - } + from .pyenv import Runner, PyenvError + pyenv = Runner("pyenv") try: - if len(python.split(".")) == 2: - # Find the latest version of Python available. - version = version_map[python] - else: - version = python - except KeyError: + version = pyenv.find_version_to_install(python) + except ValueError: + abort() + except PyenvError as e: + click.echo(u"Something went wrong…") + click.echo(crayons.blue(e.err), err=True) abort() s = "{0} {1} {2}".format( "Would you like us to install", @@ -500,24 +488,17 @@ def ensure_python(three=None, python=None): ) ) with spinner(): - # Install Python. - c = delegator.run( - "pyenv install {0} -s".format(version), - timeout=PIPENV_INSTALL_TIMEOUT, - block=False, - ) - # Wait until the process has finished… - c.block() try: - assert c.return_code == 0 - except AssertionError: + c = pyenv.install(version) + except PyenvError as e: click.echo(u"Something went wrong…") - click.echo(crayons.blue(c.err), err=True) + click.echo(crayons.blue(e.err), err=True) # Print the results, in a beautiful blue… click.echo(crayons.blue(c.out), err=True) # Add new paths to PATH. activate_pyenv() # Find the newly installed Python, hopefully. + version = str(version) path_to_python = find_a_system_python(version) try: assert python_version(path_to_python) == version diff --git a/pipenv/pyenv.py b/pipenv/pyenv.py new file mode 100644 index 00000000..bbfd1a97 --- /dev/null +++ b/pipenv/pyenv.py @@ -0,0 +1,119 @@ +import operator +import re + +from .vendor import attr, delegator + +from .environments import PIPENV_INSTALL_TIMEOUT + + +@attr.s +class Version(object): + + major = attr.ib() + minor = attr.ib() + patch = attr.ib() + + def __str__(self): + parts = [self.major, self.minor] + if self.patch is not None: + parts.append(self.patch) + return '.'.join(str(p) for p in parts) + + @classmethod + def parse(cls, name): + """Parse an X.Y.Z or X.Y string into a version tuple. + """ + match = re.match(r'^(\d+)\.(\d+)(?:\.(\d+))?$', name) + if not match: + raise ValueError('invalid version name {0!r}'.format(name)) + major = int(match.group(1)) + minor = int(match.group(2)) + patch = match.group(3) + if patch is not None: + patch = int(patch) + return cls(major, minor, patch) + + @property + def cmpkey(self): + """Make the version a comparable tuple. + + Some old Python versions does not have a patch part, e.g. 2.7.0 is + named "2.7" in pyenv. Fix that, otherwise `None` will fail to compare + with int. + """ + return (self.major, self.minor, self.patch or 0) + + def matches_minor(self, other): + """Check whether this version matches the other in (major, minor). + """ + return (self.major, self.minor) == (other.major, other.minor) + + +class PyenvError(RuntimeError): + def __init__(self, desc, c): + super(PyenvError, self).__init__(desc) + self.out = c.out + self.err = c.err + + +class Runner(object): + + def __init__(self, pyenv): + self._cmd = pyenv + + def _pyenv(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) + c = delegator.run(args, block=False, timeout=timeout) + c.block() + if c.return_code != 0: + raise PyenvError('faild to run {0}'.format(args), c) + return c + + def iter_installable_versions(self): + """Iterate through CPython versions available for Pipenv to install. + """ + for name in self._pyenv('install', '--list').out.splitlines(): + try: + version = Version.parse(name.strip()) + except ValueError: + continue + yield version + + def find_version_to_install(self, name): + """Find a version in pyenv from the version supplied. + + A ValueError is raised if a matching version cannot be found. + """ + version = Version.parse(name) + if version.patch is not None: + return name + try: + best_match = max(( + inst_version + for inst_version in self.iter_installable_versions() + if inst_version.matches_minor(version) + ), key=operator.attrgetter('cmpkey')) + except ValueError: + raise ValueError( + 'no installable version found for {0!r}'.format(name), + ) + return best_match + + def install(self, version): + """Install the given version with pyenv. + + The version must be a ``Version`` instance representing a version + found in pyenv. + + A ValueError is raised if the given version does not have a match in + pyenv. A PyenvError is raised if the pyenv command fails. + """ + c = self._pyenv( + 'install', '-s', str(version), + timeout=PIPENV_INSTALL_TIMEOUT, + ) + return c