diff options
| author | Bernát Gábor <bgabor8@bloomberg.net> | 2020-06-21 08:28:50 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-06-21 08:28:50 +0100 |
| commit | 0cd009b5a1338f66397f71c85a75f576a2f3eabf (patch) | |
| tree | d1a1e6564776ba6123f9e8b245fb58c14ea71df9 /src/virtualenv/seed | |
| parent | f99353ca3d0ca9e23cfe4b66e54ba653bf99ab4a (diff) | |
| download | virtualenv-0cd009b5a1338f66397f71c85a75f576a2f3eabf.tar.gz | |
Implement periodic update feature (#1841)
Co-authored-by: Pradyun Gedam <pradyunsg@gmail.com>
Diffstat (limited to 'src/virtualenv/seed')
| -rw-r--r-- | src/virtualenv/seed/embed/__init__.py | 1 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/base_embed.py | 74 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/pip_invoke.py | 48 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/via_app_data/__init__.py (renamed from src/virtualenv/seed/via_app_data/__init__.py) | 0 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/via_app_data/pip_install/__init__.py (renamed from src/virtualenv/seed/via_app_data/pip_install/__init__.py) | 0 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/via_app_data/pip_install/base.py (renamed from src/virtualenv/seed/via_app_data/pip_install/base.py) | 4 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/via_app_data/pip_install/copy.py (renamed from src/virtualenv/seed/via_app_data/pip_install/copy.py) | 0 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/via_app_data/pip_install/symlink.py (renamed from src/virtualenv/seed/via_app_data/pip_install/symlink.py) | 0 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/via_app_data/via_app_data.py | 127 | ||||
| -rw-r--r-- | src/virtualenv/seed/embed/wheels/acquire.py | 178 | ||||
| -rw-r--r-- | src/virtualenv/seed/via_app_data/via_app_data.py | 128 | ||||
| -rw-r--r-- | src/virtualenv/seed/wheels/__init__.py | 11 | ||||
| -rw-r--r-- | src/virtualenv/seed/wheels/acquire.py | 114 | ||||
| -rw-r--r-- | src/virtualenv/seed/wheels/bundle.py | 51 | ||||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/__init__.py (renamed from src/virtualenv/seed/embed/wheels/__init__.py) | 24 | ||||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/pip-19.1.1-py2.py3-none-any.whl (renamed from src/virtualenv/seed/embed/wheels/pip-19.1.1-py2.py3-none-any.whl) | bin | 1360957 -> 1360957 bytes | |||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/pip-20.1.1-py2.py3-none-any.whl (renamed from src/virtualenv/seed/embed/wheels/pip-20.1.1-py2.py3-none-any.whl) | bin | 1490666 -> 1490666 bytes | |||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/setuptools-43.0.0-py2.py3-none-any.whl (renamed from src/virtualenv/seed/embed/wheels/setuptools-43.0.0-py2.py3-none-any.whl) | bin | 583228 -> 583228 bytes | |||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/setuptools-44.1.1-py2.py3-none-any.whl (renamed from src/virtualenv/seed/embed/wheels/setuptools-44.1.1-py2.py3-none-any.whl) | bin | 583493 -> 583493 bytes | |||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/setuptools-47.1.1-py3-none-any.whl (renamed from src/virtualenv/seed/embed/wheels/setuptools-47.1.1-py3-none-any.whl) | bin | 583233 -> 583233 bytes | |||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/wheel-0.33.6-py2.py3-none-any.whl (renamed from src/virtualenv/seed/embed/wheels/wheel-0.33.6-py2.py3-none-any.whl) | bin | 21556 -> 21556 bytes | |||
| -rw-r--r-- | src/virtualenv/seed/wheels/embed/wheel-0.34.2-py2.py3-none-any.whl (renamed from src/virtualenv/seed/embed/wheels/wheel-0.34.2-py2.py3-none-any.whl) | bin | 26502 -> 26502 bytes | |||
| -rw-r--r-- | src/virtualenv/seed/wheels/periodic_update.py | 311 | ||||
| -rw-r--r-- | src/virtualenv/seed/wheels/util.py | 116 |
24 files changed, 824 insertions, 363 deletions
diff --git a/src/virtualenv/seed/embed/__init__.py b/src/virtualenv/seed/embed/__init__.py index 01e6d4f..e69de29 100644 --- a/src/virtualenv/seed/embed/__init__.py +++ b/src/virtualenv/seed/embed/__init__.py @@ -1 +0,0 @@ -from __future__ import absolute_import, unicode_literals diff --git a/src/virtualenv/seed/embed/base_embed.py b/src/virtualenv/seed/embed/base_embed.py index bffd494..f41b5fc 100644 --- a/src/virtualenv/seed/embed/base_embed.py +++ b/src/virtualenv/seed/embed/base_embed.py @@ -8,38 +8,45 @@ from virtualenv.util.path import Path from virtualenv.util.six import ensure_str, ensure_text from ..seeder import Seeder +from ..wheels import Version + +PERIODIC_UPDATE_ON_BY_DEFAULT = True @add_metaclass(ABCMeta) class BaseEmbed(Seeder): - packages = ["pip", "setuptools", "wheel"] - def __init__(self, options): super(BaseEmbed, self).__init__(options, enabled=options.no_seed is False) + self.download = options.download self.extra_search_dir = [i.resolve() for i in options.extra_search_dir if i.exists()] - def latest_is_none(key): - value = getattr(options, key) - return None if value == "latest" else value - - self.pip_version = latest_is_none("pip") - self.setuptools_version = latest_is_none("setuptools") - self.wheel_version = latest_is_none("wheel") + self.pip_version = options.pip + self.setuptools_version = options.setuptools + self.wheel_version = options.wheel self.no_pip = options.no_pip self.no_setuptools = options.no_setuptools self.no_wheel = options.no_wheel - self.app_data = options.app_data.folder + self.app_data = options.app_data + self.periodic_update = not options.no_periodic_update - if not self.package_version(): + if not self.distribution_to_versions(): self.enabled = False - def package_version(self): + @classmethod + def distributions(cls): return { - package: getattr(self, "{}_version".format(package)) - for package in self.packages - if getattr(self, "no_{}".format(package)) is False + "pip": Version.bundle, + "setuptools": Version.bundle, + "wheel": Version.bundle, + } + + def distribution_to_versions(self): + return { + distribution: getattr(self, "{}_version".format(distribution)) + for distribution in self.distributions() + if getattr(self, "no_{}".format(distribution)) is False } @classmethod @@ -50,14 +57,14 @@ class BaseEmbed(Seeder): "--never-download", dest="download", action="store_false", - help="pass to disable download of the latest {} from PyPI".format("/".join(cls.packages)), + help="pass to disable download of the latest {} from PyPI".format("/".join(cls.distributions())), default=True, ) group.add_argument( "--download", dest="download", action="store_true", - help="pass to enable download of the latest {} from PyPI".format("/".join(cls.packages)), + help="pass to enable download of the latest {} from PyPI".format("/".join(cls.distributions())), default=False, ) parser.add_argument( @@ -65,25 +72,32 @@ class BaseEmbed(Seeder): metavar="d", type=Path, nargs="+", - help="a path containing wheels the seeder may also use beside bundled (can be set 1+ times)", + help="a path containing wheels to extend the internal wheel list (can be set 1+ times)", default=[], ) - for package in cls.packages: + for distribution, default in cls.distributions().items(): parser.add_argument( - "--{}".format(package), - dest=package, + "--{}".format(distribution), + dest=distribution, metavar="version", - help="{} version to install, bundle for bundled".format(package), - default="latest", + help="version of {} to install as seed: embed, bundle or exact version".format(distribution), + default=default, ) - for package in cls.packages: + for distribution in cls.distributions(): parser.add_argument( - "--no-{}".format(package), - dest="no_{}".format(package), + "--no-{}".format(distribution), + dest="no_{}".format(distribution), action="store_true", - help="do not install {}".format(package), + help="do not install {}".format(distribution), default=False, ) + parser.add_argument( + "--no-periodic-update", + dest="no_periodic_update", + action="store_true", + help="disable the periodic (once every 14 days) update of the embedded wheels", + default=not PERIODIC_UPDATE_ON_BY_DEFAULT, + ) def __unicode__(self): result = self.__class__.__name__ @@ -91,11 +105,11 @@ class BaseEmbed(Seeder): if self.extra_search_dir: result += "extra_search_dir={},".format(", ".join(ensure_text(str(i)) for i in self.extra_search_dir)) result += "download={},".format(self.download) - for package in self.packages: - if getattr(self, "no_{}".format(package)): + for distribution in self.distributions(): + if getattr(self, "no_{}".format(distribution)): continue result += " {}{},".format( - package, "={}".format(getattr(self, "{}_version".format(package), None) or "latest"), + distribution, "={}".format(getattr(self, "{}_version".format(distribution), None) or "latest"), ) return result[:-1] + ")" diff --git a/src/virtualenv/seed/embed/pip_invoke.py b/src/virtualenv/seed/embed/pip_invoke.py index 25be493..372e140 100644 --- a/src/virtualenv/seed/embed/pip_invoke.py +++ b/src/virtualenv/seed/embed/pip_invoke.py @@ -4,16 +4,10 @@ import logging from contextlib import contextmanager from virtualenv.discovery.cached_py_info import LogCmd -from virtualenv.info import PY3 from virtualenv.seed.embed.base_embed import BaseEmbed -from virtualenv.seed.embed.wheels.acquire import get_bundled_wheel, pip_wheel_env_run from virtualenv.util.subprocess import Popen -from virtualenv.util.zipapp import ensure_file_on_disk -if PY3: - from contextlib import ExitStack -else: - from contextlib2 import ExitStack +from ..wheels import Version, get_wheel, pip_wheel_env_run class PipInvoke(BaseEmbed): @@ -23,9 +17,10 @@ class PipInvoke(BaseEmbed): def run(self, creator): if not self.enabled: return - with self.get_pip_install_cmd(creator.exe, creator.interpreter.version_release_str) as cmd: - with pip_wheel_env_run(creator.interpreter.version_release_str, self.app_data) as env: - self._execute(cmd, env) + for_py_version = creator.interpreter.version_release_str + with self.get_pip_install_cmd(creator.exe, for_py_version) as cmd: + env = pip_wheel_env_run(self.extra_search_dir, self.app_data) + self._execute(cmd, env) @staticmethod def _execute(cmd, env): @@ -37,18 +32,25 @@ class PipInvoke(BaseEmbed): return process @contextmanager - def get_pip_install_cmd(self, exe, version): - cmd = [str(exe), "-m", "pip", "-q", "install", "--only-binary", ":all:"] + def get_pip_install_cmd(self, exe, for_py_version): + cmd = [str(exe), "-m", "pip", "-q", "install", "--only-binary", ":all:", "--disable-pip-version-check"] if not self.download: cmd.append("--no-index") - pkg_versions = self.package_version() - for key, ver in pkg_versions.items(): - cmd.append("{}{}".format(key, "=={}".format(ver) if ver is not None else "")) - with ExitStack() as stack: - folders = set() - for context in (ensure_file_on_disk(get_bundled_wheel(p, version), self.app_data) for p in pkg_versions): - folders.add(stack.enter_context(context).parent) - folders.update(set(self.extra_search_dir)) - for folder in folders: - cmd.extend(["--find-links", str(folder)]) - yield cmd + folders = set() + for dist, version in self.distribution_to_versions().items(): + wheel = get_wheel( + distribution=dist, + version=version, + for_py_version=for_py_version, + search_dirs=self.extra_search_dir, + download=False, + app_data=self.app_data, + do_periodic_update=self.periodic_update, + ) + if wheel is None: + raise RuntimeError("could not get wheel for distribution {}".format(dist)) + folders.add(str(wheel.path.parent)) + cmd.append(Version.as_pip_req(dist, wheel.version)) + for folder in sorted(folders): + cmd.extend(["--find-links", str(folder)]) + yield cmd diff --git a/src/virtualenv/seed/via_app_data/__init__.py b/src/virtualenv/seed/embed/via_app_data/__init__.py index e69de29..e69de29 100644 --- a/src/virtualenv/seed/via_app_data/__init__.py +++ b/src/virtualenv/seed/embed/via_app_data/__init__.py diff --git a/src/virtualenv/seed/via_app_data/pip_install/__init__.py b/src/virtualenv/seed/embed/via_app_data/pip_install/__init__.py index e69de29..e69de29 100644 --- a/src/virtualenv/seed/via_app_data/pip_install/__init__.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/__init__.py diff --git a/src/virtualenv/seed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py index f7f29ca..f382bda 100644 --- a/src/virtualenv/seed/via_app_data/pip_install/base.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py @@ -54,11 +54,11 @@ class PipInstall(object): def build_image(self): # 1. first extract the wheel - logging.debug("build install image to %s of %s", self._image_dir, self._wheel.name) + logging.debug("build install image for %s to %s", self._wheel.name, self._image_dir) with zipfile.ZipFile(str(self._wheel)) as zip_ref: zip_ref.extractall(str(self._image_dir)) self._extracted = True - # 2. now add additional files not present in the package + # 2. now add additional files not present in the distribution new_files = self._generate_new_files() # 3. finally fix the records file self._fix_records(new_files) diff --git a/src/virtualenv/seed/via_app_data/pip_install/copy.py b/src/virtualenv/seed/embed/via_app_data/pip_install/copy.py index 29d0bc8..29d0bc8 100644 --- a/src/virtualenv/seed/via_app_data/pip_install/copy.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/copy.py diff --git a/src/virtualenv/seed/via_app_data/pip_install/symlink.py b/src/virtualenv/seed/embed/via_app_data/pip_install/symlink.py index f958b65..f958b65 100644 --- a/src/virtualenv/seed/via_app_data/pip_install/symlink.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/symlink.py diff --git a/src/virtualenv/seed/embed/via_app_data/via_app_data.py b/src/virtualenv/seed/embed/via_app_data/via_app_data.py new file mode 100644 index 0000000..779ee18 --- /dev/null +++ b/src/virtualenv/seed/embed/via_app_data/via_app_data.py @@ -0,0 +1,127 @@ +"""Bootstrap""" +from __future__ import absolute_import, unicode_literals + +import logging +from contextlib import contextmanager +from subprocess import CalledProcessError +from threading import Lock, Thread + +import six + +from virtualenv.info import fs_supports_symlink +from virtualenv.seed.embed.base_embed import BaseEmbed +from virtualenv.seed.wheels import get_wheel +from virtualenv.util.path import Path + +from .pip_install.copy import CopyPipInstall +from .pip_install.symlink import SymlinkPipInstall + + +class FromAppData(BaseEmbed): + def __init__(self, options): + super(FromAppData, self).__init__(options) + self.symlinks = options.symlink_app_data + + @classmethod + def add_parser_arguments(cls, parser, interpreter, app_data): + super(FromAppData, cls).add_parser_arguments(parser, interpreter, app_data) + can_symlink = app_data.transient is False and fs_supports_symlink() + parser.add_argument( + "--symlink-app-data", + dest="symlink_app_data", + action="store_true" if can_symlink else "store_false", + help="{} symlink the python packages from the app-data folder (requires seed pip>=19.3)".format( + "" if can_symlink else "not supported - ", + ), + default=False, + ) + + def run(self, creator): + if not self.enabled: + return + with self._get_seed_wheels(creator) as name_to_whl: + pip_version = name_to_whl["pip"].version_tuple if "pip" in name_to_whl else None + installer_class = self.installer_class(pip_version) + + def _install(name, wheel): + logging.debug("install %s from wheel %s via %s", name, wheel, installer_class.__name__) + key = Path(installer_class.__name__) / wheel.path.stem + wheel_img = self.app_data.wheel_image(creator.interpreter.version_release_str, key) + installer = installer_class(wheel.path, creator, wheel_img) + if not installer.has_image(): + installer.build_image() + installer.install(creator.interpreter.version_info) + + threads = list(Thread(target=_install, args=(n, w)) for n, w in name_to_whl.items()) + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + @contextmanager + def _get_seed_wheels(self, creator): + name_to_whl, lock, fail = {}, Lock(), {} + + def _get(distribution, version): + for_py_version = creator.interpreter.version_release_str + failure, result = None, None + # fallback to download in case the exact version is not available + for download in [True] if self.download else [False, True]: + failure = None + try: + result = get_wheel( + distribution=distribution, + version=version, + for_py_version=for_py_version, + search_dirs=self.extra_search_dir, + download=download, + app_data=self.app_data, + do_periodic_update=self.periodic_update, + ) + if result is not None: + break + except Exception as exception: # noqa + logging.exception("fail") + failure = exception + if failure: + if isinstance(failure, CalledProcessError): + msg = "failed to download {}".format(distribution) + if version is not None: + msg += " version {}".format(version) + msg += ", pip download exit code {}".format(failure.returncode) + output = failure.output if six.PY2 else (failure.output + failure.stderr) + if output: + msg += "\n" + msg += output + else: + msg = repr(failure) + logging.error(msg) + with lock: + fail[distribution] = version + else: + with lock: + name_to_whl[distribution] = result + + threads = list( + Thread(target=_get, args=(distribution, version)) + for distribution, version in self.distribution_to_versions().items() + ) + for thread in threads: + thread.start() + for thread in threads: + thread.join() + if fail: + raise RuntimeError("seed failed due to failing to download wheels {}".format(", ".join(fail.keys()))) + yield name_to_whl + + def installer_class(self, pip_version_tuple): + if self.symlinks and pip_version_tuple: + # symlink support requires pip 19.3+ + if pip_version_tuple >= (19, 3): + return SymlinkPipInstall + return CopyPipInstall + + def __unicode__(self): + base = super(FromAppData, self).__unicode__() + msg = ", via={}, app_data_dir={}".format("symlink" if self.symlinks else "copy", self.app_data) + return base[:-1] + msg + base[-1] diff --git a/src/virtualenv/seed/embed/wheels/acquire.py b/src/virtualenv/seed/embed/wheels/acquire.py deleted file mode 100644 index 91b630d..0000000 --- a/src/virtualenv/seed/embed/wheels/acquire.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Bootstrap""" -from __future__ import absolute_import, unicode_literals - -import logging -import os -import sys -from collections import defaultdict -from contextlib import contextmanager -from copy import copy -from shutil import copy2 -from zipfile import ZipFile - -from virtualenv.info import IS_ZIPAPP -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_str, ensure_text -from virtualenv.util.subprocess import Popen, subprocess -from virtualenv.util.zipapp import ensure_file_on_disk - -from . import BUNDLE_SUPPORT, MAX - -BUNDLE_FOLDER = Path(os.path.abspath(__file__)).parent - - -class WheelDownloadFail(ValueError): - def __init__(self, packages, for_py_version, exit_code, out, err): - self.packages = packages - self.for_py_version = for_py_version - self.exit_code = exit_code - self.out = out.strip() - self.err = err.strip() - - -def get_wheels(for_py_version, wheel_cache_dir, extra_search_dir, packages, app_data, download): - # not all wheels are compatible with all python versions, so we need to py version qualify it - processed = copy(packages) - # 1. acquire from bundle - acquire_from_bundle(processed, for_py_version, wheel_cache_dir) - # 2. acquire from extra search dir - acquire_from_dir(processed, for_py_version, wheel_cache_dir, extra_search_dir) - # 3. download from the internet - if download and processed: - download_wheel(processed, for_py_version, wheel_cache_dir, app_data) - - # in the end just get the wheels - wheels = _get_wheels(wheel_cache_dir, packages) - return {p: next(iter(ver_to_files))[1] for p, ver_to_files in wheels.items()} - - -def acquire_from_bundle(packages, for_py_version, to_folder): - for pkg, version in list(packages.items()): - bundle = get_bundled_wheel(pkg, for_py_version) - if bundle is not None: - pkg_version = bundle.stem.split("-")[1] - exact_version_match = version == pkg_version - if exact_version_match: - del packages[pkg] - if version is None or exact_version_match: - bundled_wheel_file = to_folder / bundle.name - if not bundled_wheel_file.exists(): - logging.debug("get bundled wheel %s", bundle) - if IS_ZIPAPP: - from virtualenv.util.zipapp import extract - - extract(bundle, bundled_wheel_file) - else: - copy2(str(bundle), str(bundled_wheel_file)) - - -def get_bundled_wheel(package, version_release): - return BUNDLE_FOLDER / (BUNDLE_SUPPORT.get(version_release, {}) or BUNDLE_SUPPORT[MAX]).get(package) - - -def acquire_from_dir(packages, for_py_version, to_folder, extra_search_dir): - if not packages: - return - for search_dir in extra_search_dir: - wheels = _get_wheels(search_dir, packages) - for pkg, ver_wheels in wheels.items(): - stop = False - for _, filename in ver_wheels: - dest = to_folder / filename.name - if not dest.exists(): - if wheel_support_py(filename, for_py_version): - logging.debug("get extra search dir wheel %s", filename) - copy2(str(filename), str(dest)) - stop = True - else: - stop = True - if stop and packages[pkg] is not None: - del packages[pkg] - break - - -def wheel_support_py(filename, py_version): - name = "{}.dist-info/METADATA".format("-".join(filename.stem.split("-")[0:2])) - with ZipFile(ensure_text(str(filename)), "r") as zip_file: - metadata = zip_file.read(name).decode("utf-8") - marker = "Requires-Python:" - requires = next((i[len(marker) :] for i in metadata.splitlines() if i.startswith(marker)), None) - if requires is None: # if it does not specify a python requires the assumption is compatible - return True - py_version_int = tuple(int(i) for i in py_version.split(".")) - for require in (i.strip() for i in requires.split(",")): - # https://www.python.org/dev/peps/pep-0345/#version-specifiers - for operator, check in [ - ("!=", lambda v: py_version_int != v), - ("==", lambda v: py_version_int == v), - ("<=", lambda v: py_version_int <= v), - (">=", lambda v: py_version_int >= v), - ("<", lambda v: py_version_int < v), - (">", lambda v: py_version_int > v), - ]: - if require.startswith(operator): - ver_str = require[len(operator) :].strip() - version = tuple((int(i) if i != "*" else None) for i in ver_str.split("."))[0:2] - if not check(version): - return False - break - return True - - -def _get_wheels(from_folder, packages): - wheels = defaultdict(list) - for filename in from_folder.iterdir(): - if filename.suffix == ".whl": - data = filename.stem.split("-") - if len(data) >= 2: - pkg, version = data[0:2] - if pkg in packages: - pkg_version = packages[pkg] - if pkg_version is None or pkg_version == version: - wheels[pkg].append((version, filename)) - for versions in wheels.values(): - versions.sort( - key=lambda a: tuple(int(i) if i.isdigit() else i for i in a[0].split(".")), reverse=True, - ) - return wheels - - -def download_wheel(packages, for_py_version, to_folder, app_data): - to_download = list(p if v is None else "{}=={}".format(p, v) for p, v in packages.items()) - logging.debug("download wheels %s", to_download) - cmd = [ - sys.executable, - "-m", - "pip", - "download", - "--disable-pip-version-check", - "--only-binary=:all:", - "--no-deps", - "--python-version", - for_py_version, - "-d", - str(to_folder), - ] - cmd.extend(to_download) - # pip has no interface in python - must be a new sub-process - - with pip_wheel_env_run("{}.{}".format(*sys.version_info[0:2]), app_data) as env: - process = Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) - out, err = process.communicate() - if process.returncode != 0: - raise WheelDownloadFail(packages, for_py_version, process.returncode, out, err) - - -@contextmanager -def pip_wheel_env_run(version, app_data): - env = os.environ.copy() - env.update( - { - ensure_str(k): str(v) # python 2 requires these to be string only (non-unicode) - for k, v in {"PIP_USE_WHEEL": "1", "PIP_USER": "0", "PIP_NO_INPUT": "1"}.items() - }, - ) - with ensure_file_on_disk(get_bundled_wheel("pip", version), app_data) as pip_wheel_path: - # put the bundled wheel onto the path, and use it to do the bootstrap operation - env[str("PYTHONPATH")] = str(pip_wheel_path) - yield env diff --git a/src/virtualenv/seed/via_app_data/via_app_data.py b/src/virtualenv/seed/via_app_data/via_app_data.py deleted file mode 100644 index de3757d..0000000 --- a/src/virtualenv/seed/via_app_data/via_app_data.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Bootstrap""" -from __future__ import absolute_import, unicode_literals - -import logging -from contextlib import contextmanager -from functools import partial -from threading import Lock, Thread - -from virtualenv.info import fs_supports_symlink -from virtualenv.seed.embed.base_embed import BaseEmbed -from virtualenv.seed.embed.wheels.acquire import WheelDownloadFail, get_wheels -from virtualenv.util.path import safe_delete - -from .pip_install.copy import CopyPipInstall -from .pip_install.symlink import SymlinkPipInstall - - -class FromAppData(BaseEmbed): - def __init__(self, options): - super(FromAppData, self).__init__(options) - self.symlinks = options.symlink_app_data - self.base_cache = self.app_data / "seed-app-data" / "v1.0.1" - - @classmethod - def add_parser_arguments(cls, parser, interpreter, app_data): - super(FromAppData, cls).add_parser_arguments(parser, interpreter, app_data) - can_symlink = app_data.transient is False and fs_supports_symlink() - parser.add_argument( - "--symlink-app-data", - dest="symlink_app_data", - action="store_true" if can_symlink else "store_false", - help="{} symlink the python packages from the app-data folder (requires seed pip>=19.3)".format( - "" if can_symlink else "not supported - ", - ), - default=False, - ) - - def run(self, creator): - if not self.enabled: - return - base_cache = self.base_cache / creator.interpreter.version_release_str - with self._get_seed_wheels(creator, base_cache) as name_to_whl: - pip_version = name_to_whl["pip"].stem.split("-")[1] if "pip" in name_to_whl else None - installer_class = self.installer_class(pip_version) - - def _install(name, wheel): - logging.debug("install %s from wheel %s via %s", name, wheel, installer_class.__name__) - image_folder = base_cache.path / "image" / installer_class.__name__ / wheel.stem - installer = installer_class(wheel, creator, image_folder) - if not installer.has_image(): - installer.build_image() - installer.install(creator.interpreter.version_info) - - threads = list(Thread(target=_install, args=(n, w)) for n, w in name_to_whl.items()) - for thread in threads: - thread.start() - for thread in threads: - thread.join() - - @contextmanager - def _get_seed_wheels(self, creator, base_cache): - with base_cache.lock_for_key("wheels"): - wheels_to = base_cache.path / "wheels" - if wheels_to.exists(): - safe_delete(wheels_to) - wheels_to.mkdir(parents=True, exist_ok=True) - name_to_whl, lock, fail = {}, Lock(), {} - - def _get(package, version): - wheel_loader = partial( - get_wheels, - creator.interpreter.version_release_str, - wheels_to, - self.extra_search_dir, - {package: version}, - self.app_data, - ) - failure, result = None, None - # fallback to download in case the exact version is not available - for download in [True] if self.download else [False, True]: - failure = None - try: - result = wheel_loader(download) - if result: - break - except Exception as exception: - failure = exception - if failure: - if isinstance(failure, WheelDownloadFail): - msg = "failed to download {}".format(package) - if version is not None: - msg += " version {}".format(version) - msg += ", pip download exit code {}".format(failure.exit_code) - output = failure.out + failure.err - if output: - msg += "\n" - msg += output - else: - msg = repr(failure) - logging.error(msg) - with lock: - fail[package] = version - else: - with lock: - name_to_whl.update(result) - - package_versions = self.package_version() - threads = list(Thread(target=_get, args=(pkg, v)) for pkg, v in package_versions.items()) - for thread in threads: - thread.start() - for thread in threads: - thread.join() - if fail: - raise RuntimeError("seed failed due to failing to download wheels {}".format(", ".join(fail.keys()))) - yield name_to_whl - - def installer_class(self, pip_version): - if self.symlinks and pip_version: - # symlink support requires pip 19.3+ - pip_version_int = tuple(int(i) for i in pip_version.split(".")[0:2]) - if pip_version_int >= (19, 3): - return SymlinkPipInstall - return CopyPipInstall - - def __unicode__(self): - base = super(FromAppData, self).__unicode__() - msg = ", via={}, app_data_dir={}".format("symlink" if self.symlinks else "copy", self.base_cache.path) - return base[:-1] + msg + base[-1] diff --git a/src/virtualenv/seed/wheels/__init__.py b/src/virtualenv/seed/wheels/__init__.py new file mode 100644 index 0000000..dbffe2e --- /dev/null +++ b/src/virtualenv/seed/wheels/__init__.py @@ -0,0 +1,11 @@ +from __future__ import absolute_import, unicode_literals + +from .acquire import get_wheel, pip_wheel_env_run +from .util import Version, Wheel + +__all__ = ( + "get_wheel", + "pip_wheel_env_run", + "Version", + "Wheel", +) diff --git a/src/virtualenv/seed/wheels/acquire.py b/src/virtualenv/seed/wheels/acquire.py new file mode 100644 index 0000000..8c88725 --- /dev/null +++ b/src/virtualenv/seed/wheels/acquire.py @@ -0,0 +1,114 @@ +"""Bootstrap""" +from __future__ import absolute_import, unicode_literals + +import logging +import os +import sys +from operator import eq, lt + +import six + +from virtualenv.util.path import Path +from virtualenv.util.six import ensure_str +from virtualenv.util.subprocess import Popen, subprocess + +from .bundle import from_bundle +from .util import Version, Wheel, discover_wheels + + +def get_wheel(distribution, version, for_py_version, search_dirs, download, app_data, do_periodic_update): + """ + Get a wheel with the given distribution-version-for_py_version trio, by using the extra search dir + download + """ + # not all wheels are compatible with all python versions, so we need to py version qualify it + # 1. acquire from bundle + wheel = from_bundle(distribution, version, for_py_version, search_dirs, app_data, do_periodic_update) + + # 2. download from the internet + if version not in Version.non_version and download: + wheel = download_wheel( + distribution=distribution, + version_spec=Version.as_version_spec(version), + for_py_version=for_py_version, + search_dirs=search_dirs, + app_data=app_data, + to_folder=app_data.house, + ) + return wheel + + +def download_wheel(distribution, version_spec, for_py_version, search_dirs, app_data, to_folder): + to_download = "{}{}".format(distribution, version_spec or "") + logging.debug("download wheel %s", to_download) + cmd = [ + sys.executable, + "-m", + "pip", + "download", + "--disable-pip-version-check", + "--only-binary=:all:", + "--no-deps", + "--python-version", + for_py_version, + "-d", + str(to_folder), + to_download, + ] + # pip has no interface in python - must be a new sub-process + env = pip_wheel_env_run(search_dirs, app_data) + process = Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + out, err = process.communicate() + if process.returncode != 0: + kwargs = {"output": out} + if six.PY2: + kwargs["output"] += err + else: + kwargs["stderr"] = err + raise subprocess.CalledProcessError(process.returncode, cmd, **kwargs) + for line in out.splitlines(): + line = line.lstrip() + for marker in ("Saved ", "File was already downloaded "): + if line.startswith(marker): + return Wheel(Path(line[len(marker) :]).absolute()) + # if for some reason the output does not match fallback to latest version with that spec + return find_compatible_in_house(distribution, version_spec, for_py_version, to_folder) + + +def find_compatible_in_house(distribution, version_spec, for_py_version, in_folder): + wheels = discover_wheels(in_folder, distribution, None, for_py_version) + start, end = 0, len(wheels) + if version_spec is not None: + if version_spec.startswith("<"): + from_pos, op = 1, lt + elif version_spec.startswith("=="): + from_pos, op = 2, eq + else: + raise ValueError(version_spec) + version = Wheel.as_version_tuple(version_spec[from_pos:]) + start = next((at for at, w in enumerate(wheels) if op(w.version_tuple, version)), len(wheels)) + + return None if start == end else wheels[start] + + +def pip_wheel_env_run(search_dirs, app_data): + for_py_version = "{}.{}".format(*sys.version_info[0:2]) + env = os.environ.copy() + env.update( + { + ensure_str(k): str(v) # python 2 requires these to be string only (non-unicode) + for k, v in {"PIP_USE_WHEEL": "1", "PIP_USER": "0", "PIP_NO_INPUT": "1"}.items() + }, + ) + wheel = get_wheel( + distribution="pip", + version=None, + for_py_version=for_py_version, + search_dirs=search_dirs, + download=False, + app_data=app_data, + do_periodic_update=False, + ) + if wheel is None: + raise RuntimeError("could not find the embedded pip") + env[str("PYTHONPATH")] = str(wheel.path) + return env diff --git a/src/virtualenv/seed/wheels/bundle.py b/src/virtualenv/seed/wheels/bundle.py new file mode 100644 index 0000000..6ac15f9 --- /dev/null +++ b/src/virtualenv/seed/wheels/bundle.py @@ -0,0 +1,51 @@ +from __future__ import absolute_import, unicode_literals + +from virtualenv.app_data import AppDataDiskFolder, TempAppData + +from ..wheels.embed import get_embed_wheel +from .periodic_update import periodic_update +from .util import Version, Wheel, discover_wheels + + +def from_bundle(distribution, version, for_py_version, search_dirs, app_data, do_periodic_update): + """ + Load the bundled wheel to a cache directory. + """ + of_version = Version.of_version(version) + wheel = load_embed_wheel(app_data, distribution, for_py_version, of_version) + + if version != Version.embed: + # 2. check if we have upgraded embed + if isinstance(app_data, AppDataDiskFolder) and not isinstance(app_data, TempAppData): + wheel = periodic_update(distribution, for_py_version, wheel, search_dirs, app_data, do_periodic_update) + + # 3. acquire from extra search dir + found_wheel = from_dir(distribution, of_version, for_py_version, search_dirs) + if found_wheel is not None: + if wheel is None: + wheel = found_wheel + elif found_wheel.version_tuple > wheel.version_tuple: + wheel = found_wheel + return wheel + + +def load_embed_wheel(app_data, distribution, for_py_version, version): + wheel = get_embed_wheel(distribution, for_py_version) + if wheel is not None: + version_match = version == wheel.version + if version is None or version_match: + with app_data.ensure_extracted(wheel.path, lambda: app_data.house) as wheel_path: + wheel = Wheel(wheel_path) + else: # if version does not match ignore + wheel = None + return wheel + + +def from_dir(distribution, version, for_py_version, directories): + """ + Load a compatible wheel from a given folder. + """ + for folder in directories: + for wheel in discover_wheels(folder, distribution, version, for_py_version): + return wheel + return None diff --git a/src/virtualenv/seed/embed/wheels/__init__.py b/src/virtualenv/seed/wheels/embed/__init__.py index 28fd0c4..17860e0 100644 --- a/src/virtualenv/seed/embed/wheels/__init__.py +++ b/src/virtualenv/seed/wheels/embed/__init__.py @@ -1,6 +1,15 @@ from __future__ import absolute_import, unicode_literals +from virtualenv.seed.wheels.util import Wheel +from virtualenv.util.path import Path + +BUNDLE_FOLDER = Path(__file__).absolute().parent BUNDLE_SUPPORT = { + "3.10": { + "pip": "pip-20.1.1-py2.py3-none-any.whl", + "setuptools": "setuptools-47.1.1-py3-none-any.whl", + "wheel": "wheel-0.34.2-py2.py3-none-any.whl", + }, "3.9": { "pip": "pip-20.1.1-py2.py3-none-any.whl", "setuptools": "setuptools-47.1.1-py3-none-any.whl", @@ -37,4 +46,17 @@ BUNDLE_SUPPORT = { "wheel": "wheel-0.34.2-py2.py3-none-any.whl", }, } -MAX = "3.9" +MAX = "3.10" + + +def get_embed_wheel(distribution, for_py_version): + path = BUNDLE_FOLDER / (BUNDLE_SUPPORT.get(for_py_version, {}) or BUNDLE_SUPPORT[MAX]).get(distribution) + return Wheel.from_path(path) + + +__all__ = ( + "get_embed_wheel", + "BUNDLE_SUPPORT", + "MAX", + "BUNDLE_FOLDER", +) diff --git a/src/virtualenv/seed/embed/wheels/pip-19.1.1-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/pip-19.1.1-py2.py3-none-any.whl Binary files differindex 8476c11..8476c11 100644 --- a/src/virtualenv/seed/embed/wheels/pip-19.1.1-py2.py3-none-any.whl +++ b/src/virtualenv/seed/wheels/embed/pip-19.1.1-py2.py3-none-any.whl diff --git a/src/virtualenv/seed/embed/wheels/pip-20.1.1-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/pip-20.1.1-py2.py3-none-any.whl Binary files differindex ea1d0f7..ea1d0f7 100644 --- a/src/virtualenv/seed/embed/wheels/pip-20.1.1-py2.py3-none-any.whl +++ b/src/virtualenv/seed/wheels/embed/pip-20.1.1-py2.py3-none-any.whl diff --git a/src/virtualenv/seed/embed/wheels/setuptools-43.0.0-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-43.0.0-py2.py3-none-any.whl Binary files differindex 733faa6..733faa6 100644 --- a/src/virtualenv/seed/embed/wheels/setuptools-43.0.0-py2.py3-none-any.whl +++ b/src/virtualenv/seed/wheels/embed/setuptools-43.0.0-py2.py3-none-any.whl diff --git a/src/virtualenv/seed/embed/wheels/setuptools-44.1.1-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-44.1.1-py2.py3-none-any.whl Binary files differindex bf28513..bf28513 100644 --- a/src/virtualenv/seed/embed/wheels/setuptools-44.1.1-py2.py3-none-any.whl +++ b/src/virtualenv/seed/wheels/embed/setuptools-44.1.1-py2.py3-none-any.whl diff --git a/src/virtualenv/seed/embed/wheels/setuptools-47.1.1-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-47.1.1-py3-none-any.whl Binary files differindex e226baf..e226baf 100644 --- a/src/virtualenv/seed/embed/wheels/setuptools-47.1.1-py3-none-any.whl +++ b/src/virtualenv/seed/wheels/embed/setuptools-47.1.1-py3-none-any.whl diff --git a/src/virtualenv/seed/embed/wheels/wheel-0.33.6-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/wheel-0.33.6-py2.py3-none-any.whl Binary files differindex 2a71896..2a71896 100644 --- a/src/virtualenv/seed/embed/wheels/wheel-0.33.6-py2.py3-none-any.whl +++ b/src/virtualenv/seed/wheels/embed/wheel-0.33.6-py2.py3-none-any.whl diff --git a/src/virtualenv/seed/embed/wheels/wheel-0.34.2-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/wheel-0.34.2-py2.py3-none-any.whl Binary files differindex becbee8..becbee8 100644 --- a/src/virtualenv/seed/embed/wheels/wheel-0.34.2-py2.py3-none-any.whl +++ b/src/virtualenv/seed/wheels/embed/wheel-0.34.2-py2.py3-none-any.whl diff --git a/src/virtualenv/seed/wheels/periodic_update.py b/src/virtualenv/seed/wheels/periodic_update.py new file mode 100644 index 0000000..25270ad --- /dev/null +++ b/src/virtualenv/seed/wheels/periodic_update.py @@ -0,0 +1,311 @@ +""" +Periodically update bundled versions. +""" + +from __future__ import absolute_import, unicode_literals + +import json +import logging +import os +import subprocess +import sys +from datetime import datetime, timedelta +from itertools import groupby +from shutil import copy2 +from threading import Thread + +from six.moves.urllib.request import urlopen + +from virtualenv.app_data import AppDataDiskFolder +from virtualenv.info import PY2 +from virtualenv.util.path import Path +from virtualenv.util.subprocess import DETACHED_PROCESS, Popen + +from ..wheels.embed import BUNDLE_SUPPORT +from ..wheels.util import Wheel + +if PY2: + # on Python 2 datetime.strptime throws the error below if the import did not trigger on main thread + # Failed to import _strptime because the import lock is held by + try: + import _strptime # noqa + except ImportError: # pragma: no cov + pass # pragma: no cov + + +def periodic_update(distribution, for_py_version, wheel, search_dirs, app_data, do_periodic_update): + if do_periodic_update: + handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data) + + now = datetime.now() + + u_log = UpdateLog.from_app_data(app_data, distribution, for_py_version) + u_log_older_than_hour = now - u_log.completed > timedelta(hours=1) if u_log.completed is not None else False + for _, group in groupby(u_log.versions, key=lambda v: v.wheel.version_tuple[0:2]): + version = next(group) # use only latest patch version per minor, earlier assumed to be buggy + if wheel is not None and Path(version.filename).name == wheel.name: + break + if u_log.periodic is False or (u_log_older_than_hour and version.use(now)): + updated_wheel = Wheel(app_data.house / version.filename) + logging.debug("using %supdated wheel %s", "periodically " if updated_wheel else "", updated_wheel) + wheel = updated_wheel + break + + return wheel + + +def handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data): + embed_update_log = app_data.embed_update_log(distribution, for_py_version) + u_log = UpdateLog.from_dict(embed_update_log.read()) + if u_log.needs_update: + u_log.periodic = True + u_log.started = datetime.now() + embed_update_log.write(u_log.to_dict()) + trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, periodic=True) + + +DATETIME_FMT = "%Y-%m-%dT%H:%M:%S.%fZ" + + +def dump_datetime(value): + return None if value is None else value.strftime(DATETIME_FMT) + + +def load_datetime(value): + return None if value is None else datetime.strptime(value, DATETIME_FMT) + + +class NewVersion(object): + def __init__(self, filename, found_date, release_date): + self.filename = filename + self.found_date = found_date + self.release_date = release_date + + @classmethod + def from_dict(cls, dictionary): + return cls( + filename=dictionary["filename"], + found_date=load_datetime(dictionary["found_date"]), + release_date=load_datetime(dictionary["release_date"]), + ) + + def to_dict(self): + return { + "filename": self.filename, + "release_date": dump_datetime(self.release_date), + "found_date": dump_datetime(self.found_date), + } + + def use(self, now): + compare_from = self.release_date or self.found_date + return now - compare_from >= timedelta(days=28) + + def __repr__(self): + return "{}(filename={}), found_date={}, release_date={})".format( + self.__class__.__name__, self.filename, self.found_date, self.release_date, + ) + + def __eq__(self, other): + return type(self) == type(other) and all( + getattr(self, k) == getattr(other, k) for k in ["filename", "release_date", "found_date"] + ) + + def __ne__(self, other): + return not (self == other) + + @property + def wheel(self): + return Wheel(Path(self.filename)) + + +class UpdateLog(object): + def __init__(self, started, completed, versions, periodic): + self.started = started + self.completed = completed + self.versions = versions + self.periodic = periodic + + @classmethod + def from_dict(cls, dictionary): + if dictionary is None: + dictionary = {} + return cls( + load_datetime(dictionary.get("started")), + load_datetime(dictionary.get("completed")), + [NewVersion.from_dict(v) for v in dictionary.get("versions", [])], + dictionary.get("periodic"), + ) + + @classmethod + def from_app_data(cls, app_data, distribution, for_py_version): + raw_json = app_data.embed_update_log(distribution, for_py_version).read() + return cls.from_dict(raw_json) + + def to_dict(self): + return { + "started": dump_datetime(self.started), + "completed": dump_datetime(self.completed), + "periodic": self.periodic, + "versions": [r.to_dict() for r in self.versions], + } + + @property + def needs_update(self): + now = datetime.now() + if self.completed is None: # never completed + return self._check_start(now) + else: + if now - self.completed <= timedelta(days=14): + return False + return self._check_start(now) + + def _check_start(self, now): + return self.started is None or now - self.started > timedelta(hours=1) + + +def trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, periodic): + wheel_path = None if wheel is None else str(wheel.path) + cmd = [ + sys.executable, + "-c", + "from virtualenv.seed.wheels.periodic_update import do_update;" + "do_update({!r}, {!r}, {!r}, {!r}, {!r}, {!r})".format( + distribution, for_py_version, wheel_path, str(app_data), [str(p) for p in search_dirs], periodic, + ), + ] + debug = os.environ.get(str("_VIRTUALENV_PERIODIC_UPDATE_INLINE")) == str("1") + pipe = None if debug else subprocess.PIPE + kwargs = {"stdout": pipe, "stderr": pipe} + if not debug and sys.platform == "win32": + kwargs["creationflags"] = DETACHED_PROCESS + process = Popen(cmd, **kwargs) + logging.info( + "triggered periodic upgrade of %s%s (for python %s) via background process having PID %d", + distribution, + "" if wheel is None else "=={}".format(wheel.version), + for_py_version, + process.pid, + ) + if debug: + process.communicate() # on purpose not called to make it a background process + + +def do_update(distribution, for_py_version, embed_filename, app_data, search_dirs, periodic): + + from virtualenv.seed.wheels import acquire + + wheel_filename = None if embed_filename is None else Path(embed_filename) + app_data = AppDataDiskFolder(app_data) if isinstance(app_data, str) else app_data + search_dirs = [Path(p) if isinstance(p, str) else p for p in search_dirs] + wheelhouse = app_data.house + embed_update_log = app_data.embed_update_log(distribution, for_py_version) + u_log = UpdateLog.from_dict(embed_update_log.read()) + + now = datetime.now() + + if wheel_filename is not None: + dest = wheelhouse / wheel_filename.name + if not dest.exists(): + copy2(str(wheel_filename), str(wheelhouse)) + + last, versions = None, [] + while last is None or not last.use(now): + download_time = datetime.now() + dest = acquire.download_wheel( + distribution=distribution, + version_spec=None if last is None else "<{}".format(Wheel(Path(last.filename)).version), + for_py_version=for_py_version, + search_dirs=search_dirs, + app_data=app_data, + to_folder=wheelhouse, + ) + if dest is None or (u_log.versions and u_log.versions[0].filename == dest.name): + break + release_date = _get_release_date(dest.path) + last = NewVersion(filename=dest.path.name, release_date=release_date, found_date=download_time) + logging.info("detected %s in %s", last, datetime.now() - download_time) + versions.append(last) + u_log.periodic = periodic + if not u_log.periodic: + u_log.started = now + u_log.versions = versions + u_log.versions + u_log.completed = datetime.now() + embed_update_log.write(u_log.to_dict()) + return versions + + +def _get_release_date(dest): + wheel = Wheel(dest) + # the most accurate is to ask PyPi - e.g. https://pypi.org/pypi/pip/json, + # see https://warehouse.pypa.io/api-reference/json/ for more details + try: + with urlopen("https://pypi.org/pypi/{}/json".format(wheel.distribution)) as file_handler: + content = json.load(file_handler) + return datetime.strptime(content["releases"][wheel.version][0]["upload_time"], "%Y-%m-%dT%H:%M:%S") + except Exception: # noqa + return None + + +def manual_upgrade(app_data): + threads = [] + + for for_py_version, distribution_to_package in BUNDLE_SUPPORT.items(): + # load extra search dir for the given for_py + for distribution in distribution_to_package.keys(): + thread = Thread(target=_run_manual_upgrade, args=(app_data, distribution, for_py_version)) + thread.start() + threads.append(thread) + + for thread in threads: + thread.join() + + +def _run_manual_upgrade(app_data, distribution, for_py_version): + start = datetime.now() + from .bundle import from_bundle + + current = from_bundle( + distribution=distribution, + version=None, + for_py_version=for_py_version, + search_dirs=[], + app_data=app_data, + do_periodic_update=False, + ) + logging.warning( + "upgrade %s for python %s with current %s", + distribution, + for_py_version, + "" if current is None else current.name, + ) + versions = do_update( + distribution=distribution, + for_py_version=for_py_version, + embed_filename=current.path, + app_data=app_data, + search_dirs=[], + periodic=False, + ) + msg = "upgraded %s for python %s in %s {}".format( + "new entries found:\n%s" if versions else "no new versions found", + ) + args = [ + distribution, + for_py_version, + datetime.now() - start, + ] + if versions: + args.append("\n".join("\t{}".format(v) for v in versions)) + logging.warning(msg, *args) + + +__all__ = ( + "periodic_update", + "do_update", + "manual_upgrade", + "NewVersion", + "UpdateLog", + "load_datetime", + "dump_datetime", + "trigger_update", +) diff --git a/src/virtualenv/seed/wheels/util.py b/src/virtualenv/seed/wheels/util.py new file mode 100644 index 0000000..1240eb2 --- /dev/null +++ b/src/virtualenv/seed/wheels/util.py @@ -0,0 +1,116 @@ +from __future__ import absolute_import, unicode_literals + +from operator import attrgetter +from zipfile import ZipFile + +from virtualenv.util.six import ensure_text + + +class Wheel(object): + def __init__(self, path): + # https://www.python.org/dev/peps/pep-0427/#file-name-convention + # The wheel filename is {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl + self.path = path + self._parts = path.stem.split("-") + + @classmethod + def from_path(cls, path): + if path is not None and path.suffix == ".whl" and len(path.stem.split("-")) >= 5: + return cls(path) + return None + + @property + def distribution(self): + return self._parts[0] + + @property + def version(self): + return self._parts[1] + + @property + def version_tuple(self): + return self.as_version_tuple(self.version) + + @staticmethod + def as_version_tuple(version): + result = [] + for part in version.split(".")[0:3]: + try: + result.append(int(part)) + except ValueError: + break + if not result: + raise ValueError(version) + return tuple(result) + + @property + def name(self): + return self.path.name + + def support_py(self, py_version): + name = "{}.dist-info/METADATA".format("-".join(self.path.stem.split("-")[0:2])) + with ZipFile(ensure_text(str(self.path)), "r") as zip_file: + metadata = zip_file.read(name).decode("utf-8") + marker = "Requires-Python:" + requires = next((i[len(marker) :] for i in metadata.splitlines() if i.startswith(marker)), None) + if requires is None: # if it does not specify a python requires the assumption is compatible + return True + py_version_int = tuple(int(i) for i in py_version.split(".")) + for require in (i.strip() for i in requires.split(",")): + # https://www.python.org/dev/peps/pep-0345/#version-specifiers + for operator, check in [ + ("!=", lambda v: py_version_int != v), + ("==", lambda v: py_version_int == v), + ("<=", lambda v: py_version_int <= v), + (">=", lambda v: py_version_int >= v), + ("<", lambda v: py_version_int < v), + (">", lambda v: py_version_int > v), + ]: + if require.startswith(operator): + ver_str = require[len(operator) :].strip() + version = tuple((int(i) if i != "*" else None) for i in ver_str.split("."))[0:2] + if not check(version): + return False + break + return True + + def __repr__(self): + return "{}({})".format(self.__class__.__name__, self.path) + + def __str__(self): + return str(self.path) + + +def discover_wheels(from_folder, distribution, version, for_py_version): + wheels = [] + for filename in from_folder.iterdir(): + wheel = Wheel.from_path(filename) + if wheel and wheel.distribution == distribution: + if version is None or wheel.version == version: + if wheel.support_py(for_py_version): + wheels.append(wheel) + return sorted(wheels, key=attrgetter("version_tuple", "distribution"), reverse=True) + + +class Version: + #: the version bundled with virtualenv + bundle = "bundle" + embed = "embed" + #: custom version handlers + non_version = ( + bundle, + embed, + ) + + @staticmethod + def of_version(value): + return None if value in Version.non_version else value + + @staticmethod + def as_pip_req(distribution, version): + return "{}{}".format(distribution, Version.as_version_spec(version)) + + @staticmethod + def as_version_spec(version): + of_version = Version.of_version(version) + return "" if of_version is None else "=={}".format(of_version) |
