1# Copyright 2023 The Bazel Authors. All rights reserved. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Build and/or fetch a single wheel based on the requirement passed in""" 16 17import argparse 18import errno 19import glob 20import json 21import os 22import re 23import shutil 24import subprocess 25import sys 26import textwrap 27from pathlib import Path 28from tempfile import NamedTemporaryFile 29from typing import Dict, Iterable, List, Optional, Set, Tuple 30 31from pip._vendor.packaging.utils import canonicalize_name 32 33from python.pip_install.tools.wheel_installer import arguments, namespace_pkgs, wheel 34 35 36def _configure_reproducible_wheels() -> None: 37 """Modifies the environment to make wheel building reproducible. 38 Wheels created from sdists are not reproducible by default. We can however workaround this by 39 patching in some configuration with environment variables. 40 """ 41 42 # wheel, by default, enables debug symbols in GCC. This incidentally captures the build path in the .so file 43 # We can override this behavior by disabling debug symbols entirely. 44 # https://github.com/pypa/pip/issues/6505 45 if "CFLAGS" in os.environ: 46 os.environ["CFLAGS"] += " -g0" 47 else: 48 os.environ["CFLAGS"] = "-g0" 49 50 # set SOURCE_DATE_EPOCH to 1980 so that we can use python wheels 51 # https://github.com/NixOS/nixpkgs/blob/master/doc/languages-frameworks/python.section.md#python-setuppy-bdist_wheel-cannot-create-whl 52 if "SOURCE_DATE_EPOCH" not in os.environ: 53 os.environ["SOURCE_DATE_EPOCH"] = "315532800" 54 55 # Python wheel metadata files can be unstable. 56 # See https://bitbucket.org/pypa/wheel/pull-requests/74/make-the-output-of-metadata-files/diff 57 if "PYTHONHASHSEED" not in os.environ: 58 os.environ["PYTHONHASHSEED"] = "0" 59 60 61def _parse_requirement_for_extra( 62 requirement: str, 63) -> Tuple[Optional[str], Optional[Set[str]]]: 64 """Given a requirement string, returns the requirement name and set of extras, if extras specified. 65 Else, returns (None, None) 66 """ 67 68 # https://www.python.org/dev/peps/pep-0508/#grammar 69 extras_pattern = re.compile( 70 r"^\s*([0-9A-Za-z][0-9A-Za-z_.\-]*)\s*\[\s*([0-9A-Za-z][0-9A-Za-z_.\-]*(?:\s*,\s*[0-9A-Za-z][0-9A-Za-z_.\-]*)*)\s*\]" 71 ) 72 73 matches = extras_pattern.match(requirement) 74 if matches: 75 return ( 76 canonicalize_name(matches.group(1)), 77 {extra.strip() for extra in matches.group(2).split(",")}, 78 ) 79 80 return None, None 81 82 83def _setup_namespace_pkg_compatibility(wheel_dir: str) -> None: 84 """Converts native namespace packages to pkgutil-style packages 85 86 Namespace packages can be created in one of three ways. They are detailed here: 87 https://packaging.python.org/guides/packaging-namespace-packages/#creating-a-namespace-package 88 89 'pkgutil-style namespace packages' (2) and 'pkg_resources-style namespace packages' (3) works in Bazel, but 90 'native namespace packages' (1) do not. 91 92 We ensure compatibility with Bazel of method 1 by converting them into method 2. 93 94 Args: 95 wheel_dir: the directory of the wheel to convert 96 """ 97 98 namespace_pkg_dirs = namespace_pkgs.implicit_namespace_packages( 99 wheel_dir, 100 ignored_dirnames=["%s/bin" % wheel_dir], 101 ) 102 103 for ns_pkg_dir in namespace_pkg_dirs: 104 namespace_pkgs.add_pkgutil_style_namespace_pkg_init(ns_pkg_dir) 105 106 107def _extract_wheel( 108 wheel_file: str, 109 extras: Dict[str, Set[str]], 110 enable_implicit_namespace_pkgs: bool, 111 installation_dir: Path = Path("."), 112) -> None: 113 """Extracts wheel into given directory and creates py_library and filegroup targets. 114 115 Args: 116 wheel_file: the filepath of the .whl 117 installation_dir: the destination directory for installation of the wheel. 118 extras: a list of extras to add as dependencies for the installed wheel 119 enable_implicit_namespace_pkgs: if true, disables conversion of implicit namespace packages and will unzip as-is 120 """ 121 122 whl = wheel.Wheel(wheel_file) 123 whl.unzip(installation_dir) 124 125 if not enable_implicit_namespace_pkgs: 126 _setup_namespace_pkg_compatibility(installation_dir) 127 128 extras_requested = extras[whl.name] if whl.name in extras else set() 129 # Packages may create dependency cycles when specifying optional-dependencies / 'extras'. 130 # Example: github.com/google/etils/blob/a0b71032095db14acf6b33516bca6d885fe09e35/pyproject.toml#L32. 131 self_edge_dep = set([whl.name]) 132 whl_deps = sorted(whl.dependencies(extras_requested) - self_edge_dep) 133 134 with open(os.path.join(installation_dir, "metadata.json"), "w") as f: 135 metadata = { 136 "name": whl.name, 137 "version": whl.version, 138 "deps": whl_deps, 139 "entry_points": [ 140 { 141 "name": name, 142 "module": module, 143 "attribute": attribute, 144 } 145 for name, (module, attribute) in sorted(whl.entry_points().items()) 146 ], 147 } 148 json.dump(metadata, f) 149 150 151def main() -> None: 152 args = arguments.parser(description=__doc__).parse_args() 153 deserialized_args = dict(vars(args)) 154 arguments.deserialize_structured_args(deserialized_args) 155 156 _configure_reproducible_wheels() 157 158 pip_args = ( 159 [sys.executable, "-m", "pip"] 160 + (["--isolated"] if args.isolated else []) 161 + (["download", "--only-binary=:all:"] if args.download_only else ["wheel"]) 162 + ["--no-deps"] 163 + deserialized_args["extra_pip_args"] 164 ) 165 166 requirement_file = NamedTemporaryFile(mode="wb", delete=False) 167 try: 168 requirement_file.write(args.requirement.encode("utf-8")) 169 requirement_file.flush() 170 # Close the file so pip is allowed to read it when running on Windows. 171 # For more information, see: https://bugs.python.org/issue14243 172 requirement_file.close() 173 # Requirement specific args like --hash can only be passed in a requirements file, 174 # so write our single requirement into a temp file in case it has any of those flags. 175 pip_args.extend(["-r", requirement_file.name]) 176 177 env = os.environ.copy() 178 env.update(deserialized_args["environment"]) 179 # Assumes any errors are logged by pip so do nothing. This command will fail if pip fails 180 subprocess.run(pip_args, check=True, env=env) 181 finally: 182 try: 183 os.unlink(requirement_file.name) 184 except OSError as e: 185 if e.errno != errno.ENOENT: 186 raise 187 188 name, extras_for_pkg = _parse_requirement_for_extra(args.requirement) 189 extras = {name: extras_for_pkg} if extras_for_pkg and name else dict() 190 191 whl = next(iter(glob.glob("*.whl"))) 192 _extract_wheel( 193 wheel_file=whl, 194 extras=extras, 195 enable_implicit_namespace_pkgs=args.enable_implicit_namespace_pkgs, 196 ) 197 198 199if __name__ == "__main__": 200 main() 201