summaryrefslogtreecommitdiff
path: root/src/virtualenv/seed
diff options
context:
space:
mode:
authorBernát Gábor <bgabor8@bloomberg.net>2020-06-21 08:28:50 +0100
committerGitHub <noreply@github.com>2020-06-21 08:28:50 +0100
commit0cd009b5a1338f66397f71c85a75f576a2f3eabf (patch)
treed1a1e6564776ba6123f9e8b245fb58c14ea71df9 /src/virtualenv/seed
parentf99353ca3d0ca9e23cfe4b66e54ba653bf99ab4a (diff)
downloadvirtualenv-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__.py1
-rw-r--r--src/virtualenv/seed/embed/base_embed.py74
-rw-r--r--src/virtualenv/seed/embed/pip_invoke.py48
-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.py127
-rw-r--r--src/virtualenv/seed/embed/wheels/acquire.py178
-rw-r--r--src/virtualenv/seed/via_app_data/via_app_data.py128
-rw-r--r--src/virtualenv/seed/wheels/__init__.py11
-rw-r--r--src/virtualenv/seed/wheels/acquire.py114
-rw-r--r--src/virtualenv/seed/wheels/bundle.py51
-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)bin1360957 -> 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)bin1490666 -> 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)bin583228 -> 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)bin583493 -> 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)bin583233 -> 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)bin21556 -> 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)bin26502 -> 26502 bytes
-rw-r--r--src/virtualenv/seed/wheels/periodic_update.py311
-rw-r--r--src/virtualenv/seed/wheels/util.py116
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
index 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
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
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)