xref: /aosp_15_r20/external/bazelbuild-rules_python/python/private/pypi/whl_library.bzl (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
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""
16
17load("//python/private:auth.bzl", "AUTH_ATTRS", "get_auth")
18load("//python/private:envsubst.bzl", "envsubst")
19load("//python/private:is_standalone_interpreter.bzl", "is_standalone_interpreter")
20load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils")
21load(":attrs.bzl", "ATTRS", "use_isolated")
22load(":deps.bzl", "all_repo_names")
23load(":generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel")
24load(":parse_whl_name.bzl", "parse_whl_name")
25load(":patch_whl.bzl", "patch_whl")
26load(":pypi_repo_utils.bzl", "pypi_repo_utils")
27load(":whl_target_platforms.bzl", "whl_target_platforms")
28
29_CPPFLAGS = "CPPFLAGS"
30_COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools"
31_WHEEL_ENTRY_POINT_PREFIX = "rules_python_wheel_entry_point"
32
33def _get_xcode_location_cflags(rctx):
34    """Query the xcode sdk location to update cflags
35
36    Figure out if this interpreter target comes from rules_python, and patch the xcode sdk location if so.
37    Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg
38    otherwise. See https://github.com/indygreg/python-build-standalone/issues/103
39    """
40
41    # Only run on MacOS hosts
42    if not rctx.os.name.lower().startswith("mac os"):
43        return []
44
45    xcode_sdk_location = repo_utils.execute_unchecked(
46        rctx,
47        op = "GetXcodeLocation",
48        arguments = [repo_utils.which_checked(rctx, "xcode-select"), "--print-path"],
49    )
50    if xcode_sdk_location.return_code != 0:
51        return []
52
53    xcode_root = xcode_sdk_location.stdout.strip()
54    if _COMMAND_LINE_TOOLS_PATH_SLUG not in xcode_root.lower():
55        # This is a full xcode installation somewhere like /Applications/Xcode13.0.app/Contents/Developer
56        # so we need to change the path to to the macos specific tools which are in a different relative
57        # path than xcode installed command line tools.
58        xcode_root = "{}/Platforms/MacOSX.platform/Developer".format(xcode_root)
59    return [
60        "-isysroot {}/SDKs/MacOSX.sdk".format(xcode_root),
61    ]
62
63def _get_toolchain_unix_cflags(rctx, python_interpreter, logger = None):
64    """Gather cflags from a standalone toolchain for unix systems.
65
66    Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg
67    otherwise. See https://github.com/indygreg/python-build-standalone/issues/103
68    """
69
70    # Only run on Unix systems
71    if not rctx.os.name.lower().startswith(("mac os", "linux")):
72        return []
73
74    # Only update the location when using a standalone toolchain.
75    if not is_standalone_interpreter(rctx, python_interpreter, logger = logger):
76        return []
77
78    stdout = repo_utils.execute_checked_stdout(
79        rctx,
80        op = "GetPythonVersionForUnixCflags",
81        arguments = [
82            python_interpreter,
83            "-c",
84            "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}', end='')",
85        ],
86    )
87    _python_version = stdout
88    include_path = "{}/include/python{}".format(
89        python_interpreter.dirname,
90        _python_version,
91    )
92
93    return ["-isystem {}".format(include_path)]
94
95def _parse_optional_attrs(rctx, args, extra_pip_args = None):
96    """Helper function to parse common attributes of pip_repository and whl_library repository rules.
97
98    This function also serializes the structured arguments as JSON
99    so they can be passed on the command line to subprocesses.
100
101    Args:
102        rctx: Handle to the rule repository context.
103        args: A list of parsed args for the rule.
104        extra_pip_args: The pip args to pass.
105    Returns: Augmented args list.
106    """
107
108    if use_isolated(rctx, rctx.attr):
109        args.append("--isolated")
110
111    # Bazel version 7.1.0 and later (and rolling releases from version 8.0.0-pre.20240128.3)
112    # support rctx.getenv(name, default): When building incrementally, any change to the value of
113    # the variable named by name will cause this repository to be re-fetched.
114    if "getenv" in dir(rctx):
115        getenv = rctx.getenv
116    else:
117        getenv = rctx.os.environ.get
118
119    # Check for None so we use empty default types from our attrs.
120    # Some args want to be list, and some want to be dict.
121    if extra_pip_args != None:
122        args += [
123            "--extra_pip_args",
124            json.encode(struct(arg = [
125                envsubst(pip_arg, rctx.attr.envsubst, getenv)
126                for pip_arg in extra_pip_args
127            ])),
128        ]
129
130    if rctx.attr.download_only:
131        args.append("--download_only")
132
133    if rctx.attr.pip_data_exclude != None:
134        args += [
135            "--pip_data_exclude",
136            json.encode(struct(arg = rctx.attr.pip_data_exclude)),
137        ]
138
139    if rctx.attr.enable_implicit_namespace_pkgs:
140        args.append("--enable_implicit_namespace_pkgs")
141
142    if rctx.attr.environment != None:
143        args += [
144            "--environment",
145            json.encode(struct(arg = rctx.attr.environment)),
146        ]
147
148    return args
149
150def _create_repository_execution_environment(rctx, python_interpreter, logger = None):
151    """Create a environment dictionary for processes we spawn with rctx.execute.
152
153    Args:
154        rctx (repository_ctx): The repository context.
155        python_interpreter (path): The resolved python interpreter.
156        logger: Optional logger to use for operations.
157    Returns:
158        Dictionary of environment variable suitable to pass to rctx.execute.
159    """
160
161    # Gather any available CPPFLAGS values
162    cppflags = []
163    cppflags.extend(_get_xcode_location_cflags(rctx))
164    cppflags.extend(_get_toolchain_unix_cflags(rctx, python_interpreter, logger = logger))
165
166    env = {
167        "PYTHONPATH": pypi_repo_utils.construct_pythonpath(
168            rctx,
169            entries = rctx.attr._python_path_entries,
170        ),
171        _CPPFLAGS: " ".join(cppflags),
172    }
173
174    return env
175
176def _whl_library_impl(rctx):
177    logger = repo_utils.logger(rctx)
178    python_interpreter = pypi_repo_utils.resolve_python_interpreter(
179        rctx,
180        python_interpreter = rctx.attr.python_interpreter,
181        python_interpreter_target = rctx.attr.python_interpreter_target,
182    )
183    args = [
184        python_interpreter,
185        "-m",
186        "python.private.pypi.whl_installer.wheel_installer",
187        "--requirement",
188        rctx.attr.requirement,
189    ]
190    extra_pip_args = []
191    extra_pip_args.extend(rctx.attr.extra_pip_args)
192
193    # Manually construct the PYTHONPATH since we cannot use the toolchain here
194    environment = _create_repository_execution_environment(rctx, python_interpreter, logger = logger)
195
196    whl_path = None
197    if rctx.attr.whl_file:
198        whl_path = rctx.path(rctx.attr.whl_file)
199
200        # Simulate the behaviour where the whl is present in the current directory.
201        rctx.symlink(whl_path, whl_path.basename)
202        whl_path = rctx.path(whl_path.basename)
203    elif rctx.attr.urls:
204        filename = rctx.attr.filename
205        urls = rctx.attr.urls
206        if not filename:
207            _, _, filename = urls[0].rpartition("/")
208
209        if not (filename.endswith(".whl") or filename.endswith("tar.gz") or filename.endswith(".zip")):
210            if rctx.attr.filename:
211                msg = "got '{}'".format(filename)
212            else:
213                msg = "detected '{}' from url:\n{}".format(filename, urls[0])
214            fail("Only '.whl', '.tar.gz' or '.zip' files are supported, {}".format(msg))
215
216        result = rctx.download(
217            url = urls,
218            output = filename,
219            sha256 = rctx.attr.sha256,
220            auth = get_auth(rctx, urls),
221        )
222
223        if not result.success:
224            fail("could not download the '{}' from {}:\n{}".format(filename, urls, result))
225
226        if filename.endswith(".whl"):
227            whl_path = rctx.path(rctx.attr.filename)
228        else:
229            # It is an sdist and we need to tell PyPI to use a file in this directory
230            # and, allow getting build dependencies from PYTHONPATH, which we
231            # setup in this repository rule, but still download any necessary
232            # build deps from PyPI (e.g. `flit_core`) if they are missing.
233            extra_pip_args.extend(["--no-build-isolation", "--find-links", "."])
234
235    args = _parse_optional_attrs(rctx, args, extra_pip_args)
236
237    if not whl_path:
238        if rctx.attr.urls:
239            op_tmpl = "whl_library.BuildWheelFromSource({name}, {requirement})"
240        elif rctx.attr.download_only:
241            op_tmpl = "whl_library.DownloadWheel({name}, {requirement})"
242        else:
243            op_tmpl = "whl_library.ResolveRequirement({name}, {requirement})"
244
245        repo_utils.execute_checked(
246            rctx,
247            op = op_tmpl.format(name = rctx.attr.name, requirement = rctx.attr.requirement),
248            arguments = args,
249            environment = environment,
250            quiet = rctx.attr.quiet,
251            timeout = rctx.attr.timeout,
252            logger = logger,
253        )
254
255        whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
256        if not rctx.delete("whl_file.json"):
257            fail("failed to delete the whl_file.json file")
258
259    if rctx.attr.whl_patches:
260        patches = {}
261        for patch_file, json_args in rctx.attr.whl_patches.items():
262            patch_dst = struct(**json.decode(json_args))
263            if whl_path.basename in patch_dst.whls:
264                patches[patch_file] = patch_dst.patch_strip
265
266        whl_path = patch_whl(
267            rctx,
268            op = "whl_library.PatchWhl({}, {})".format(rctx.attr.name, rctx.attr.requirement),
269            python_interpreter = python_interpreter,
270            whl_path = whl_path,
271            patches = patches,
272            quiet = rctx.attr.quiet,
273            timeout = rctx.attr.timeout,
274        )
275
276    target_platforms = rctx.attr.experimental_target_platforms
277    if target_platforms:
278        parsed_whl = parse_whl_name(whl_path.basename)
279        if parsed_whl.platform_tag != "any":
280            # NOTE @aignas 2023-12-04: if the wheel is a platform specific
281            # wheel, we only include deps for that target platform
282            target_platforms = [
283                p.target_platform
284                for p in whl_target_platforms(
285                    platform_tag = parsed_whl.platform_tag,
286                    abi_tag = parsed_whl.abi_tag,
287                )
288            ]
289
290    repo_utils.execute_checked(
291        rctx,
292        op = "whl_library.ExtractWheel({}, {})".format(rctx.attr.name, whl_path),
293        arguments = args + [
294            "--whl-file",
295            whl_path,
296        ] + ["--platform={}".format(p) for p in target_platforms],
297        environment = environment,
298        quiet = rctx.attr.quiet,
299        timeout = rctx.attr.timeout,
300        logger = logger,
301    )
302
303    metadata = json.decode(rctx.read("metadata.json"))
304    rctx.delete("metadata.json")
305
306    # NOTE @aignas 2024-06-22: this has to live on until we stop supporting
307    # passing `twine` as a `:pkg` library via the `WORKSPACE` builds.
308    #
309    # See ../../packaging.bzl line 190
310    entry_points = {}
311    for item in metadata["entry_points"]:
312        name = item["name"]
313        module = item["module"]
314        attribute = item["attribute"]
315
316        # There is an extreme edge-case with entry_points that end with `.py`
317        # See: https://github.com/bazelbuild/bazel/blob/09c621e4cf5b968f4c6cdf905ab142d5961f9ddc/src/test/java/com/google/devtools/build/lib/rules/python/PyBinaryConfiguredTargetTest.java#L174
318        entry_point_without_py = name[:-3] + "_py" if name.endswith(".py") else name
319        entry_point_target_name = (
320            _WHEEL_ENTRY_POINT_PREFIX + "_" + entry_point_without_py
321        )
322        entry_point_script_name = entry_point_target_name + ".py"
323
324        rctx.file(
325            entry_point_script_name,
326            _generate_entry_point_contents(module, attribute),
327        )
328        entry_points[entry_point_without_py] = entry_point_script_name
329
330    build_file_contents = generate_whl_library_build_bazel(
331        dep_template = rctx.attr.dep_template or "@{}{{name}}//:{{target}}".format(rctx.attr.repo_prefix),
332        whl_name = whl_path.basename,
333        dependencies = metadata["deps"],
334        dependencies_by_platform = metadata["deps_by_platform"],
335        group_name = rctx.attr.group_name,
336        group_deps = rctx.attr.group_deps,
337        data_exclude = rctx.attr.pip_data_exclude,
338        tags = [
339            "pypi_name=" + metadata["name"],
340            "pypi_version=" + metadata["version"],
341        ],
342        entry_points = entry_points,
343        annotation = None if not rctx.attr.annotation else struct(**json.decode(rctx.read(rctx.attr.annotation))),
344    )
345    rctx.file("BUILD.bazel", build_file_contents)
346
347    return
348
349def _generate_entry_point_contents(
350        module,
351        attribute,
352        shebang = "#!/usr/bin/env python3"):
353    """Generate the contents of an entry point script.
354
355    Args:
356        module (str): The name of the module to use.
357        attribute (str): The name of the attribute to call.
358        shebang (str, optional): The shebang to use for the entry point python
359            file.
360
361    Returns:
362        str: A string of python code.
363    """
364    contents = """\
365{shebang}
366import sys
367from {module} import {attribute}
368if __name__ == "__main__":
369    sys.exit({attribute}())
370""".format(
371        shebang = shebang,
372        module = module,
373        attribute = attribute,
374    )
375    return contents
376
377# NOTE @aignas 2024-03-21: The usage of dict({}, **common) ensures that all args to `dict` are unique
378whl_library_attrs = dict({
379    "annotation": attr.label(
380        doc = (
381            "Optional json encoded file containing annotation to apply to the extracted wheel. " +
382            "See `package_annotation`"
383        ),
384        allow_files = True,
385    ),
386    "dep_template": attr.string(
387        doc = """
388The dep template to use for referencing the dependencies. It should have `{name}`
389and `{target}` tokens that will be replaced with the normalized distribution name
390and the target that we need respectively.
391""",
392    ),
393    "filename": attr.string(
394        doc = "Download the whl file to this filename. Only used when the `urls` is passed. If not specified, will be auto-detected from the `urls`.",
395    ),
396    "group_deps": attr.string_list(
397        doc = "List of dependencies to skip in order to break the cycles within a dependency group.",
398        default = [],
399    ),
400    "group_name": attr.string(
401        doc = "Name of the group, if any.",
402    ),
403    "repo": attr.string(
404        mandatory = True,
405        doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.",
406    ),
407    "repo_prefix": attr.string(
408        doc = """
409Prefix for the generated packages will be of the form `@<prefix><sanitized-package-name>//...`
410
411DEPRECATED. Only left for people who vendor requirements.bzl.
412""",
413    ),
414    "requirement": attr.string(
415        mandatory = True,
416        doc = "Python requirement string describing the package to make available, if 'urls' or 'whl_file' is given, then this only needs to include foo[any_extras] as a bare minimum.",
417    ),
418    "sha256": attr.string(
419        doc = "The sha256 of the downloaded whl. Only used when the `urls` is passed.",
420    ),
421    "urls": attr.string_list(
422        doc = """\
423The list of urls of the whl to be downloaded using bazel downloader. Using this
424attr makes `extra_pip_args` and `download_only` ignored.""",
425    ),
426    "whl_file": attr.label(
427        doc = "The whl file that should be used instead of downloading or building the whl.",
428    ),
429    "whl_patches": attr.label_keyed_string_dict(
430        doc = """a label-keyed-string dict that has
431            json.encode(struct([whl_file], patch_strip]) as values. This
432            is to maintain flexibility and correct bzlmod extension interface
433            until we have a better way to define whl_library and move whl
434            patching to a separate place. INTERNAL USE ONLY.""",
435    ),
436    "_python_path_entries": attr.label_list(
437        # Get the root directory of these rules and keep them as a default attribute
438        # in order to avoid unnecessary repository fetching restarts.
439        #
440        # This is very similar to what was done in https://github.com/bazelbuild/rules_go/pull/3478
441        default = [
442            Label("//:BUILD.bazel"),
443        ] + [
444            # Includes all the external dependencies from repositories.bzl
445            Label("@" + repo + "//:BUILD.bazel")
446            for repo in all_repo_names
447        ],
448    ),
449    "_rule_name": attr.string(default = "whl_library"),
450}, **ATTRS)
451whl_library_attrs.update(AUTH_ATTRS)
452
453whl_library = repository_rule(
454    attrs = whl_library_attrs,
455    doc = """
456Download and extracts a single wheel based into a bazel repo based on the requirement string passed in.
457Instantiated from pip_repository and inherits config options from there.""",
458    implementation = _whl_library_impl,
459    environ = [
460        "RULES_PYTHON_PIP_ISOLATED",
461        REPO_DEBUG_ENV_VAR,
462    ],
463)
464