234 lines
8.0 KiB
Python
234 lines
8.0 KiB
Python
import operator
|
|
import os
|
|
import re
|
|
import sys
|
|
from abc import ABCMeta, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from pipenv.utils.processes import subprocess_run
|
|
from pipenv.utils.shell import find_windows_executable
|
|
|
|
|
|
@dataclass
|
|
class Version:
|
|
major: int
|
|
minor: int
|
|
patch: Optional[int] = field(default=None)
|
|
|
|
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: str):
|
|
"""Parse an X.Y.Z, X.Y, or pre-release version string into a version tuple."""
|
|
match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?(a|b|rc)?(\d+)?$", name)
|
|
if not match:
|
|
raise ValueError(f"invalid version name {name!r}")
|
|
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=major, minor=minor, patch=patch)
|
|
|
|
@property
|
|
def cmpkey(self):
|
|
"""Make the version a comparable tuple.
|
|
|
|
Some old Python versions do 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: "Version"):
|
|
"""Check whether this version matches the other in (major, minor)."""
|
|
return (self.major, self.minor) == (other.major, other.minor)
|
|
|
|
|
|
class InstallerNotFound(RuntimeError):
|
|
pass
|
|
|
|
|
|
class InstallerError(RuntimeError):
|
|
def __init__(self, desc, c):
|
|
super().__init__(desc)
|
|
self.out = c.stdout
|
|
self.err = c.stderr
|
|
|
|
|
|
class Installer(metaclass=ABCMeta):
|
|
def __init__(self, project):
|
|
self.cmd = self._find_installer()
|
|
self.project = project
|
|
|
|
def __str__(self):
|
|
return self.__class__.__name__
|
|
|
|
@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 allows 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(f"~/.{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", 30)
|
|
shell = kwargs.pop("shell", False)
|
|
if kwargs:
|
|
k = list(kwargs.keys())[0]
|
|
raise TypeError(f"unexpected keyword argument {k!r}")
|
|
args = (self.cmd,) + tuple(args)
|
|
c = subprocess_run(args, timeout=timeout, shell=shell)
|
|
if c.returncode != 0:
|
|
raise InstallerError(f"failed to run {args}", c)
|
|
return c
|
|
|
|
@abstractmethod
|
|
def iter_installable_versions(self):
|
|
"""Iterate through CPython versions available for Pipenv to install."""
|
|
pass
|
|
|
|
def find_version_to_install(self, name):
|
|
"""Find a version in the installer 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(
|
|
f"no installable version found for {name!r}",
|
|
)
|
|
return best_match
|
|
|
|
@abstractmethod
|
|
def install(self, version):
|
|
"""Install the given version with runner implementation.
|
|
|
|
The version must be a ``Version`` instance representing a version
|
|
found in the Installer.
|
|
|
|
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.
|
|
"""
|
|
pass
|
|
|
|
|
|
class Pyenv(Installer):
|
|
WIN = sys.platform.startswith("win") or (sys.platform == "cli" and os.name == "nt")
|
|
|
|
def _find_installer(self):
|
|
return self._find_python_installer_by_name_and_env("pyenv", "PYENV_ROOT")
|
|
|
|
def _run(self, *args, **kwargs):
|
|
if Pyenv.WIN:
|
|
kwargs["shell"] = True
|
|
return super()._run(*args, **kwargs)
|
|
|
|
def iter_installable_versions(self):
|
|
"""Iterate through CPython versions available for Pipenv to install."""
|
|
for name in self._run("install", "--list").stdout.splitlines():
|
|
try:
|
|
version = Version.parse(name.strip())
|
|
except ValueError:
|
|
continue
|
|
yield version
|
|
|
|
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 InstallerError is raised if the pyenv command fails.
|
|
"""
|
|
args = ["install", "-s", str(version)]
|
|
if Pyenv.WIN:
|
|
# pyenv-win skips installed versions by default and does not support -s
|
|
del args[1]
|
|
return self._run(*args, timeout=self.project.s.PIPENV_INSTALL_TIMEOUT)
|
|
|
|
|
|
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."""
|
|
for name in self._run("list-all", "python").stdout.splitlines():
|
|
try:
|
|
version = Version.parse(name.strip())
|
|
except ValueError:
|
|
continue
|
|
yield version
|
|
|
|
def install(self, version):
|
|
"""Install the given version with asdf.
|
|
The version must be a ``Version`` instance representing a version
|
|
found in asdf.
|
|
A ValueError is raised if the given version does not have a match in
|
|
asdf. A InstallerError is raised if the asdf command fails.
|
|
"""
|
|
c = self._run(
|
|
"install",
|
|
"python",
|
|
str(version),
|
|
timeout=self.project.s.PIPENV_INSTALL_TIMEOUT,
|
|
)
|
|
return c
|