blob: 62e583555b46c7217a9c9812efedc264aa6df6aa [file]
"""https://docs.python.org/3/library/zipapp.html."""
from __future__ import annotations
import argparse
import io
import json
import os
import shutil
import subprocess
import sys
import zipapp
import zipfile
from collections import defaultdict, deque
from email import message_from_string
from pathlib import Path, PurePosixPath
from shlex import quote
from stat import S_IWUSR
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Any
from packaging.markers import Marker
from packaging.requirements import Requirement
if TYPE_CHECKING:
from collections.abc import Iterator
HERE = Path(__file__).parent.absolute()
VERSIONS = [f"3.{i}" for i in range(14, 7, -1)]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--dest", default="virtualenv.pyz")
args = parser.parse_args()
with TemporaryDirectory() as folder:
packages = get_wheels_for_support_versions(Path(folder))
create_zipapp(os.path.abspath(args.dest), packages)
def create_zipapp(dest: str, packages: dict[str, Any]) -> None:
bio = io.BytesIO()
base = PurePosixPath("__virtualenv__")
modules = defaultdict(lambda: defaultdict(dict))
dist = defaultdict(lambda: defaultdict(dict))
with zipfile.ZipFile(bio, "w") as zip_app:
write_packages_to_zipapp(base, dist, modules, packages, zip_app)
modules_json = json.dumps(modules, indent=2)
zip_app.writestr("modules.json", modules_json)
distributions_json = json.dumps(dist, indent=2)
zip_app.writestr("distributions.json", distributions_json)
zip_app.writestr("__main__.py", (HERE / "__main__zipapp.py").read_bytes())
bio.seek(0)
zipapp.create_archive(bio, dest)
print(f"zipapp created at {dest} with size {os.path.getsize(dest) / 1024 / 1024:.2f}MB") # noqa: T201
def write_packages_to_zipapp( # noqa: C901, PLR0912
base: PurePosixPath,
dist: dict[str, Any],
modules: dict[str, Any],
packages: dict[str, Any],
zip_app: zipfile.ZipFile,
) -> None:
has = set()
for name, p_w_v in packages.items(): # noqa: PLR1702
for platform, w_v in p_w_v.items():
for wheel_data in w_v.values():
wheel = wheel_data.wheel
with zipfile.ZipFile(str(wheel)) as wheel_zip:
for filename in wheel_zip.namelist():
if name == "virtualenv":
dest = PurePosixPath(filename)
else:
dest = base / wheel.stem / filename
if dest.suffix in {".so", ".pyi"}:
continue
if dest.suffix == ".py":
key = filename[:-3].replace("/", ".").replace("__init__", "").rstrip(".")
for version in wheel_data.versions:
modules[version][platform][key] = str(dest)
if dest.parent.suffix == ".dist-info":
dist_name = dest.parent.stem.split("-")[0].replace("_", "-")
for version in wheel_data.versions:
dist[version][platform][dist_name] = str(dest.parent)
dest_str = str(dest)
if dest_str in has:
continue
has.add(dest_str)
if "/tests/" in dest_str or "/docs/" in dest_str:
continue
print(dest_str) # noqa: T201
content = wheel_zip.read(filename)
zip_app.writestr(dest_str, content)
del content
class WheelDownloader:
def __init__(self, into: Path) -> None:
if into.exists():
shutil.rmtree(into)
into.mkdir(parents=True)
self.into = into
self.collected = defaultdict(lambda: defaultdict(dict))
self.pip_cmd = [str(Path(sys.executable).parent / "pip")]
self._cmd = [*self.pip_cmd, "download", "-q", "--no-deps", "--no-cache-dir", "--dest", str(self.into)]
def run(self, target: Path, versions: list[str]) -> None:
whl = self.build_sdist(target)
todo = deque((version, None, whl) for version in versions)
wheel_store = {}
while todo:
version, platform, dep = todo.popleft()
dep_str = dep.name.split("-")[0] if isinstance(dep, Path) else dep.name
if dep_str in self.collected[version] and platform in self.collected[version][dep_str]:
continue
whl = self._get_wheel(dep, platform[2:] if platform and platform.startswith("==") else None, version)
if whl is None:
if dep_str not in wheel_store:
msg = f"failed to get {dep_str}, have {wheel_store}"
raise RuntimeError(msg)
whl = wheel_store[dep_str]
else:
wheel_store[dep_str] = whl
self.collected[version][dep_str][platform] = whl
todo.extend(self.get_dependencies(whl, version))
def _get_wheel(self, dep: Requirement | Path, platform: str | None, version: str) -> Path | None:
if isinstance(dep, Requirement):
before = set(self.into.iterdir())
if self._download(
platform,
False, # noqa: FBT003
"--python-version",
version,
"--only-binary",
":all:",
str(dep),
):
self._download(platform, True, "--python-version", version, str(dep)) # noqa: FBT003
after = set(self.into.iterdir())
new_files = after - before
assert len(new_files) <= 1 # noqa: S101
if not len(new_files):
return None
new_file = next(iter(new_files))
if new_file.suffix == ".whl":
return new_file
dep = new_file
new_file = self.build_sdist(dep)
assert new_file.suffix == ".whl" # noqa: S101
return new_file
def _download(self, platform: str | None, stop_print_on_fail: bool, *args: str) -> int:
exe_cmd = self._cmd + list(args)
if platform is not None:
exe_cmd.extend(["--platform", platform])
return run_suppress_output(exe_cmd, stop_print_on_fail=stop_print_on_fail)
@staticmethod
def get_dependencies(whl: Path, version: str) -> Iterator[tuple[str, str | None, Requirement]]:
with zipfile.ZipFile(str(whl), "r") as zip_file:
name = "/".join([f"{'-'.join(whl.name.split('-')[0:2])}.dist-info", "METADATA"])
with zip_file.open(name) as file_handler:
metadata = message_from_string(file_handler.read().decode("utf-8"))
deps = metadata.get_all("Requires-Dist")
if deps is None:
return
for dep in deps:
req = Requirement(dep)
markers = getattr(req.marker, "_markers", ()) or ()
if any(
m
for m in markers
if isinstance(m, tuple) and len(m) == 3 and m[0].value == "extra" # noqa: PLR2004
):
continue
py_versions = WheelDownloader._marker_at(markers, "python_version")
if py_versions:
marker = Marker('python_version < "1"')
marker._markers = [ # noqa: SLF001
markers[ver] for ver in sorted(i for i in set(py_versions) | {i - 1 for i in py_versions} if i >= 0)
]
matches_python = marker.evaluate({"python_version": version})
if not matches_python:
continue
deleted = 0
for ver in py_versions:
deleted += WheelDownloader._del_marker_at(markers, ver - deleted)
platforms = []
platform_positions = WheelDownloader._marker_at(markers, "sys_platform")
deleted = 0
for pos in platform_positions: # can only be or meaningfully
platform = f"{markers[pos][1].value}{markers[pos][2].value}"
deleted += WheelDownloader._del_marker_at(markers, pos - deleted)
platforms.append(platform)
if not platforms:
platforms.append(None)
for platform in platforms:
yield version, platform, req
@staticmethod
def _marker_at(markers: list[Any], key: str) -> list[int]:
return [
i
for i, m in enumerate(markers)
if isinstance(m, tuple) and len(m) == 3 and m[0].value == key # noqa: PLR2004
]
@staticmethod
def _del_marker_at(markers: list[Any], at: int) -> int:
del markers[at]
deleted = 1
op = max(at - 1, 0)
if markers and isinstance(markers[op], str):
del markers[op]
deleted += 1
return deleted
def build_sdist(self, target: Path) -> Path:
if target.is_dir():
# pip 20.1 no longer guarantees this to be parallel safe, need to copy/lock
with TemporaryDirectory() as temp_folder:
folder = Path(temp_folder) / target.name
shutil.copytree(
str(target),
str(folder),
ignore=shutil.ignore_patterns(".tox", ".tox4", "venv", "__pycache__", "*.pyz"),
)
try:
return self._build_sdist(self.into, folder)
finally:
# permission error on Windows <3.7 https://bugs.python.org/issue26660
def onerror(func: Any, path: str, exc_info: Any) -> None: # noqa: ARG001, ANN401
os.chmod(path, S_IWUSR)
func(path)
shutil.rmtree(str(folder), onerror=onerror)
else:
return self._build_sdist(target.parent / target.stem, target)
def _build_sdist(self, folder: Path, target: Path) -> Path:
if not folder.exists() or not list(folder.iterdir()):
cmd = [*self.pip_cmd, "wheel", "-w", str(folder), "--no-deps", str(target), "-q"]
run_suppress_output(cmd, stop_print_on_fail=True)
return next(iter(folder.iterdir()))
def run_suppress_output(cmd: list[str], stop_print_on_fail: bool = False) -> int: # noqa: FBT002
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding="utf-8",
)
out, err = process.communicate()
if stop_print_on_fail and process.returncode != 0:
print(f"exit with {process.returncode} of {' '.join(quote(i) for i in cmd)}", file=sys.stdout) # noqa: T201
if out:
print(out, file=sys.stdout) # noqa: T201
if err:
print(err, file=sys.stderr) # noqa: T201
raise SystemExit(process.returncode)
return process.returncode
def get_wheels_for_support_versions(folder: Path) -> dict[str, Any]:
downloader = WheelDownloader(folder / "wheel-store")
downloader.run(HERE.parent, VERSIONS)
packages = defaultdict(lambda: defaultdict(lambda: defaultdict(WheelForVersion)))
for version, collected in downloader.collected.items():
for pkg, platform_to_wheel in collected.items():
name = Requirement(pkg).name
for platform, wheel in platform_to_wheel.items():
pl = platform or "==any"
wheel_versions = packages[name][pl][wheel.name]
wheel_versions.versions.append(version)
wheel_versions.wheel = wheel
for name, p_w_v in packages.items():
for platform, w_v in p_w_v.items():
print(f"{name} - {platform}") # noqa: T201
for wheel, wheel_versions in w_v.items():
print(f"{' '.join(wheel_versions.versions)} of {wheel} (use {wheel_versions.wheel})") # noqa: T201
return packages
class WheelForVersion:
def __init__(self, wheel: Path | None = None, versions: list[str] | None = None) -> None:
self.wheel = wheel
self.versions = versions or []
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.wheel!r}, {self.versions!r})"
if __name__ == "__main__":
main()