• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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