Repository: pex-tool/pex

Issue:
Reproducibility of installed wheels
Comparing two PEXes built with `2.1.44` and `--requirement` from [a lockfile](https://github.com/pantsbuild/pants/blob/d342d9e65fb6962d9c419a0cac6c2a22d9cd486a/3rdparty/python/lockfiles/user_reqs.txt), some of the contents of the PEXes were mismatched run over run: see attached diff of the `shasum`s.

[pex-reproducibility.diff.txt](https://github.com/pantsbuild/pex/files/7016552/pex-reproducibility.diff.txt)

The `PEX-INFO`, `__main__.py`, and `RECORD` differences are secondary effects of the contents having changed: the other changes look like:
* A `*.so` file... likely due to its own build not being reproducible... there is unlikely to be anything that PEX can do here.
* Generated scripts placed in the PEX file having different shebang lines:
```diff
--- lockfile-1/.deps/pytest-6.2.4-py3-none-any.whl/bin/pytest	1980-01-01 00:00:00.000000000 -0800
+++ lockfile-2/.deps/pytest-6.2.4-py3-none-any.whl/bin/pytest	1980-01-01 00:00:00.000000000 -0800
@@ -1,8 +1,8 @@
-#!/private/var/folders/bg/_r10hqp14kjcpv68yzdk5svc0000gn/T/process-executionksN0pU/.cache/pex_root/venvs/short/9cb0d755/bin/python3.7
+#!/private/var/folders/bg/_r10hqp14kjcpv68yzdk5svc0000gn/T/process-executionXjB3hx/.cache/pex_root/venvs/short/e12499a5/bin/python3.7
 # -*- coding: utf-8 -*-
 import re
 import sys
 from pytest import console_main
 if __name__ == '__main__':
     sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
     sys.exit(console_main())
```


Discussion:
> Generated scripts placed in the PEX file having different shebang lines

Almost certainly due to the `*.so`s as well. The venv hash includes the pex hash and the pex hash includes a hash of PEX-INFO.
Let me know if you agree this can be closed or if you have an idea. I don't think there is anything Pex can reasonably do here.
But actually - it's worse. A shebang must be absolute and the tmp path in those shebangs is different. So Pex aside, you'd need to fix that.
Given we're building these to be portable, in known throwaway directories, it feels like we could set a fixed shebang with a flag, to `/bin/env python` or something? It's strictly better than the status quo.
I'm not sure we all understand exactly the setup of the original report. I certainly don't. This is a Pex bug report but there are Pantisms in play. The PEX_ROOT for the two runs is different, for 1: `/private/var/folders/bg/_r10hqp14kjcpv68yzdk5svc0000gn/T/process-executionksN0pU/.cache/pex_root` vs `/private/var/folders/bg/_r10hqp14kjcpv68yzdk5svc0000gn/T/process-executionXjB3hx/.cache/pex_root`. The shebangs in question look like `--venv` mode shebangs. A PEX running in `--venv` mode installs itself in a venv in he PEX_ROOT and purposefully re-writes python shebangs to point to the venv interpreter just like in any other venv impl for correctness (the script has to run with the venv interpreter specifically once installed so that it can see all the venv's installed deps). So the sharp point I think is that venvs aren't portable by design. The PEX zip file is and should be reproducible. That we have automated tests for. @stuhood can you clarify the steps used to create the PEX files or venvs from those or whichever is applicable?
Ok, setting Pants aside to clarify things:
```console
$ pex --version
2.1.45
$ rm -rf ~/.pex
$ pex pex -opex.pex
$ unzip -qc pex.pex .deps/pex-2.1.45-py2.py3-none-any.whl/bin/pex | head -1
#!/home/jsirois/.pex/venvs/short/1d052010/bin/python3
$ rm -rf ~/.pex pex.pex
$ pex pex -opex.pex
$ unzip -qc pex.pex .deps/pex-2.1.45-py2.py3-none-any.whl/bin/pex | head -1
#!/home/jsirois/.pex/venvs/short/1d052010/bin/python3
```
You create a PEX file and it is reproducible, but only using the same interpreter to create that pex file. That interpreter is the Pip venv PEX creates once per Pex version per PEX_ROOT:
```console
$ ls -1 /home/jsirois/.pex/venvs/short/1d052010/lib/python3.9/site-packages/
__init__.py
__main__.pyc
__pex_patched_pip__.py
__pex_patched_pip__.pyc
pip
pip-20.3.4.dist-info
pkg_resources
__pycache__
setuptools
setuptools-44.0.0.dist-info
wheel
wheel-0.36.2.dist-info
```
So that's where the `--venv` is coming from - Pex dogfood:
https://github.com/pantsbuild/pex/blob/c9ff7d965fddb533bee846cfca2d9e0ace574074/pex/pip.py#L308-L331

So now that I've answered my own confusion about @stuhood's case and the interpreter path is definitely embedded in the PEX file bin/ scripts by normal means I agree with @illicitonion - but we can instead use an invalid and ~standard shebang for this purpose of `#!python`:
https://github.com/pantsbuild/pex/blob/196b4cd5b8dd4b4af2586460530e9a777262be7d/pex/vendor/_vendored/pip/pip/_internal/operations/install/wheel.py#L111-L129

I think this works since that shebang appears on disk in three places:
1. Inside `.deps/<installed wheel dir>/bin/<python script>` in a Pex zip file.
2. Inside `PEX_ROOT/installed/wheels/<hash dirs>/<installed wheel dir>/bin/<python script>`.
3. Inside a Pex created venv either in `PEX_ROOT/venvs/...` or in a location of choice when created via `pex-tools <pex file> venv ...`.

In the case of 1, the script can't be executed anyhow. In the case of 2 the script is loose on the filesystem and executabke, but its not added to the PATH by the Pex runtime; so its also not executable in any normal sense by the user code booted by the Pex runtime except via very tight binding to Pex internals - which I think is acceptable to break as a usage. In case 3 Pex already re-writes the shebang as noted above in order to point the script's interpreter back into the venv. In that case the shebang gets reified to the correct path for the current machine and these scripts get additionally added to the PATH if requested via `--venv {prepend,append}`.

I'm liking `#!python` over `#!/usr/bin/env python` since it'll cause script execution to fail outright for someone binding to Pex internals in the 1 or 2 scenarios above. I guess if the script were executed in a CWD that happened to contain a `python` executable though, the script would start running. Perhaps?:
```console
$ head -1 test.py 
#!In_order_to_run_this_script_rebuild_your_PEX_file_with_--venv.
$ ./test.py 
bash: ./test.py: In_order_to_run_this_script_rebuild_your_PEX_file_with_--venv.: bad interpreter: No such file or directory
```


It looks like we'll need to add creating the same pex with two different interpreters to the reproducibility test here to smoke this out and keep it out:
https://github.com/pantsbuild/pex/blob/81ca9a07767e95508c80cdb41701d9e123bf6497/tests/test_integration.py#L1772-L1799

Resolving patch:
diff --git a/pex/pex_info.py b/pex/pex_info.py
--- a/pex/pex_info.py
+++ b/pex/pex_info.py
@@ -60,24 +60,9 @@ class PexInfo(object):
     INSTALL_CACHE = "installed_wheels"
 
     @classmethod
-    def make_build_properties(cls, interpreter=None):
-        # This lazy import is currently needed for performance reasons. At PEX runtime PexInfo is
-        # read in the bootstrap to see if the PEX should run in `--unzip` mode. If so, it must
-        # re-exec itself to run against its unzipped contents. Since `make_build_properties` is only
-        # used at PEX buildtime and the transitive imports of PythonInterpreter are large and slow,
-        # we avoid this import cost for runtime-only use.
-        #
-        # See: https://github.com/pantsbuild/pex/issues/1054
-        from pex.interpreter import PythonInterpreter
-
-        pi = interpreter or PythonInterpreter.get()
-        plat = pi.platform
-        platform_name = plat.platform
+    def make_build_properties(cls):
         return {
             "pex_version": pex_version,
-            "class": pi.identity.interpreter,
-            "version": pi.identity.version,
-            "platform": platform_name,
         }
 
     @classmethod
@@ -86,7 +71,7 @@ def default(cls, interpreter=None):
         pex_info = {
             "requirements": [],
             "distributions": {},
-            "build_properties": cls.make_build_properties(interpreter),
+            "build_properties": cls.make_build_properties(),
         }
         return cls(info=pex_info)
 
diff --git a/pex/pip.py b/pex/pip.py
--- a/pex/pip.py
+++ b/pex/pip.py
@@ -4,8 +4,11 @@
 
 from __future__ import absolute_import
 
+import base64
+import csv
 import fileinput
 import functools
+import hashlib
 import json
 import os
 import re
@@ -17,7 +20,7 @@
 from textwrap import dedent
 
 from pex import dist_metadata, third_party
-from pex.common import atomic_directory, safe_mkdtemp
+from pex.common import atomic_directory, is_exe, safe_mkdtemp
 from pex.compatibility import urlparse
 from pex.dist_metadata import ProjectNameAndVersion
 from pex.distribution_target import DistributionTarget
@@ -30,8 +33,8 @@
 from pex.platforms import Platform
 from pex.third_party import isolated
 from pex.tracer import TRACER
-from pex.typing import TYPE_CHECKING
-from pex.util import named_temporary_file
+from pex.typing import TYPE_CHECKING, cast
+from pex.util import CacheHelper, named_temporary_file
 from pex.variables import ENV
 
 if TYPE_CHECKING:
@@ -46,9 +49,17 @@
         List,
         Mapping,
         Optional,
+        Protocol,
         Tuple,
         Union,
     )
+
+    class CSVWriter(Protocol):
+        def writerow(self, row):
+            # type: (Iterable[Union[str, int]]) -> None
+            pass
+
+
 else:
     from pex.third_party import attr
 
@@ -699,10 +710,21 @@ def spawn_build_wheels(
             interpreter=interpreter,
         )
 
-    @staticmethod
+    @classmethod
     def _fixup_install(
+        cls,
+        dist,  # type: str
+        install_dir,  # type: str
+    ):
+        # type: (...) -> None
+        modified_scripts = list(cls._fixup_scripts(install_dir))
+        cls._fixup_record(dist, install_dir, modified_scripts=modified_scripts)
+
+    @staticmethod
+    def _fixup_record(
         dist,  # type: str
         install_dir,  # type: str
+        modified_scripts=None,  # type: Optional[Iterable[str]]
     ):
         # type: (...) -> None
 
@@ -729,33 +751,95 @@ def _fixup_install(
             for root, _, files in os.walk(install_dir)
             for f in files
         ]
-        direct_url_relpath = dist_metadata.find_dist_info_file(
+        record_relpath = dist_metadata.find_dist_info_file(
             project_name=project_name_and_version.project_name,
             version=project_name_and_version.version,
-            filename="direct_url.json",
+            filename="RECORD",
             listing=listing,
         )
-        if not direct_url_relpath:
+        if not record_relpath:
             return
 
-        direct_url_abspath = os.path.join(install_dir, direct_url_relpath)
-        if not os.path.exists(direct_url_abspath):
-            return
+        exclude_relpaths = []
+
+        record_abspath = os.path.join(install_dir, record_relpath)
+        dist_info_dir = os.path.dirname(record_abspath)
 
-        with open(direct_url_abspath) as fp:
-            if urlparse.urlparse(json.load(fp)["url"]).scheme != "file":
-                return
+        direct_url_abspath = os.path.join(dist_info_dir, "direct_url.json")
+        direct_url_relpath = os.path.relpath(direct_url_abspath, install_dir)
+        if os.path.exists(direct_url_abspath):
+            with open(direct_url_abspath) as fp:
+                if urlparse.urlparse(json.load(fp)["url"]).scheme == "file":
+                    exclude_relpaths.append(os.path.relpath(direct_url_abspath, install_dir))
+                    os.unlink(direct_url_abspath)
 
-        os.unlink(direct_url_abspath)
+        to_rehash = {}
+        if modified_scripts:
+            for modified_script in modified_scripts:
+                # N.B.: Pip installs wheels with RECORD entries like `../../bin/script` even when it's
+                # called in `--target <dir>` mode which installs the script in `bin/script`.
+                record_relpath = os.path.join(os.pardir, os.pardir, modified_script)
+                modified_script_abspath = os.path.join(install_dir, modified_script)
+                to_rehash[record_relpath] = modified_script_abspath
 
         # The RECORD is a csv file with the path to each installed file in the 1st column.
         # See: https://www.python.org/dev/peps/pep-0376/#record
-        dist_info_dir = os.path.dirname(direct_url_abspath)
-        with closing(
-            fileinput.input(files=[os.path.join(dist_info_dir, "RECORD")], inplace=True)
-        ) as record_fi:
-            for line in record_fi:
-                if line.split(",")[0] != direct_url_relpath:
+        with closing(fileinput.input(files=[record_abspath], inplace=True, mode="rU")) as record_fi:
+            csv_writer = None  # type: Optional[CSVWriter]
+            for path, existing_hash, existing_size in csv.reader(
+                record_fi, delimiter=",", quotechar='"'
+            ):
+                if csv_writer is None:
+                    # N.B.: The raw input lines include a newline that varies between '\r\n' and
+                    # '\n' when the wheel was built from an sdist by Pip depending on whether the
+                    # interpreter used was Python 2 or Python 3 respectively. As such, we normalize
+                    # all RECORD files to use '\n' regardless of interpreter.
+                    csv_writer = cast(
+                        "CSVWriter",
+                        csv.writer(sys.stdout, delimiter=",", quotechar='"', lineterminator="\n"),
+                    )
+
+                abspath_to_rehash = to_rehash.pop(path, None)
+                if existing_hash and abspath_to_rehash is not None:
+                    algorithm = existing_hash.split("=")[0]
+                    hasher = hashlib.new(algorithm)
+                    with open(abspath_to_rehash, "rb") as rehash_fp:
+                        CacheHelper.update_hash(rehash_fp, digest=hasher)
+
+                    fingerprint = base64.urlsafe_b64encode(hasher.digest()).decode("ascii")
+                    de_padded, pad, rest = fingerprint.rpartition("=")
+                    new_hash = str(de_padded if pad and not rest else fingerprint)
+                    new_size = os.stat(abspath_to_rehash).st_size
+                    csv_writer.writerow((path, new_hash, new_size))
+                elif path != direct_url_relpath:
+                    csv_writer.writerow((path, existing_hash, existing_size))
+
+    @staticmethod
+    def _fixup_scripts(
+        install_dir,  # type: str
+    ):
+        # type: (...) -> Iterator[str]
+        bin_dir = os.path.join(install_dir, "bin")
+        if not os.path.isdir(bin_dir):
+            return
+
+        scripts = []
+        for script_name in os.listdir(bin_dir):
+            script_path = os.path.join(bin_dir, script_name)
+            if is_exe(script_path):
+                scripts.append(script_path)
+
+        with closing(fileinput.input(files=scripts, inplace=True)) as script_fi:
+            for line in script_fi:
+                if script_fi.isfirstline() and re.match(
+                    r"^#!.*(?:python|pypy)", line, re.IGNORECASE
+                ):
+                    # Ensure python shebangs are reproducible. The only place these can be used is
+                    # in venv mode PEXes where the `#!python` placeholder shebang will be re-written
+                    # to use the venv's python interpreter.
+                    print("#!python")
+                    yield os.path.relpath(script_fi.filename(), install_dir)
+                else:
                     # N.B.: These lines include the newline already.
                     sys.stdout.write(line)
 
diff --git a/pex/tools/commands/virtualenv.py b/pex/tools/commands/virtualenv.py
--- a/pex/tools/commands/virtualenv.py
+++ b/pex/tools/commands/virtualenv.py
@@ -39,7 +39,11 @@ def _is_python_script(executable):
             return False
         interpreter = fp.readline()
         return bool(
-            re.search(
+            # Support the `#!python` shebang that wheel installers should recognize as a special
+            # form to convert to a localized shebang upon install.
+            # See: https://www.python.org/dev/peps/pep-0427/#recommended-installer-features
+            interpreter == b"python\n"
+            or re.search(
                 br"""
                 # The aim is to admit the common shebang forms:
                 # + /usr/bin/env <python bin name>


Test patch:
diff --git a/pex/testing.py b/pex/testing.py
--- a/pex/testing.py
+++ b/pex/testing.py
@@ -4,6 +4,7 @@
 from __future__ import absolute_import, print_function
 
 import contextlib
+import itertools
 import os
 import platform
 import random
@@ -583,20 +584,17 @@ def make_env(**kwargs):
     return env
 
 
-def run_command_with_jitter(
-    args,  # type: Iterable[str]
+def run_commands_with_jitter(
+    commands,  # type: Iterable[Iterable[str]]
     path_argument,  # type: str
     extra_env=None,  # type: Optional[Mapping[str, str]]
-    python=None,  # type: Optional[str]
     delay=2.0,  # type: float
-    count=3,  # type: int
 ):
     # type: (...) -> List[str]
-    """Runs the command `count` times in an attempt to introduce randomness.
+    """Runs the commands with tactics that attempt to introduce randomness in outputs.
 
-    Each run of the command will run against a clean Pex cache with a unique path injected as the
-    value for `path_argument`. A unique `PYTHONHASHSEED` is set in the environment for each
-    execution as well.
+    Each command will run against a clean Pex cache with a unique path injected as the value for
+    `path_argument`. A unique `PYTHONHASHSEED` is set in the environment for each execution as well.
 
     Additionally, a delay is inserted between executions. By default, this delay is 2s to ensure zip
     precision is stressed. See: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT.
@@ -605,9 +603,9 @@ def run_command_with_jitter(
     pex_root = os.path.join(td, "pex_root")
 
     paths = []
-    for index in range(count):
+    for index, command in enumerate(commands):
         path = os.path.join(td, str(index))
-        cmd = list(args) + [path_argument, path]
+        cmd = list(command) + [path_argument, path]
 
         # Note that we change the `PYTHONHASHSEED` to ensure that there are no issues resulting
         # from the random seed, such as data structures, as Tox sets this value by default.
@@ -625,3 +623,28 @@ def run_command_with_jitter(
         subprocess.check_call(args=cmd, env=env)
         paths.append(path)
     return paths
+
+
+def run_command_with_jitter(
+    args,  # type: Iterable[str]
+    path_argument,  # type: str
+    extra_env=None,  # type: Optional[Mapping[str, str]]
+    delay=2.0,  # type: float
+    count=3,  # type: int
+):
+    # type: (...) -> List[str]
+    """Runs the command `count` times in an attempt to introduce randomness.
+
+    Each run of the command will run against a clean Pex cache with a unique path injected as the
+    value for `path_argument`. A unique `PYTHONHASHSEED` is set in the environment for each
+    execution as well.
+
+    Additionally, a delay is inserted between executions. By default, this delay is 2s to ensure zip
+    precision is stressed. See: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT.
+    """
+    return run_commands_with_jitter(
+        commands=list(itertools.repeat(list(args), count)),
+        path_argument=path_argument,
+        extra_env=extra_env,
+        delay=delay,
+    )
diff --git a/tests/test_integration.py b/tests/test_integration.py
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -23,7 +23,7 @@
 import pytest
 
 from pex.common import safe_copy, safe_mkdir, safe_open, safe_rmtree, temporary_dir, touch
-from pex.compatibility import WINDOWS, to_bytes
+from pex.compatibility import PY2, WINDOWS, to_bytes
 from pex.executor import Executor
 from pex.fetcher import URLFetcher
 from pex.interpreter import PythonInterpreter
@@ -51,6 +51,7 @@
     make_env,
     make_source_dir,
     run_command_with_jitter,
+    run_commands_with_jitter,
     run_pex_command,
     run_simple_pex,
     run_simple_pex_test,
@@ -506,7 +507,6 @@ def test_interpreter_resolution_with_constraint_option():
         res.assert_success()
         pex_info = PexInfo.from_pex(pex_out_path)
         assert [">=2.7,<3"] == pex_info.interpreter_constraints
-        assert pex_info.build_properties["version"][0] < 3
 
 
 def test_interpreter_resolution_with_multiple_constraint_options():
@@ -527,7 +527,6 @@ def test_interpreter_resolution_with_multiple_constraint_options():
         res.assert_success()
         pex_info = PexInfo.from_pex(pex_out_path)
         assert {">=2.7,<3", ">=500"} == set(pex_info.interpreter_constraints)
-        assert pex_info.build_properties["version"][0] < 3
 
 
 def test_interpreter_resolution_with_pex_python_path():
@@ -1754,8 +1753,11 @@ def test_issues_539_abi3_resolution():
         subprocess.check_call([cryptography_pex, "-c", "import cryptography"])
 
 
-def assert_reproducible_build(args):
-    # type: (List[str]) -> None
+def assert_reproducible_build(
+    args,  # type: List[str]
+    pythons=None,  # type: Optional[Iterable[str]]
+):
+    # type: (...) -> None
     with temporary_dir() as td:
 
         def explode_pex(path):
@@ -1765,9 +1767,23 @@ def explode_pex(path):
                 zf.extractall(path=destination_dir)
                 return [os.path.join(destination_dir, member) for member in sorted(zf.namelist())]
 
-        pexes = run_command_with_jitter(
-            path_argument="--output-file", args=create_pex_command(quiet=True), count=3
-        )
+        if pythons:
+            pexes = run_commands_with_jitter(
+                path_argument="--output-file",
+                commands=[
+                    create_pex_command(
+                        args=args + ["--python-shebang", "/usr/bin/env python"],
+                        python=python,
+                        quiet=True,
+                    )
+                    for python in pythons
+                ],
+            )
+        else:
+            pexes = run_command_with_jitter(
+                create_pex_command(args=args, quiet=True), path_argument="--output-file", count=3
+            )
+
         pex_members = {pex: explode_pex(path=pex) for pex in pexes}
         pex1 = pexes.pop()
         for pex2 in pexes:
@@ -1787,9 +1803,22 @@ def explode_pex(path):
             assert filecmp.cmp(pex1, pex2, shallow=False)
 
 
+MAJOR_COMPATIBLE_PYTHONS = (
+    (sys.executable, ensure_python_interpreter(PY27))
+    if PY2
+    else (sys.executable, ensure_python_interpreter(PY37), ensure_python_interpreter(PY38))
+)
+MIXED_MAJOR_PYTHONS = (
+    sys.executable,
+    ensure_python_interpreter(PY27),
+    ensure_python_interpreter(PY37),
+    ensure_python_interpreter(PY38),
+)
+
+
 def test_reproducible_build_no_args():
     # type: () -> None
-    assert_reproducible_build([])
+    assert_reproducible_build([], pythons=MIXED_MAJOR_PYTHONS)
 
 
 def test_reproducible_build_bdist_requirements():
@@ -1800,16 +1829,24 @@ def test_reproducible_build_bdist_requirements():
 
 def test_reproducible_build_sdist_requirements():
     # type: () -> None
-    assert_reproducible_build(["python-crontab==2.3.6"])
+    # The python-crontab sdist will be built as py2-none-any or py3-none-any depending on the
+    # Python major version since it is not marked as universal in the sdist.
+    assert_reproducible_build(["python-crontab==2.3.6"], pythons=MAJOR_COMPATIBLE_PYTHONS)
 
 
 def test_reproducible_build_m_flag():
     # type: () -> None
-    assert_reproducible_build(["-m", "pydoc"])
+    assert_reproducible_build(["-m", "pydoc"], pythons=MIXED_MAJOR_PYTHONS)
 
 
 def test_reproducible_build_c_flag_from_source():
     # type: () -> None
+    setup_cfg = dedent(
+        """\
+        [wheel]
+        universal = 1
+        """
+    )
     setup_py = dedent(
         """\
         from setuptools import setup
@@ -1818,7 +1855,7 @@ def test_reproducible_build_c_flag_from_source():
             name='my_app',
             entry_points={'console_scripts': ['my_app_function = my_app:do_something']},
         )
-  """
+        """
     )
     my_app = dedent(
         """\
@@ -1826,22 +1863,31 @@ def do_something():
             return "reproducible"
         """
     )
-    with temporary_content({"setup.py": setup_py, "my_app.py": my_app}) as project_dir:
-        assert_reproducible_build([project_dir, "-c", "my_app_function"])
+    with temporary_content(
+        {"setup.cfg": setup_cfg, "setup.py": setup_py, "my_app.py": my_app}
+    ) as project_dir:
+        assert_reproducible_build(
+            [project_dir, "-c", "my_app_function"], pythons=MIXED_MAJOR_PYTHONS
+        )
 
 
 def test_reproducible_build_c_flag_from_dependency():
     # type: () -> None
-    assert_reproducible_build(["future==0.17.1", "-c", "futurize"])
+    # The futurize script installed depends on the version of python being used; so we don't try
+    # to mix Python 2 with Python 3 as in many other reproducibility tests.
+    assert_reproducible_build(
+        ["future==0.17.1", "-c", "futurize"], pythons=MAJOR_COMPATIBLE_PYTHONS
+    )
 
 
 def test_reproducible_build_python_flag():
     # type: () -> None
-    assert_reproducible_build(["--python=python2.7"])
+    assert_reproducible_build(["--python=python2.7"], pythons=MIXED_MAJOR_PYTHONS)
 
 
 def test_reproducible_build_python_shebang_flag():
     # type: () -> None
+    # Passing `python_versions` override `--python-shebang`; so we don't do that here.
     assert_reproducible_build(["--python-shebang=/usr/bin/python"])