Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

produce a virtualenv zipapp #1287

Merged
merged 4 commits into from Jan 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog/1092.feature.rst
@@ -0,0 +1,3 @@
Enable virtualenv to be distributed as a ``zipapp`` or to be run as a
wheel with ``PYTHONPATH=virtualenv...any.whl python -mvirtualenv`` - by
Anthony Sottile
37 changes: 37 additions & 0 deletions tasks/make_zipapp.py
@@ -0,0 +1,37 @@
"""https://docs.python.org/3/library/zipapp.html"""
import argparse
asottile marked this conversation as resolved.
Show resolved Hide resolved
import io
import os.path
import zipapp
import zipfile


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", default=".")
gaborbernat marked this conversation as resolved.
Show resolved Hide resolved
parser.add_argument("--dest")
args = parser.parse_args()

if args.dest is not None:
asottile marked this conversation as resolved.
Show resolved Hide resolved
dest = args.dest
else:
dest = os.path.join(args.root, "virtualenv.pyz")

bio = io.BytesIO()
with zipfile.ZipFile(bio, "w") as zipf:
filenames = ["LICENSE.txt", "virtualenv.py"]
for whl in os.listdir(os.path.join(args.root, "virtualenv_support")):
filenames.append(os.path.join("virtualenv_support", whl))

for filename in filenames:
zipf.write(os.path.join(args.root, filename), filename)

zipf.writestr("__main__.py", "import virtualenv; virtualenv.main()")

bio.seek(0)
zipapp.create_archive(bio, dest)
print("zipapp created at {}".format(dest))


if __name__ == "__main__":
exit(main())
asottile marked this conversation as resolved.
Show resolved Hide resolved
98 changes: 98 additions & 0 deletions tests/test_zipapp.py
@@ -0,0 +1,98 @@
from __future__ import unicode_literals

import json
import os.path
import subprocess
import sys

import pytest

import virtualenv

HERE = os.path.dirname(os.path.dirname(__file__))


def _python(v):
return virtualenv.get_installed_pythons().get(v, "python{}".format(v))


@pytest.fixture(scope="session")
def call_zipapp(tmp_path_factory):
if sys.version_info[:2] == (3, 4):
pytest.skip("zipapp was introduced in python3.5")
pyz = str(tmp_path_factory.mktemp(basename="zipapp") / "virtualenv.pyz")
subprocess.check_call(
(_python("3"), os.path.join(HERE, "tasks/make_zipapp.py"), "--root", virtualenv.HERE, "--dest", pyz)
)

def zipapp_make_env(path, python=None):
cmd = (sys.executable, pyz, "--no-download", path)
if python:
cmd += ("-p", python)
subprocess.check_call(cmd)

return zipapp_make_env


@pytest.fixture(scope="session")
def call_wheel(tmp_path_factory):
wheels = tmp_path_factory.mktemp(basename="wheel")
subprocess.check_call((sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", str(wheels), HERE))
wheel, = wheels.iterdir()

def wheel_make_env(path, python=None):
cmd = (sys.executable, "-m", "virtualenv", "--no-download", path)
if python:
cmd += ("-p", python)
env = dict(os.environ, PYTHONPATH=str(wheel))
subprocess.check_call(cmd, env=env)

return wheel_make_env


def test_zipapp_basic_invocation(call_zipapp, tmp_path):
_test_basic_invocation(call_zipapp, tmp_path)


def test_wheel_basic_invocation(call_wheel, tmp_path):
_test_basic_invocation(call_wheel, tmp_path)


def _test_basic_invocation(make_env, tmp_path):
venv = tmp_path / "venv"
make_env(str(venv))
assert_venv_looks_good(
venv, list(sys.version_info), "{}{}".format(virtualenv.EXPECTED_EXE, ".exe" if virtualenv.IS_WIN else "")
)


def version_exe(venv, exe_name):
_, _, _, bin_dir = virtualenv.path_locations(str(venv))
exe = os.path.join(bin_dir, exe_name)
script = "import sys; import json; print(json.dumps(dict(v=list(sys.version_info), e=sys.executable)))"
cmd = [exe, "-c", script]
out = json.loads(subprocess.check_output(cmd, universal_newlines=True))
return out["v"], out["e"]


def assert_venv_looks_good(venv, version_info, exe_name):
assert venv.exists()
version, exe = version_exe(venv, exe_name=exe_name)
assert version[: len(version_info)] == version_info
assert exe != sys.executable


def _test_invocation_dash_p(make_env, tmp_path):
venv = tmp_path / "venv"
asottile marked this conversation as resolved.
Show resolved Hide resolved
python = {2: _python("3"), 3: _python("2.7")}[sys.version_info[0]]
asottile marked this conversation as resolved.
Show resolved Hide resolved
make_env(str(venv), python)
expected = {3: 2, 2: 3}[sys.version_info[0]]
assert_venv_looks_good(venv, [expected], "python{}".format(".exe" if virtualenv.IS_WIN else ""))


def test_zipapp_invocation_dash_p(call_zipapp, tmp_path):
_test_invocation_dash_p(call_zipapp, tmp_path)


def test_wheel_invocation_dash_p(call_wheel, tmp_path):
_test_invocation_dash_p(call_wheel, tmp_path)
58 changes: 46 additions & 12 deletions virtualenv.py
Expand Up @@ -18,6 +18,7 @@
import ast
import base64
import codecs
import contextlib
import distutils.spawn
import distutils.sysconfig
import errno
Expand All @@ -30,7 +31,9 @@
import struct
import subprocess
import sys
import tempfile
import textwrap
import zipfile
import zlib
from distutils.util import strtobool
from os.path import join
Expand All @@ -49,6 +52,9 @@
print("ERROR: this script requires Python 2.7 or greater.")
sys.exit(101)

HERE = os.path.dirname(os.path.abspath(__file__))
IS_ZIPAPP = os.path.isfile(HERE)

try:
# noinspection PyUnresolvedReferences,PyUnboundLocalVariable
basestring
Expand Down Expand Up @@ -459,19 +465,35 @@ def _find_file(filename, folders):
return False, filename


def file_search_dirs():
here = os.path.dirname(os.path.abspath(__file__))
dirs = [here, join(here, "virtualenv_support")]
if os.path.splitext(os.path.dirname(__file__))[0] != "virtualenv":
# Probably some boot script; just in case virtualenv is installed...
@contextlib.contextmanager
def virtualenv_support_dirs():
"""Context manager yielding either [virtualenv_support_dir] or []"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe more accurate to say: locate virtualenv support files directory


# normal filesystem installation
if os.path.isdir(join(HERE, "virtualenv_support")):
yield [join(HERE, "virtualenv_support")]
asottile marked this conversation as resolved.
Show resolved Hide resolved
elif IS_ZIPAPP:
tmpdir = tempfile.mkdtemp()
asottile marked this conversation as resolved.
Show resolved Hide resolved
try:
with zipfile.ZipFile(HERE) as zipf:
for member in zipf.namelist():
if os.path.dirname(member) == "virtualenv_support":
zipf.extract(member, tmpdir)
yield [join(tmpdir, "virtualenv_support")]
finally:
shutil.rmtree(tmpdir)
# probably a bootstrap script
elif os.path.splitext(os.path.dirname(__file__))[0] != "virtualenv":
try:
# noinspection PyUnresolvedReferences
import virtualenv
except ImportError:
pass
yield []
else:
dirs.append(os.path.join(os.path.dirname(virtualenv.__file__), "virtualenv_support"))
return [d for d in dirs if os.path.isdir(d)]
yield [join(os.path.dirname(virtualenv.__file__), "virtualenv_support")]
asottile marked this conversation as resolved.
Show resolved Hide resolved
# we tried!
else:
yield []


class UpdatingDefaultsHelpFormatter(optparse.IndentedHelpFormatter):
Expand Down Expand Up @@ -650,13 +672,12 @@ def main():
"--no-wheel", dest="no_wheel", action="store_true", help="Do not install wheel in the new virtualenv."
)

default_search_dirs = file_search_dirs()
parser.add_option(
"--extra-search-dir",
dest="search_dirs",
action="append",
metavar="DIR",
default=default_search_dirs,
default=[],
help="Directory to look for setuptools/pip distributions in. " "This option can be used multiple times.",
)

Expand Down Expand Up @@ -724,6 +745,8 @@ def main():
file = __file__
if file.endswith(".pyc"):
file = file[:-1]
elif IS_ZIPAPP:
file = HERE
sub_process_call = subprocess.Popen([interpreter, file] + sys.argv[1:], env=env)
raise SystemExit(sub_process_call.wait())

Expand Down Expand Up @@ -756,12 +779,13 @@ def main():
make_environment_relocatable(home_dir)
return

with virtualenv_support_dirs() as search_dirs:
create_environment(
home_dir,
site_packages=options.system_site_packages,
clear=options.clear,
prompt=options.prompt,
search_dirs=options.search_dirs,
search_dirs=search_dirs + options.search_dirs,
download=options.download,
no_setuptools=options.no_setuptools,
no_pip=options.no_pip,
Expand Down Expand Up @@ -900,8 +924,18 @@ def find_wheels(projects, search_dirs):

def install_wheel(project_names, py_executable, search_dirs=None, download=False):
if search_dirs is None:
search_dirs = file_search_dirs()
search_dirs_context = virtualenv_support_dirs
else:

@contextlib.contextmanager
def search_dirs_context():
yield search_dirs

with search_dirs_context() as search_dirs:
_install_wheel_with_search_dir(download, project_names, py_executable, search_dirs)


def _install_wheel_with_search_dir(download, project_names, py_executable, search_dirs):
wheels = find_wheels(["setuptools", "pip"], search_dirs)
python_path = os.pathsep.join(wheels)

Expand Down