diff options
Diffstat (limited to 'python/pip_install/pip_repository.bzl')
-rw-r--r-- | python/pip_install/pip_repository.bzl | 622 |
1 files changed, 411 insertions, 211 deletions
diff --git a/python/pip_install/pip_repository.bzl b/python/pip_install/pip_repository.bzl index 99d1fb0..3e4878b 100644 --- a/python/pip_install/pip_repository.bzl +++ b/python/pip_install/pip_repository.bzl @@ -14,19 +14,29 @@ "" -load("//python:repositories.bzl", "get_interpreter_dirname", "is_standalone_interpreter") +load("@bazel_skylib//lib:sets.bzl", "sets") +load("//python:repositories.bzl", "is_standalone_interpreter") load("//python:versions.bzl", "WINDOWS_NAME") load("//python/pip_install:repositories.bzl", "all_requirements") load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse") +load("//python/pip_install/private:generate_group_library_build_bazel.bzl", "generate_group_library_build_bazel") +load("//python/pip_install/private:generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel") load("//python/pip_install/private:srcs.bzl", "PIP_INSTALL_PY_SRCS") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") load("//python/private:normalize_name.bzl", "normalize_name") +load("//python/private:parse_whl_name.bzl", "parse_whl_name") +load("//python/private:patch_whl.bzl", "patch_whl") +load("//python/private:render_pkg_aliases.bzl", "render_pkg_aliases") load("//python/private:toolchains_repo.bzl", "get_host_os_arch") +load("//python/private:which.bzl", "which_with_fail") +load("//python/private:whl_target_platforms.bzl", "whl_target_platforms") CPPFLAGS = "CPPFLAGS" COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools" +_WHEEL_ENTRY_POINT_PREFIX = "rules_python_wheel_entry_point" + def _construct_pypath(rctx): """Helper function to construct a PYTHONPATH. @@ -35,18 +45,15 @@ def _construct_pypath(rctx): Args: rctx: Handle to the repository_context. + Returns: String of the PYTHONPATH. """ - # Get the root directory of these rules - rules_root = rctx.path(Label("//:BUILD.bazel")).dirname - thirdparty_roots = [ - # Includes all the external dependencies from repositories.bzl - rctx.path(Label("@" + repo + "//:BUILD.bazel")).dirname - for repo in all_requirements - ] separator = ":" if not "windows" in rctx.os.name.lower() else ";" - pypath = separator.join([str(p) for p in [rules_root] + thirdparty_roots]) + pypath = separator.join([ + str(rctx.path(entry).dirname) + for entry in rctx.attr._python_path_entries + ]) return pypath def _get_python_interpreter_attr(rctx): @@ -71,7 +78,9 @@ def _resolve_python_interpreter(rctx): Args: rctx: Handle to the rule repository context. - Returns: Python interpreter path. + + Returns: + `path` object, for the resolved path to the Python interpreter. """ python_interpreter = _get_python_interpreter_attr(rctx) @@ -86,10 +95,13 @@ def _resolve_python_interpreter(rctx): if os == WINDOWS_NAME: python_interpreter = python_interpreter.realpath elif "/" not in python_interpreter: + # It's a plain command, e.g. "python3", to look up in the environment. found_python_interpreter = rctx.which(python_interpreter) if not found_python_interpreter: fail("python interpreter `{}` not found in PATH".format(python_interpreter)) python_interpreter = found_python_interpreter + else: + python_interpreter = rctx.path(python_interpreter) return python_interpreter def _get_xcode_location_cflags(rctx): @@ -104,10 +116,7 @@ def _get_xcode_location_cflags(rctx): if not rctx.os.name.lower().startswith("mac os"): return [] - # Locate xcode-select - xcode_select = rctx.which("xcode-select") - - xcode_sdk_location = rctx.execute([xcode_select, "--print-path"]) + xcode_sdk_location = rctx.execute([which_with_fail("xcode-select", rctx), "--print-path"]) if xcode_sdk_location.return_code != 0: return [] @@ -121,7 +130,7 @@ def _get_xcode_location_cflags(rctx): "-isysroot {}/SDKs/MacOSX.sdk".format(xcode_root), ] -def _get_toolchain_unix_cflags(rctx): +def _get_toolchain_unix_cflags(rctx, python_interpreter): """Gather cflags from a standalone toolchain for unix systems. Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg @@ -133,11 +142,11 @@ def _get_toolchain_unix_cflags(rctx): return [] # Only update the location when using a standalone toolchain. - if not is_standalone_interpreter(rctx, rctx.attr.python_interpreter_target): + if not is_standalone_interpreter(rctx, python_interpreter): return [] er = rctx.execute([ - rctx.path(rctx.attr.python_interpreter_target).realpath, + python_interpreter, "-c", "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}', end='')", ]) @@ -145,7 +154,7 @@ def _get_toolchain_unix_cflags(rctx): fail("could not get python version from interpreter (status {}): {}".format(er.return_code, er.stderr)) _python_version = er.stdout include_path = "{}/include/python{}".format( - get_interpreter_dirname(rctx, rctx.attr.python_interpreter_target), + python_interpreter.dirname, _python_version, ) @@ -216,11 +225,12 @@ def _parse_optional_attrs(rctx, args): return args -def _create_repository_execution_environment(rctx): +def _create_repository_execution_environment(rctx, python_interpreter): """Create a environment dictionary for processes we spawn with rctx.execute. Args: - rctx: The repository context. + rctx (repository_ctx): The repository context. + python_interpreter (path): The resolved python interpreter. Returns: Dictionary of environment variable suitable to pass to rctx.execute. """ @@ -228,7 +238,7 @@ def _create_repository_execution_environment(rctx): # Gather any available CPPFLAGS values cppflags = [] cppflags.extend(_get_xcode_location_cflags(rctx)) - cppflags.extend(_get_toolchain_unix_cflags(rctx)) + cppflags.extend(_get_toolchain_unix_cflags(rctx, python_interpreter)) env = { "PYTHONPATH": _construct_pypath(rctx), @@ -268,163 +278,49 @@ A requirements_lock attribute must be specified, or a platform-specific lockfile """) return requirements_txt -def _pkg_aliases(rctx, repo_name, bzl_packages): - """Create alias declarations for each python dependency. - - The aliases should be appended to the pip_repository BUILD.bazel file. These aliases - allow users to use requirement() without needed a corresponding `use_repo()` for each dep - when using bzlmod. - - Args: - rctx: the repository context. - repo_name: the repository name of the parent that is visible to the users. - bzl_packages: the list of packages to setup. - """ - for name in bzl_packages: - build_content = """package(default_visibility = ["//visibility:public"]) - -alias( - name = "{name}", - actual = "@{repo_name}_{dep}//:pkg", -) - -alias( - name = "pkg", - actual = "@{repo_name}_{dep}//:pkg", -) - -alias( - name = "whl", - actual = "@{repo_name}_{dep}//:whl", -) - -alias( - name = "data", - actual = "@{repo_name}_{dep}//:data", -) - -alias( - name = "dist_info", - actual = "@{repo_name}_{dep}//:dist_info", -) -""".format( - name = name, - repo_name = repo_name, - dep = name, - ) - rctx.file("{}/BUILD.bazel".format(name), build_content) - -def _create_pip_repository_bzlmod(rctx, bzl_packages, requirements): - repo_name = rctx.attr.repo_name - build_contents = _BUILD_FILE_CONTENTS - _pkg_aliases(rctx, repo_name, bzl_packages) - - # NOTE: we are using the canonical name with the double '@' in order to - # always uniquely identify a repository, as the labels are being passed as - # a string and the resolution of the label happens at the call-site of the - # `requirement`, et al. macros. - macro_tmpl = "@@{name}//{{}}:{{}}".format(name = rctx.attr.name) - - rctx.file("BUILD.bazel", build_contents) - rctx.template("requirements.bzl", rctx.attr._template, substitutions = { - "%%ALL_DATA_REQUIREMENTS%%": _format_repr_list([ - macro_tmpl.format(p, "data") - for p in bzl_packages - ]), - "%%ALL_REQUIREMENTS%%": _format_repr_list([ - macro_tmpl.format(p, p) - for p in bzl_packages - ]), - "%%ALL_WHL_REQUIREMENTS%%": _format_repr_list([ - macro_tmpl.format(p, "whl") - for p in bzl_packages - ]), - "%%MACRO_TMPL%%": macro_tmpl, - "%%NAME%%": rctx.attr.name, - "%%REQUIREMENTS_LOCK%%": requirements, - }) - -def _pip_hub_repository_bzlmod_impl(rctx): - bzl_packages = rctx.attr.whl_library_alias_names - _create_pip_repository_bzlmod(rctx, bzl_packages, "") - -pip_hub_repository_bzlmod_attrs = { - "repo_name": attr.string( - mandatory = True, - doc = "The apparent name of the repo. This is needed because in bzlmod, the name attribute becomes the canonical name.", - ), - "whl_library_alias_names": attr.string_list( - mandatory = True, - doc = "The list of whl alias that we use to build aliases and the whl names", - ), - "_template": attr.label( - default = ":pip_hub_repository_requirements_bzlmod.bzl.tmpl", - ), -} - -pip_hub_repository_bzlmod = repository_rule( - attrs = pip_hub_repository_bzlmod_attrs, - doc = """A rule for bzlmod mulitple pip repository creation. PRIVATE USE ONLY.""", - implementation = _pip_hub_repository_bzlmod_impl, -) - -def _pip_repository_bzlmod_impl(rctx): +def _pip_repository_impl(rctx): requirements_txt = locked_requirements_label(rctx, rctx.attr) content = rctx.read(requirements_txt) parsed_requirements_txt = parse_requirements(content) packages = [(normalize_name(name), requirement) for name, requirement in parsed_requirements_txt.requirements] - bzl_packages = sorted([name for name, _ in packages]) - _create_pip_repository_bzlmod(rctx, bzl_packages, str(requirements_txt)) - -pip_repository_bzlmod_attrs = { - "repo_name": attr.string( - mandatory = True, - doc = "The apparent name of the repo. This is needed because in bzlmod, the name attribute becomes the canonical name", - ), - "requirements_darwin": attr.label( - allow_single_file = True, - doc = "Override the requirements_lock attribute when the host platform is Mac OS", - ), - "requirements_linux": attr.label( - allow_single_file = True, - doc = "Override the requirements_lock attribute when the host platform is Linux", - ), - "requirements_lock": attr.label( - allow_single_file = True, - doc = """ -A fully resolved 'requirements.txt' pip requirement file containing the transitive set of your dependencies. If this file is passed instead -of 'requirements' no resolve will take place and pip_repository will create individual repositories for each of your dependencies so that -wheels are fetched/built only for the targets specified by 'build/run/test'. -""", - ), - "requirements_windows": attr.label( - allow_single_file = True, - doc = "Override the requirements_lock attribute when the host platform is Windows", - ), - "_template": attr.label( - default = ":pip_repository_requirements_bzlmod.bzl.tmpl", - ), -} - -pip_repository_bzlmod = repository_rule( - attrs = pip_repository_bzlmod_attrs, - doc = """A rule for bzlmod pip_repository creation. Intended for private use only.""", - implementation = _pip_repository_bzlmod_impl, -) - -def _pip_repository_impl(rctx): - requirements_txt = locked_requirements_label(rctx, rctx.attr) - content = rctx.read(requirements_txt) - parsed_requirements_txt = parse_requirements(content) + bzl_packages = sorted([normalize_name(name) for name, _ in parsed_requirements_txt.requirements]) - packages = [(normalize_name(name), requirement) for name, requirement in parsed_requirements_txt.requirements] + # Normalize cycles first + requirement_cycles = { + name: sorted(sets.to_list(sets.make(deps))) + for name, deps in rctx.attr.experimental_requirement_cycles.items() + } - bzl_packages = sorted([name for name, _ in packages]) + # Check for conflicts between cycles _before_ we normalize package names so + # that reported errors use the names the user specified + for i in range(len(requirement_cycles)): + left_group = requirement_cycles.keys()[i] + left_deps = requirement_cycles.values()[i] + for j in range(len(requirement_cycles) - (i + 1)): + right_deps = requirement_cycles.values()[1 + i + j] + right_group = requirement_cycles.keys()[1 + i + j] + for d in left_deps: + if d in right_deps: + fail("Error: Requirement %s cannot be repeated between cycles %s and %s; please merge the cycles." % (d, left_group, right_group)) + + # And normalize the names as used in the cycle specs + # + # NOTE: We must check that a listed dependency is actually in the actual + # requirements set for the current platform so that we can support cycles in + # platform-conditional requirements. Otherwise we'll blindly generate a + # label referencing a package which may not be installed on the current + # platform. + requirement_cycles = { + normalize_name(name): sorted([normalize_name(d) for d in group if normalize_name(d) in bzl_packages]) + for name, group in requirement_cycles.items() + } imports = [ - 'load("@rules_python//python/pip_install:pip_repository.bzl", "whl_library")', + # NOTE: Maintain the order consistent with `buildifier` + 'load("@rules_python//python:pip.bzl", "pip_utils")', + 'load("@rules_python//python/pip_install:pip_repository.bzl", "group_library", "whl_library")', ] annotations = {} @@ -456,28 +352,37 @@ def _pip_repository_impl(rctx): if rctx.attr.python_interpreter_target: config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target) + if rctx.attr.experimental_target_platforms: + config["experimental_target_platforms"] = rctx.attr.experimental_target_platforms if rctx.attr.incompatible_generate_aliases: - _pkg_aliases(rctx, rctx.attr.name, bzl_packages) + macro_tmpl = "@%s//{}:{}" % rctx.attr.name + aliases = render_pkg_aliases(repo_name = rctx.attr.name, bzl_packages = bzl_packages) + for path, contents in aliases.items(): + rctx.file(path, contents) + else: + macro_tmpl = "@%s_{}//:{}" % rctx.attr.name rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS) rctx.template("requirements.bzl", rctx.attr._template, substitutions = { "%%ALL_DATA_REQUIREMENTS%%": _format_repr_list([ - "@{}//{}:data".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:data".format(rctx.attr.name, p) + macro_tmpl.format(p, "data") for p in bzl_packages ]), "%%ALL_REQUIREMENTS%%": _format_repr_list([ - "@{}//{}".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:pkg".format(rctx.attr.name, p) + macro_tmpl.format(p, "pkg") for p in bzl_packages ]), - "%%ALL_WHL_REQUIREMENTS%%": _format_repr_list([ - "@{}//{}:whl".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:whl".format(rctx.attr.name, p) + "%%ALL_REQUIREMENT_GROUPS%%": _format_dict(_repr_dict(requirement_cycles)), + "%%ALL_WHL_REQUIREMENTS_BY_PACKAGE%%": _format_dict(_repr_dict({ + p: macro_tmpl.format(p, "whl") for p in bzl_packages - ]), + })), "%%ANNOTATIONS%%": _format_dict(_repr_dict(annotations)), "%%CONFIG%%": _format_dict(_repr_dict(config)), "%%EXTRA_PIP_ARGS%%": json.encode(options), - "%%IMPORTS%%": "\n".join(sorted(imports)), + "%%IMPORTS%%": "\n".join(imports), + "%%MACRO_TMPL%%": macro_tmpl, "%%NAME%%": rctx.attr.name, "%%PACKAGES%%": _format_repr_list( [ @@ -522,6 +427,86 @@ can be passed. """, default = {}, ), + "experimental_requirement_cycles": attr.string_list_dict( + default = {}, + doc = """\ +A mapping of dependency cycle names to a list of requirements which form that cycle. + +Requirements which form cycles will be installed together and taken as +dependencies together in order to ensure that the cycle is always satisified. + +Example: + `sphinx` depends on `sphinxcontrib-serializinghtml` + When listing both as requirements, ala + + ``` + py_binary( + name = "doctool", + ... + deps = [ + "@pypi//sphinx:pkg", + "@pypi//sphinxcontrib_serializinghtml", + ] + ) + ``` + + Will produce a Bazel error such as + + ``` + ERROR: .../external/pypi_sphinxcontrib_serializinghtml/BUILD.bazel:44:6: in alias rule @pypi_sphinxcontrib_serializinghtml//:pkg: cycle in dependency graph: + //:doctool (...) + @pypi//sphinxcontrib_serializinghtml:pkg (...) + .-> @pypi_sphinxcontrib_serializinghtml//:pkg (...) + | @pypi_sphinxcontrib_serializinghtml//:_pkg (...) + | @pypi_sphinx//:pkg (...) + | @pypi_sphinx//:_pkg (...) + `-- @pypi_sphinxcontrib_serializinghtml//:pkg (...) + ``` + + Which we can resolve by configuring these two requirements to be installed together as a cycle + + ``` + pip_parse( + ... + experimental_requirement_cycles = { + "sphinx": [ + "sphinx", + "sphinxcontrib-serializinghtml", + ] + }, + ) + ``` + +Warning: + If a dependency participates in multiple cycles, all of those cycles must be + collapsed down to one. For instance `a <-> b` and `a <-> c` cannot be listed + as two separate cycles. +""", + ), + "experimental_target_platforms": attr.string_list( + default = [], + doc = """\ +A list of platforms that we will generate the conditional dependency graph for +cross platform wheels by parsing the wheel metadata. This will generate the +correct dependencies for packages like `sphinx` or `pylint`, which include +`colorama` when installed and used on Windows platforms. + +An empty list means falling back to the legacy behaviour where the host +platform is the target platform. + +WARNING: It may not work as expected in cases where the python interpreter +implementation that is being used at runtime is different between different platforms. +This has been tested for CPython only. + +Special values: `all` (for generating deps for all platforms), `host` (for +generating deps for the host platform only). `linux_*` and other `<os>_*` values. +In the future we plan to set `all` as the default to this attribute. + +For specific target platforms use values of the form `<os>_<arch>` where `<os>` +is one of `linux`, `osx`, `windows` and arch is one of `x86_64`, `x86_32`, +`aarch64`, `s390x` and `ppc64le`. +""", + ), "extra_pip_args": attr.string_list( doc = "Extra arguments to pass on to pip. Must not contain spaces.", ), @@ -580,8 +565,21 @@ pip_repository_attrs = { doc = "Optional annotations to apply to packages", ), "incompatible_generate_aliases": attr.bool( - default = False, - doc = "Allow generating aliases '@pip//<pkg>' -> '@pip_<pkg>//:pkg'.", + default = True, + doc = """\ +If true, extra aliases will be created in the main `hub` repo - i.e. the repo +where the `requirements.bzl` is located. This means that for a Python package +`PyYAML` initialized within a `pip` `hub_repo` there will be the following +aliases generated: +- `@pip//pyyaml` will point to `@pip_pyyaml//:pkg` +- `@pip//pyyaml:data` will point to `@pip_pyyaml//:data` +- `@pip//pyyaml:dist_info` will point to `@pip_pyyaml//:dist_info` +- `@pip//pyyaml:pkg` will point to `@pip_pyyaml//:pkg` +- `@pip//pyyaml:whl` will point to `@pip_pyyaml//:whl` + +This is to keep the dependencies coming from PyPI to have more ergonomic label +names and support smooth transition to `bzlmod`. +""", ), "requirements_darwin": attr.label( allow_single_file = True, @@ -593,10 +591,14 @@ pip_repository_attrs = { ), "requirements_lock": attr.label( allow_single_file = True, - doc = """ -A fully resolved 'requirements.txt' pip requirement file containing the transitive set of your dependencies. If this file is passed instead -of 'requirements' no resolve will take place and pip_repository will create individual repositories for each of your dependencies so that -wheels are fetched/built only for the targets specified by 'build/run/test'. + doc = """\ +A fully resolved 'requirements.txt' pip requirement file containing the +transitive set of your dependencies. If this file is passed instead of +'requirements' no resolve will take place and pip_repository will create +individual repositories for each of your dependencies so that wheels are +fetched/built only for the targets specified by 'build/run/test'. Note that if +your lockfile is platform-dependent, you can use the `requirements_[platform]` +attributes. """, ), "requirements_windows": attr.label( @@ -612,22 +614,31 @@ pip_repository_attrs.update(**common_attrs) pip_repository = repository_rule( attrs = pip_repository_attrs, - doc = """A rule for importing `requirements.txt` dependencies into Bazel. + doc = """Accepts a locked/compiled requirements file and installs the dependencies listed within. + +Those dependencies become available in a generated `requirements.bzl` file. +You can instead check this `requirements.bzl` file into your repo, see the "vendoring" section below. + +In your WORKSPACE file: -This rule imports a `requirements.txt` file and generates a new -`requirements.bzl` file. This is used via the `WORKSPACE` pattern: +```starlark +load("@rules_python//python:pip.bzl", "pip_parse") -```python -pip_repository( - name = "foo", - requirements = ":requirements.txt", +pip_parse( + name = "pip_deps", + requirements_lock = ":requirements.txt", ) + +load("@pip_deps//:requirements.bzl", "install_deps") + +install_deps() ``` -You can then reference imported dependencies from your `BUILD` file with: +You can then reference installed dependencies from a `BUILD` file with: + +```starlark +load("@pip_deps//:requirements.bzl", "requirement") -```python -load("@foo//:requirements.bzl", "requirement") py_library( name = "bar", ... @@ -639,17 +650,52 @@ py_library( ) ``` -Or alternatively: -```python -load("@foo//:requirements.bzl", "all_requirements") -py_binary( - name = "baz", - ... - deps = [ - ":foo", - ] + all_requirements, +In addition to the `requirement` macro, which is used to access the generated `py_library` +target generated from a package's wheel, The generated `requirements.bzl` file contains +functionality for exposing [entry points][whl_ep] as `py_binary` targets as well. + +[whl_ep]: https://packaging.python.org/specifications/entry-points/ + +```starlark +load("@pip_deps//:requirements.bzl", "entry_point") + +alias( + name = "pip-compile", + actual = entry_point( + pkg = "pip-tools", + script = "pip-compile", + ), ) ``` + +Note that for packages whose name and script are the same, only the name of the package +is needed when calling the `entry_point` macro. + +```starlark +load("@pip_deps//:requirements.bzl", "entry_point") + +alias( + name = "flake8", + actual = entry_point("flake8"), +) +``` + +### Vendoring the requirements.bzl file + +In some cases you may not want to generate the requirements.bzl file as a repository rule +while Bazel is fetching dependencies. For example, if you produce a reusable Bazel module +such as a ruleset, you may want to include the requirements.bzl file rather than make your users +install the WORKSPACE setup to generate it. +See https://github.com/bazelbuild/rules_python/issues/608 + +This is the same workflow as Gazelle, which creates `go_repository` rules with +[`update-repos`](https://github.com/bazelbuild/bazel-gazelle#update-repos) + +To do this, use the "write to source file" pattern documented in +https://blog.aspect.dev/bazel-can-write-to-the-source-folder +to put a copy of the generated requirements.bzl into your project. +Then load the requirements.bzl file directly rather than from the generated repository. +See the example in rules_python/examples/pip_parse_vendored. """, implementation = _pip_repository_impl, environ = common_env, @@ -663,23 +709,59 @@ def _whl_library_impl(rctx): "python.pip_install.tools.wheel_installer.wheel_installer", "--requirement", rctx.attr.requirement, - "--repo", - rctx.attr.repo, - "--repo-prefix", - rctx.attr.repo_prefix, ] - if rctx.attr.annotation: - args.extend([ - "--annotation", - rctx.path(rctx.attr.annotation), - ]) args = _parse_optional_attrs(rctx, args) + # Manually construct the PYTHONPATH since we cannot use the toolchain here + environment = _create_repository_execution_environment(rctx, python_interpreter) + result = rctx.execute( args, - # Manually construct the PYTHONPATH since we cannot use the toolchain here - environment = _create_repository_execution_environment(rctx), + environment = environment, + quiet = rctx.attr.quiet, + timeout = rctx.attr.timeout, + ) + if result.return_code: + fail("whl_library %s failed: %s (%s) error code: '%s'" % (rctx.attr.name, result.stdout, result.stderr, result.return_code)) + + whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"]) + if not rctx.delete("whl_file.json"): + fail("failed to delete the whl_file.json file") + + if rctx.attr.whl_patches: + patches = {} + for patch_file, json_args in rctx.attr.whl_patches.items(): + patch_dst = struct(**json.decode(json_args)) + if whl_path.basename in patch_dst.whls: + patches[patch_file] = patch_dst.patch_strip + + whl_path = patch_whl( + rctx, + python_interpreter = python_interpreter, + whl_path = whl_path, + patches = patches, + quiet = rctx.attr.quiet, + timeout = rctx.attr.timeout, + ) + + target_platforms = rctx.attr.experimental_target_platforms + if target_platforms: + parsed_whl = parse_whl_name(whl_path.basename) + if parsed_whl.platform_tag != "any": + # NOTE @aignas 2023-12-04: if the wheel is a platform specific + # wheel, we only include deps for that target platform + target_platforms = [ + "{}_{}".format(p.os, p.cpu) + for p in whl_target_platforms(parsed_whl.platform_tag) + ] + + result = rctx.execute( + args + [ + "--whl-file", + whl_path, + ] + ["--platform={}".format(p) for p in target_platforms], + environment = environment, quiet = rctx.attr.quiet, timeout = rctx.attr.timeout, ) @@ -687,8 +769,76 @@ def _whl_library_impl(rctx): if result.return_code: fail("whl_library %s failed: %s (%s) error code: '%s'" % (rctx.attr.name, result.stdout, result.stderr, result.return_code)) + metadata = json.decode(rctx.read("metadata.json")) + rctx.delete("metadata.json") + + entry_points = {} + for item in metadata["entry_points"]: + name = item["name"] + module = item["module"] + attribute = item["attribute"] + + # There is an extreme edge-case with entry_points that end with `.py` + # See: https://github.com/bazelbuild/bazel/blob/09c621e4cf5b968f4c6cdf905ab142d5961f9ddc/src/test/java/com/google/devtools/build/lib/rules/python/PyBinaryConfiguredTargetTest.java#L174 + entry_point_without_py = name[:-3] + "_py" if name.endswith(".py") else name + entry_point_target_name = ( + _WHEEL_ENTRY_POINT_PREFIX + "_" + entry_point_without_py + ) + entry_point_script_name = entry_point_target_name + ".py" + + rctx.file( + entry_point_script_name, + _generate_entry_point_contents(module, attribute), + ) + entry_points[entry_point_without_py] = entry_point_script_name + + build_file_contents = generate_whl_library_build_bazel( + repo_prefix = rctx.attr.repo_prefix, + whl_name = whl_path.basename, + dependencies = metadata["deps"], + dependencies_by_platform = metadata["deps_by_platform"], + group_name = rctx.attr.group_name, + group_deps = rctx.attr.group_deps, + data_exclude = rctx.attr.pip_data_exclude, + tags = [ + "pypi_name=" + metadata["name"], + "pypi_version=" + metadata["version"], + ], + entry_points = entry_points, + annotation = None if not rctx.attr.annotation else struct(**json.decode(rctx.read(rctx.attr.annotation))), + ) + rctx.file("BUILD.bazel", build_file_contents) + return +def _generate_entry_point_contents( + module, + attribute, + shebang = "#!/usr/bin/env python3"): + """Generate the contents of an entry point script. + + Args: + module (str): The name of the module to use. + attribute (str): The name of the attribute to call. + shebang (str, optional): The shebang to use for the entry point python + file. + + Returns: + str: A string of python code. + """ + contents = """\ +{shebang} +import sys +from {module} import {attribute} +if __name__ == "__main__": + sys.exit({attribute}()) +""".format( + shebang = shebang, + module = module, + attribute = attribute, + ) + return contents + whl_library_attrs = { "annotation": attr.label( doc = ( @@ -697,6 +847,13 @@ whl_library_attrs = { ), allow_files = True, ), + "group_deps": attr.string_list( + doc = "List of dependencies to skip in order to break the cycles within a dependency group.", + default = [], + ), + "group_name": attr.string( + doc = "Name of the group, if any.", + ), "repo": attr.string( mandatory = True, doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.", @@ -705,6 +862,26 @@ whl_library_attrs = { mandatory = True, doc = "Python requirement string describing the package to make available", ), + "whl_patches": attr.label_keyed_string_dict( + doc = """a label-keyed-string dict that has + json.encode(struct([whl_file], patch_strip]) as values. This + is to maintain flexibility and correct bzlmod extension interface + until we have a better way to define whl_library and move whl + patching to a separate place. INTERNAL USE ONLY.""", + ), + "_python_path_entries": attr.label_list( + # Get the root directory of these rules and keep them as a default attribute + # in order to avoid unnecessary repository fetching restarts. + # + # This is very similar to what was done in https://github.com/bazelbuild/rules_go/pull/3478 + default = [ + Label("//:BUILD.bazel"), + ] + [ + # Includes all the external dependencies from repositories.bzl + Label("@" + repo + "//:BUILD.bazel") + for repo in all_requirements + ], + ), } whl_library_attrs.update(**common_attrs) @@ -752,6 +929,29 @@ def package_annotation( srcs_exclude_glob = srcs_exclude_glob, )) +def _group_library_impl(rctx): + build_file_contents = generate_group_library_build_bazel( + repo_prefix = rctx.attr.repo_prefix, + groups = rctx.attr.groups, + ) + rctx.file("BUILD.bazel", build_file_contents) + +group_library = repository_rule( + attrs = { + "groups": attr.string_list_dict( + doc = "A mapping of group names to requirements within that group.", + ), + "repo_prefix": attr.string( + doc = "Prefix used for the whl_library created components of each group", + ), + }, + implementation = _group_library_impl, + doc = """ +Create a package containing only wrapper py_library and whl_library rules for implementing dependency groups. +This is an implementation detail of dependency groups and should not be used alone. + """, +) + # pip_repository implementation def _format_list(items): |