diff --git a/.bazelrc b/.bazelrc index e668a68822..b900d7ddb6 100644 --- a/.bazelrc +++ b/.bazelrc @@ -3,8 +3,8 @@ # This lets us glob() up all the files inside the examples to make them inputs to tests # (Note, we cannot use `common --deleted_packages` because the bazel version command doesn't support it) # To update these lines, run tools/bazel_integration_test/update_deleted_packages.sh -build --deleted_packages=examples/build_file_generation,examples/pip_install,examples/pip_parse,examples/py_import,examples/relative_requirements -query --deleted_packages=examples/build_file_generation,examples/pip_install,examples/pip_parse,examples/py_import,examples/relative_requirements +build --deleted_packages=examples/build_file_generation,examples/pip_install,examples/pip_parse,examples/pip_repository_annotations,examples/py_import,examples/relative_requirements +query --deleted_packages=examples/build_file_generation,examples/pip_install,examples/pip_parse,examples/pip_repository_annotations,examples/py_import,examples/relative_requirements test --test_output=errors diff --git a/BUILD b/BUILD index 6ba643c4fd..cd19d368b5 100644 --- a/BUILD +++ b/BUILD @@ -32,6 +32,8 @@ filegroup( "//python:distribution", "//python/pip_install:distribution", "//third_party/github.com/bazelbuild/bazel-skylib/lib:distribution", + "//third_party/github.com/bazelbuild/bazel-skylib/rules:distribution", + "//third_party/github.com/bazelbuild/bazel-skylib/rules/private:distribution", "//tools:distribution", ], visibility = ["//examples:__pkg__"], diff --git a/docs/pip.md b/docs/pip.md index ce865a6980..baf7003b26 100644 --- a/docs/pip.md +++ b/docs/pip.md @@ -35,6 +35,33 @@ It also generates two targets for running pip-compile: | kwargs | other bazel attributes passed to the "_test" rule | none | + + +## package_annotation + +
+package_annotation(build_content, copy_files, copy_executables, data, data_exclude_glob,
+                   srcs_exclude_glob)
+
+ +Annotations to apply to the BUILD file content from package generated from a `pip_repository` rule. + +[cf]: https://github.com/bazelbuild/bazel-skylib/blob/main/docs/copy_file_doc.md + + +**PARAMETERS** + + +| Name | Description | Default Value | +| :-------------: | :-------------: | :-------------: | +| build_content | Raw text to add to the generated BUILD file of a package. | None | +| copy_files | A mapping of src and out files for [@bazel_skylib//rules:copy_file.bzl][cf] | {} | +| copy_executables | A mapping of src and out files for [@bazel_skylib//rules:copy_file.bzl][cf]. Targets generated here will also be flagged as executable. | {} | +| data | A list of labels to add as data dependencies to the generated py_library target. | [] | +| data_exclude_glob | A list of exclude glob patterns to add as data to the generated py_library target. | [] | +| srcs_exclude_glob | A list of labels to add as srcs to the generated py_library target. | [] | + + ## pip_install diff --git a/examples/BUILD b/examples/BUILD index 8188ca7c81..44147e5c4a 100644 --- a/examples/BUILD +++ b/examples/BUILD @@ -27,6 +27,11 @@ bazel_integration_test( timeout = "long", ) +bazel_integration_test( + name = "pip_repository_annotations_example", + timeout = "long", +) + bazel_integration_test( name = "py_import_example", timeout = "long", diff --git a/examples/pip_repository_annotations/.bazelrc b/examples/pip_repository_annotations/.bazelrc new file mode 100644 index 0000000000..9e7ef37327 --- /dev/null +++ b/examples/pip_repository_annotations/.bazelrc @@ -0,0 +1,2 @@ +# https://docs.bazel.build/versions/main/best-practices.html#using-the-bazelrc-file +try-import %workspace%/user.bazelrc diff --git a/examples/pip_repository_annotations/BUILD b/examples/pip_repository_annotations/BUILD new file mode 100644 index 0000000000..a5a0561a37 --- /dev/null +++ b/examples/pip_repository_annotations/BUILD @@ -0,0 +1,30 @@ +load("@pip_installed//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_test") +load("@rules_python//python:pip.bzl", "compile_pip_requirements") + +exports_files( + glob(["data/**"]), + visibility = ["//visibility:public"], +) + +# This rule adds a convenient way to update the requirements file. +compile_pip_requirements( + name = "requirements", + extra_args = ["--allow-unsafe"], +) + +py_test( + name = "pip_parse_annotations_test", + srcs = ["pip_repository_annotations_test.py"], + env = {"WHEEL_PKG_DIR": "pip_parsed_wheel"}, + main = "pip_repository_annotations_test.py", + deps = ["@pip_parsed_wheel//:pkg"], +) + +py_test( + name = "pip_install_annotations_test", + srcs = ["pip_repository_annotations_test.py"], + env = {"WHEEL_PKG_DIR": "pip_installed/pypi__wheel"}, + main = "pip_repository_annotations_test.py", + deps = [requirement("wheel")], +) diff --git a/examples/pip_repository_annotations/WORKSPACE b/examples/pip_repository_annotations/WORKSPACE new file mode 100644 index 0000000000..ad16e67f15 --- /dev/null +++ b/examples/pip_repository_annotations/WORKSPACE @@ -0,0 +1,58 @@ +workspace(name = "pip_repository_annotations_example") + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "rules_python", + sha256 = "cd6730ed53a002c56ce4e2f396ba3b3be262fd7cb68339f0377a45e8227fe332", + url = "https://github.com/bazelbuild/rules_python/releases/download/0.5.0/rules_python-0.5.0.tar.gz", +) + +http_archive( + name = "bazel_skylib", + sha256 = "c6966ec828da198c5d9adbaa94c05e3a1c7f21bd012a0b29ba8ddbccb2c93b0d", + urls = [ + "https://github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz", + "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz", + ], +) + +load("@rules_python//python:pip.bzl", "package_annotation", "pip_install", "pip_parse") + +# Here we can see an example of annotations being applied to an arbitrary +# package. For details on `package_annotation` and it's uses, see the +# docs at @rules_python//docs:pip.md`. +ANNOTATIONS = { + "wheel": package_annotation( + build_content = """\ +load("@bazel_skylib//rules:write_file.bzl", "write_file") +write_file( + name = "generated_file", + out = "generated_file.txt", + content = ["Hello world from build content file"], +) +""", + copy_executables = {"@pip_repository_annotations_example//:data/copy_executable.py": "copied_content/executable.py"}, + copy_files = {"@pip_repository_annotations_example//:data/copy_file.txt": "copied_content/file.txt"}, + data = [":generated_file"], + data_exclude_glob = ["*.dist-info/RECORD"], + ), +} + +# For a more thorough example of `pip_parse`. See `@rules_python//examples/pip_parse` +pip_parse( + name = "pip_parsed", + annotations = ANNOTATIONS, + requirements_lock = "//:requirements.txt", +) + +load("@pip_parsed//:requirements.bzl", "install_deps") + +install_deps() + +# For a more thorough example of `pip_install`. See `@rules_python//examples/pip_install` +pip_install( + name = "pip_installed", + annotations = ANNOTATIONS, + requirements = "//:requirements.txt", +) diff --git a/examples/pip_repository_annotations/data/copy_executable.py b/examples/pip_repository_annotations/data/copy_executable.py new file mode 100755 index 0000000000..20c6651e5b --- /dev/null +++ b/examples/pip_repository_annotations/data/copy_executable.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python + +if __name__ == "__main__": + print("Hello world from copied executable") diff --git a/examples/pip_repository_annotations/data/copy_file.txt b/examples/pip_repository_annotations/data/copy_file.txt new file mode 100644 index 0000000000..b1020f7b95 --- /dev/null +++ b/examples/pip_repository_annotations/data/copy_file.txt @@ -0,0 +1 @@ +Hello world from copied file diff --git a/examples/pip_repository_annotations/pip_repository_annotations_test.py b/examples/pip_repository_annotations/pip_repository_annotations_test.py new file mode 100644 index 0000000000..a8f0863c9e --- /dev/null +++ b/examples/pip_repository_annotations/pip_repository_annotations_test.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import unittest +from glob import glob +from pathlib import Path + + +class PipRepositoryAnnotationsTest(unittest.TestCase): + maxDiff = None + + def wheel_pkg_dir(self) -> str: + env = os.environ.get("WHEEL_PKG_DIR") + self.assertIsNotNone(env) + return env + + def test_build_content_and_data(self): + generated_file = ( + Path.cwd() / "external" / self.wheel_pkg_dir() / "generated_file.txt" + ) + self.assertTrue(generated_file.exists()) + + content = generated_file.read_text().rstrip() + self.assertEqual(content, "Hello world from build content file") + + def test_copy_files(self): + copied_file = ( + Path.cwd() / "external" / self.wheel_pkg_dir() / "copied_content/file.txt" + ) + self.assertTrue(copied_file.exists()) + + content = copied_file.read_text().rstrip() + self.assertEqual(content, "Hello world from copied file") + + def test_copy_executables(self): + executable = ( + Path.cwd() + / "external" + / self.wheel_pkg_dir() + / "copied_content/executable.py" + ) + self.assertTrue(executable.exists()) + + proc = subprocess.run( + [executable], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout = proc.stdout.decode("utf-8").strip() + self.assertEqual(stdout, "Hello world from copied executable") + + def test_data_exclude_glob(self): + files = glob("external/" + self.wheel_pkg_dir() + "/wheel-*.dist-info/*") + basenames = [Path(path).name for path in files] + self.assertIn("WHEEL", basenames) + self.assertNotIn("RECORD", basenames) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/pip_repository_annotations/requirements.in b/examples/pip_repository_annotations/requirements.in new file mode 100644 index 0000000000..2309722a93 --- /dev/null +++ b/examples/pip_repository_annotations/requirements.in @@ -0,0 +1 @@ +wheel diff --git a/examples/pip_repository_annotations/requirements.txt b/examples/pip_repository_annotations/requirements.txt new file mode 100644 index 0000000000..51d1dfc8e2 --- /dev/null +++ b/examples/pip_repository_annotations/requirements.txt @@ -0,0 +1,10 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# bazel run //:requirements.update +# +wheel==0.37.1 \ + --hash=sha256:4bdcd7d840138086126cd09254dc6195fb4fc6f01c050a1d7236f2630db1d22a \ + --hash=sha256:e9a504e793efbca1b8e0e9cb979a249cf4a0a7b5b8c9e8b65a5e39d49529c1c4 + # via -r requirements.in diff --git a/python/pip.bzl b/python/pip.bzl index 7776efec28..0530a2c9f1 100644 --- a/python/pip.bzl +++ b/python/pip.bzl @@ -13,11 +13,12 @@ # limitations under the License. """Import pip requirements into Bazel.""" -load("//python/pip_install:pip_repository.bzl", "pip_repository") +load("//python/pip_install:pip_repository.bzl", "pip_repository", _package_annotation = "package_annotation") load("//python/pip_install:repositories.bzl", "pip_install_dependencies") load("//python/pip_install:requirements.bzl", _compile_pip_requirements = "compile_pip_requirements") compile_pip_requirements = _compile_pip_requirements +package_annotation = _package_annotation def pip_install(requirements, name = "pip", **kwargs): """Accepts a `requirements.txt` file and installs the dependencies listed within. diff --git a/python/pip_install/extract_wheels/__init__.py b/python/pip_install/extract_wheels/__init__.py index f1b72543d2..cda6d541b0 100644 --- a/python/pip_install/extract_wheels/__init__.py +++ b/python/pip_install/extract_wheels/__init__.py @@ -12,7 +12,13 @@ import subprocess import sys -from python.pip_install.extract_wheels.lib import arguments, bazel, requirements +from python.pip_install.extract_wheels.lib import ( + annotation, + arguments, + bazel, + requirements, + wheel, +) def configure_reproducible_wheels() -> None: @@ -58,6 +64,11 @@ def main() -> None: required=True, help="Path to requirements.txt from where to install dependencies", ) + parser.add_argument( + "--annotations", + type=annotation.annotations_map_from_str_path, + help="A json encoded file containing annotations for rendered packages.", + ) arguments.parse_common_args(parser) args = parser.parse_args() deserialized_args = dict(vars(args)) @@ -89,18 +100,26 @@ def main() -> None: repo_label = "@%s" % args.repo + # Locate all wheels + wheels = [whl for whl in glob.glob("*.whl")] + + # Collect all annotations + reqs = {whl: wheel.Wheel(whl).name for whl in wheels} + annotations = args.annotations.collect(reqs.values()) + targets = [ '"{}{}"'.format( repo_label, bazel.extract_wheel( - whl, - extras, - deserialized_args["pip_data_exclude"], - args.enable_implicit_namespace_pkgs, - args.repo_prefix, + wheel_file=whl, + extras=extras, + pip_data_exclude=deserialized_args["pip_data_exclude"], + enable_implicit_namespace_pkgs=args.enable_implicit_namespace_pkgs, + repo_prefix=args.repo_prefix, + annotation=annotations.get(name), ), ) - for whl in glob.glob("*.whl") + for whl, name in reqs.items() ] with open("requirements.bzl", "w") as requirement_file: diff --git a/python/pip_install/extract_wheels/lib/BUILD b/python/pip_install/extract_wheels/lib/BUILD index 2b0a91fa3d..1df1bed3aa 100644 --- a/python/pip_install/extract_wheels/lib/BUILD +++ b/python/pip_install/extract_wheels/lib/BUILD @@ -1,9 +1,11 @@ load("@rules_python//python:defs.bzl", "py_library", "py_test") load("//python/pip_install:repositories.bzl", "requirement") +load(":annotations_test_helpers.bzl", "package_annotation", "package_annotations_file") py_library( name = "lib", srcs = [ + "annotation.py", "arguments.py", "bazel.py", "namespace_pkgs.py", @@ -21,6 +23,43 @@ py_library( ], ) +package_annotations_file( + name = "mock_annotations", + annotations = { + "pkg_a": package_annotation(), + "pkg_b": package_annotation( + data_exclude_glob = [ + "*.foo", + "*.bar", + ], + ), + "pkg_c": package_annotation( + build_content = """\ +cc_library( + name = "my_target", + hdrs = glob(["**/*.h"]), + srcs = glob(["**/*.cc"]), +) +""", + data = [":my_target"], + ), + "pkg_d": package_annotation( + srcs_exclude_glob = ["pkg_d/tests/**"], + ), + }, + tags = ["manual"], +) + +py_test( + name = "annotations_test", + size = "small", + srcs = ["annotations_test.py"], + data = [":mock_annotations"], + env = {"MOCK_ANNOTATIONS": "$(rootpath :mock_annotations)"}, + tags = ["unit"], + deps = [":lib"], +) + py_test( name = "bazel_test", size = "small", diff --git a/python/pip_install/extract_wheels/lib/annotation.py b/python/pip_install/extract_wheels/lib/annotation.py new file mode 100644 index 0000000000..7ae2b348c3 --- /dev/null +++ b/python/pip_install/extract_wheels/lib/annotation.py @@ -0,0 +1,112 @@ +import json +import logging +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, List + + +class Annotation(OrderedDict): + """A python representation of `@rules_python//python:pip.bzl%package_annotation`""" + + def __init__(self, content: Dict[str, Any]) -> None: + + missing = [] + ordered_content = OrderedDict() + for field in ( + "build_content", + "copy_executables", + "copy_files", + "data", + "data_exclude_glob", + "srcs_exclude_glob", + ): + if field not in content: + missing.append(field) + continue + ordered_content.update({field: content.pop(field)}) + + if missing: + raise ValueError("Data missing from initial annotation: {}".format(missing)) + + if content: + raise ValueError( + "Unexpected data passed to annotations: {}".format( + sorted(list(content.keys())) + ) + ) + + return OrderedDict.__init__(self, ordered_content) + + @property + def build_content(self) -> str: + return self["build_content"] + + @property + def copy_executables(self) -> Dict[str, str]: + return self["copy_executables"] + + @property + def copy_files(self) -> Dict[str, str]: + return self["copy_files"] + + @property + def data(self) -> List[str]: + return self["data"] + + @property + def data_exclude_glob(self) -> List[str]: + return self["data_exclude_glob"] + + @property + def srcs_exclude_glob(self) -> List[str]: + return self["srcs_exclude_glob"] + + +class AnnotationsMap: + """A mapping of python package names to [Annotation]""" + + def __init__(self, json_file: Path): + content = json.loads(json_file.read_text()) + + self._annotations = {pkg: Annotation(data) for (pkg, data) in content.items()} + + @property + def annotations(self) -> Dict[str, Annotation]: + return self._annotations + + def collect(self, requirements: List[str]) -> Dict[str, Annotation]: + unused = self.annotations + collection = {} + for pkg in requirements: + if pkg in unused: + collection.update({pkg: unused.pop(pkg)}) + + logging.warning("Unused annotations: {}".format(sorted(list(unused.keys())))) + + return collection + + +def annotation_from_str_path(path: str) -> Annotation: + """Load an annotation from a json encoded file + + Args: + path (str): The path to a json encoded file + + Returns: + Annotation: The deserialized annotations + """ + json_file = Path(path) + content = json.loads(json_file.read_text()) + return Annotation(content) + + +def annotations_map_from_str_path(path: str) -> AnnotationsMap: + """Load an annotations map from a json encoded file + + Args: + path (str): The path to a json encoded file + + Returns: + AnnotationsMap: The deserialized annotations map + """ + return AnnotationsMap(Path(path)) diff --git a/python/pip_install/extract_wheels/lib/annotations_test.py b/python/pip_install/extract_wheels/lib/annotations_test.py new file mode 100644 index 0000000000..5b32d3cbb6 --- /dev/null +++ b/python/pip_install/extract_wheels/lib/annotations_test.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 + +import os +import textwrap +import unittest +from pathlib import Path + +from python.pip_install.extract_wheels.lib.annotation import Annotation, AnnotationsMap + + +class AnnotationsTestCase(unittest.TestCase): + + maxDiff = None + + def test_annotations_constructor(self) -> None: + annotations_env = os.environ.get("MOCK_ANNOTATIONS") + self.assertIsNotNone(annotations_env) + + annotations_path = Path.cwd() / annotations_env + self.assertTrue(annotations_path.exists()) + + annotations_map = AnnotationsMap(annotations_path) + self.assertListEqual( + list(annotations_map.annotations.keys()), + ["pkg_a", "pkg_b", "pkg_c", "pkg_d"], + ) + + collection = annotations_map.collect(["pkg_a", "pkg_b", "pkg_c", "pkg_d"]) + + self.assertEqual( + collection["pkg_a"], + Annotation( + { + "build_content": None, + "copy_executables": {}, + "copy_files": {}, + "data": [], + "data_exclude_glob": [], + "srcs_exclude_glob": [], + } + ), + ) + + self.assertEqual( + collection["pkg_b"], + Annotation( + { + "build_content": None, + "copy_executables": {}, + "copy_files": {}, + "data": [], + "data_exclude_glob": ["*.foo", "*.bar"], + "srcs_exclude_glob": [], + } + ), + ) + + self.assertEqual( + collection["pkg_c"], + Annotation( + { + "build_content": textwrap.dedent( + """\ + cc_library( + name = "my_target", + hdrs = glob(["**/*.h"]), + srcs = glob(["**/*.cc"]), + ) + """ + ), + "copy_executables": {}, + "copy_files": {}, + "data": [":my_target"], + "data_exclude_glob": [], + "srcs_exclude_glob": [], + } + ), + ) + + self.assertEqual( + collection["pkg_d"], + Annotation( + { + "build_content": None, + "copy_executables": {}, + "copy_files": {}, + "data": [], + "data_exclude_glob": [], + "srcs_exclude_glob": ["pkg_d/tests/**"], + } + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/pip_install/extract_wheels/lib/annotations_test_helpers.bzl b/python/pip_install/extract_wheels/lib/annotations_test_helpers.bzl new file mode 100644 index 0000000000..dbd1124670 --- /dev/null +++ b/python/pip_install/extract_wheels/lib/annotations_test_helpers.bzl @@ -0,0 +1,33 @@ +"""Helper macros and rules for testing the `annotations` module of `extract_wheels`""" + +load("//python:pip.bzl", _package_annotation = "package_annotation") + +package_annotation = _package_annotation + +def _package_annotations_file_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".annotations.json") + + annotations = {package: json.decode(data) for (package, data) in ctx.attr.annotations.items()} + ctx.actions.write( + output = output, + content = json.encode_indent(annotations, indent = " " * 4), + ) + + return DefaultInfo( + files = depset([output]), + runfiles = ctx.runfiles(files = [output]), + ) + +package_annotations_file = rule( + implementation = _package_annotations_file_impl, + doc = ( + "Consumes `package_annotation` definitions in the same way " + + "`pip_repository` rules do to produce an annotations file." + ), + attrs = { + "annotations": attr.string_dict( + doc = "See `@rules_python//python:pip.bzl%package_annotation", + mandatory = True, + ), + }, +) diff --git a/python/pip_install/extract_wheels/lib/bazel.py b/python/pip_install/extract_wheels/lib/bazel.py index e880c20381..f1ab8ba219 100644 --- a/python/pip_install/extract_wheels/lib/bazel.py +++ b/python/pip_install/extract_wheels/lib/bazel.py @@ -6,7 +6,12 @@ from pathlib import Path from typing import Dict, Iterable, List, Optional, Set -from python.pip_install.extract_wheels.lib import namespace_pkgs, purelib, wheel +from python.pip_install.extract_wheels.lib import ( + annotation, + namespace_pkgs, + purelib, + wheel, +) WHEEL_FILE_LABEL = "whl" PY_LIBRARY_LABEL = "pkg" @@ -77,13 +82,45 @@ def generate_entry_point_rule(script: str, pkg: str) -> str: ) +def generate_copy_commands(src, dest, is_executable=False) -> str: + """Generate a [@bazel_skylib//rules:copy_file.bzl%copy_file][cf] target + + [cf]: https://github.com/bazelbuild/bazel-skylib/blob/1.1.1/docs/copy_file_doc.md + + Args: + src (str): The label for the `src` attribute of [copy_file][cf] + dest (str): The label for the `out` attribute of [copy_file][cf] + is_executable (bool, optional): Whether or not the file being copied is executable. + sets `is_executable` for [copy_file][cf] + + Returns: + str: A `copy_file` instantiation. + """ + return textwrap.dedent( + """\ + copy_file( + name = "{dest}.copy", + src = "{src}", + out = "{dest}", + is_executable = {is_executable}, + ) + """.format( + src=src, + dest=dest, + is_executable=is_executable, + ) + ) + + def generate_build_file_contents( name: str, dependencies: List[str], whl_file_deps: List[str], - pip_data_exclude: List[str], + data_exclude: List[str], tags: List[str], - additional_targets: List[str] = [], + srcs_exclude: List[str] = [], + data: List[str] = [], + additional_content: List[str] = [], ) -> str: """Generate a BUILD file for an unzipped Wheel @@ -91,9 +128,9 @@ def generate_build_file_contents( name: the target name of the py_library dependencies: a list of Bazel labels pointing to dependencies of the library whl_file_deps: a list of Bazel labels pointing to wheel file dependencies of this wheel. - pip_data_exclude: more patterns to exclude from the data attribute of generated py_library rules. + data_exclude: more patterns to exclude from the data attribute of generated py_library rules. tags: list of tags to apply to generated py_library rules. - additional_targets: A list of additional targets to append to the BUILD file contents. + additional_content: A list of additional content to append to the BUILD file. Returns: A complete BUILD file as a string @@ -102,22 +139,28 @@ def generate_build_file_contents( there may be no Python sources whatsoever (e.g. packages written in Cython: like `pymssql`). """ - data_exclude = [ - "*.whl", - "**/__pycache__/**", - "**/*.py", - "**/*.pyc", - f"{WHEEL_ENTRY_POINT_PREFIX}*.py", - "**/* *", - "BUILD.bazel", - "WORKSPACE", - ] + pip_data_exclude + data_exclude = list( + set( + [ + "*.whl", + "**/__pycache__/**", + "**/* *", + "**/*.py", + "**/*.pyc", + "BUILD.bazel", + "WORKSPACE", + f"{WHEEL_ENTRY_POINT_PREFIX}*.py", + ] + + data_exclude + ) + ) return "\n".join( [ textwrap.dedent( """\ load("@rules_python//python:defs.bzl", "py_library", "py_binary") + load("@rules_python//third_party/github.com/bazelbuild/bazel-skylib/rules:copy_file.bzl", "copy_file") package(default_visibility = ["//visibility:public"]) @@ -139,8 +182,8 @@ def generate_build_file_contents( py_library( name = "{name}", - srcs = glob(["**/*.py"], exclude=["{entry_point_prefix}*.py", "**/__pycache__/**"], allow_empty = True), - data = glob(["**/*"], exclude={data_exclude}), + srcs = glob(["**/*.py"], exclude={srcs_exclude}, allow_empty = True), + data = {data} + glob(["**/*"], exclude={data_exclude}), # This makes this directory a top-level in the python import # search path for anything that depends on this. imports = ["."], @@ -149,18 +192,20 @@ def generate_build_file_contents( ) """.format( name=name, - dependencies=",".join(dependencies), - data_exclude=json.dumps(data_exclude), + dependencies=",".join(sorted(dependencies)), + data_exclude=json.dumps(sorted(data_exclude)), whl_file_label=WHEEL_FILE_LABEL, - whl_file_deps=",".join(whl_file_deps), - tags=",".join(['"%s"' % t for t in tags]), + whl_file_deps=",".join(sorted(whl_file_deps)), + tags=",".join(sorted(['"%s"' % t for t in tags])), data_label=DATA_LABEL, dist_info_label=DIST_INFO_LABEL, entry_point_prefix=WHEEL_ENTRY_POINT_PREFIX, + srcs_exclude=json.dumps(sorted(srcs_exclude)), + data=json.dumps(sorted(data)), ) ) ] - + additional_targets + + additional_content ) @@ -297,6 +342,7 @@ def extract_wheel( repo_prefix: str, incremental: bool = False, incremental_dir: Path = Path("."), + annotation: Optional[annotation.Annotation] = None, ) -> Optional[str]: """Extracts wheel into given directory and creates py_library and filegroup targets. @@ -308,6 +354,7 @@ def extract_wheel( incremental: If true the extract the wheel in a format suitable for an external repository. This effects the names of libraries and their dependencies, which point to other external repositories. incremental_dir: An optional override for the working directory of incremental builds. + annotation: An optional set of annotations to apply to the BUILD contents of the wheel. Returns: The Bazel label for the extracted wheel, in the form '//path/to/wheel'. @@ -367,13 +414,36 @@ def extract_wheel( ) with open(os.path.join(directory, "BUILD.bazel"), "w") as build_file: + additional_content = entry_points + data = [] + data_exclude = pip_data_exclude + srcs_exclude = [] + if annotation: + for src, dest in annotation.copy_files.items(): + data.append(dest) + additional_content.append(generate_copy_commands(src, dest)) + for src, dest in annotation.copy_executables.items(): + data.append(dest) + additional_content.append( + generate_copy_commands(src, dest, is_executable=True) + ) + data.extend(annotation.data) + data_exclude.extend(annotation.data_exclude_glob) + srcs_exclude.extend(annotation.srcs_exclude_glob) + if annotation.build_content: + additional_content.append(annotation.build_content) + contents = generate_build_file_contents( - PY_LIBRARY_LABEL if incremental else sanitise_name(whl.name, repo_prefix), - sanitised_dependencies, - sanitised_wheel_file_dependencies, - pip_data_exclude, - ["pypi_name=" + whl.name, "pypi_version=" + whl.metadata.version], - entry_points, + name=PY_LIBRARY_LABEL + if incremental + else sanitise_name(whl.name, repo_prefix), + dependencies=sanitised_dependencies, + whl_file_deps=sanitised_wheel_file_dependencies, + data_exclude=data_exclude, + data=data, + srcs_exclude=srcs_exclude, + tags=["pypi_name=" + whl.name, "pypi_version=" + whl.metadata.version], + additional_content=additional_content, ) build_file.write(contents) diff --git a/python/pip_install/parse_requirements_to_bzl/__init__.py b/python/pip_install/parse_requirements_to_bzl/__init__.py index 1e1261de7c..7a2338442c 100644 --- a/python/pip_install/parse_requirements_to_bzl/__init__.py +++ b/python/pip_install/parse_requirements_to_bzl/__init__.py @@ -3,20 +3,20 @@ import shlex import sys import textwrap -from typing import List, Tuple +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple from pip._internal.network.session import PipSession -from pip._internal.req import constructors, parse_requirements +from pip._internal.req import constructors from pip._internal.req.req_file import ( RequirementsFileParser, get_file_content, get_line_parser, - handle_line, preprocess, ) from pip._internal.req.req_install import InstallRequirement -from python.pip_install.extract_wheels.lib import arguments, bazel +from python.pip_install.extract_wheels.lib import annotation, arguments, bazel def parse_install_requirements( @@ -56,7 +56,25 @@ def repo_names_and_requirements( ] -def generate_parsed_requirements_contents(all_args: argparse.Namespace) -> str: +def parse_whl_library_args(args: argparse.Namespace) -> Dict[str, Any]: + whl_library_args = dict(vars(args)) + whl_library_args = arguments.deserialize_structured_args(whl_library_args) + whl_library_args.setdefault("python_interpreter", sys.executable) + + # These arguments are not used by `whl_library` + for arg in ("requirements_lock", "annotations"): + if arg in whl_library_args: + whl_library_args.pop(arg) + + return whl_library_args + + +def generate_parsed_requirements_contents( + requirements_lock: Path, + repo_prefix: str, + whl_library_args: Dict[str, Any], + annotations: Dict[str, str] = dict(), +) -> str: """ Parse each requirement from the requirements_lock file, and prepare arguments for each repository rule, which will represent the individual requirements. @@ -64,28 +82,21 @@ def generate_parsed_requirements_contents(all_args: argparse.Namespace) -> str: Generates a requirements.bzl file containing a macro (install_deps()) which instantiates a repository rule for each requirment in the lock file. """ - - args = dict(vars(all_args)) - args = arguments.deserialize_structured_args(args) - args.setdefault("python_interpreter", sys.executable) - # Pop this off because it wont be used as a config argument to the whl_library rule. - requirements_lock = args.pop("requirements_lock") - install_req_and_lines = parse_install_requirements( - requirements_lock, args["extra_pip_args"] + requirements_lock, whl_library_args["extra_pip_args"] ) repo_names_and_reqs = repo_names_and_requirements( - install_req_and_lines, args["repo_prefix"] + install_req_and_lines, repo_prefix ) all_requirements = ", ".join( [ - bazel.sanitised_repo_library_label(ir.name, repo_prefix=args["repo_prefix"]) + bazel.sanitised_repo_library_label(ir.name, repo_prefix=repo_prefix) for ir, _ in install_req_and_lines ] ) all_whl_requirements = ", ".join( [ - bazel.sanitised_repo_file_label(ir.name, repo_prefix=args["repo_prefix"]) + bazel.sanitised_repo_file_label(ir.name, repo_prefix=repo_prefix) for ir, _ in install_req_and_lines ] ) @@ -99,6 +110,7 @@ def generate_parsed_requirements_contents(all_args: argparse.Namespace) -> str: _packages = {repo_names_and_reqs} _config = {args} + _annotations = {annotations} def _clean_name(name): return name.replace("-", "_").replace(".", "_").lower() @@ -120,24 +132,32 @@ def entry_point(pkg, script = None): script = pkg return "@{repo_prefix}" + _clean_name(pkg) + "//:{entry_point_prefix}_" + script + def _get_annotation(requirement): + # This expects to parse `setuptools==58.2.0 --hash=sha256:2551203ae6955b9876741a26ab3e767bb3242dafe86a32a749ea0d78b6792f11` + # down wo `setuptools`. + name = requirement.split(" ")[0].split("=")[0] + return _annotations.get(name) + def install_deps(): for name, requirement in _packages: whl_library( name = name, requirement = requirement, + annotation = _get_annotation(requirement), **_config, ) """.format( all_requirements=all_requirements, all_whl_requirements=all_whl_requirements, - repo_names_and_reqs=repo_names_and_reqs, - args=args, - repo_prefix=args["repo_prefix"], - py_library_label=bazel.PY_LIBRARY_LABEL, - wheel_file_label=bazel.WHEEL_FILE_LABEL, + annotations=json.dumps(annotations), + args=whl_library_args, data_label=bazel.DATA_LABEL, dist_info_label=bazel.DIST_INFO_LABEL, entry_point_prefix=bazel.WHEEL_ENTRY_POINT_PREFIX, + py_library_label=bazel.PY_LIBRARY_LABEL, + repo_names_and_reqs=repo_names_and_reqs, + repo_prefix=repo_prefix, + wheel_file_label=bazel.WHEEL_FILE_LABEL, ) ) @@ -181,8 +201,42 @@ def main() -> None: required=True, help="timeout to use for pip operation.", ) + parser.add_argument( + "--annotations", + type=annotation.annotations_map_from_str_path, + help="A json encoded file containing annotations for rendered packages.", + ) arguments.parse_common_args(parser) args = parser.parse_args() + # Check for any annotations which match packages in the locked requirements file + install_requirements = parse_install_requirements( + args.requirements_lock, args.extra_pip_args + ) + req_names = sorted([req.name for req, _ in install_requirements]) + annotations = args.annotations.collect(req_names) + + # Write all rendered annotation files and generate a list of the labels to write to the requirements file + annotated_requirements = dict() + for name, content in annotations.items(): + annotation_path = Path(name + ".annotation.json") + annotation_path.write_text(json.dumps(content, indent=4)) + annotated_requirements.update( + { + name: "@{}//:{}.annotation.json".format( + args.repo_prefix.rstrip("_"), name + ) + } + ) + with open("requirements.bzl", "w") as requirement_file: - requirement_file.write(generate_parsed_requirements_contents(args)) + whl_library_args = parse_whl_library_args(args) + + requirement_file.write( + generate_parsed_requirements_contents( + requirements_lock=args.requirements_lock, + repo_prefix=args.repo_prefix, + whl_library_args=whl_library_args, + annotations=annotated_requirements, + ) + ) diff --git a/python/pip_install/parse_requirements_to_bzl/extract_single_wheel/__init__.py b/python/pip_install/parse_requirements_to_bzl/extract_single_wheel/__init__.py index 2c03ff3c04..198aefae83 100644 --- a/python/pip_install/parse_requirements_to_bzl/extract_single_wheel/__init__.py +++ b/python/pip_install/parse_requirements_to_bzl/extract_single_wheel/__init__.py @@ -8,6 +8,7 @@ from python.pip_install.extract_wheels import configure_reproducible_wheels from python.pip_install.extract_wheels.lib import arguments, bazel, requirements +from python.pip_install.extract_wheels.lib.annotation import annotation_from_str_path def main() -> None: @@ -20,6 +21,11 @@ def main() -> None: required=True, help="A single PEP508 requirement specifier string.", ) + parser.add_argument( + "--annotation", + type=annotation_from_str_path, + help="A json encoded file containing annotations for rendered packages.", + ) arguments.parse_common_args(parser) args = parser.parse_args() deserialized_args = dict(vars(args)) @@ -61,10 +67,11 @@ def main() -> None: whl = next(iter(glob.glob("*.whl"))) bazel.extract_wheel( - whl, - extras, - deserialized_args["pip_data_exclude"], - args.enable_implicit_namespace_pkgs, + wheel_file=whl, + extras=extras, + pip_data_exclude=deserialized_args["pip_data_exclude"], + enable_implicit_namespace_pkgs=args.enable_implicit_namespace_pkgs, incremental=True, repo_prefix=args.repo_prefix, + annotation=args.annotation, ) diff --git a/python/pip_install/parse_requirements_to_bzl/parse_requirements_to_bzl_test.py b/python/pip_install/parse_requirements_to_bzl/parse_requirements_to_bzl_test.py index 9619af5f0b..c0608bf499 100644 --- a/python/pip_install/parse_requirements_to_bzl/parse_requirements_to_bzl_test.py +++ b/python/pip_install/parse_requirements_to_bzl/parse_requirements_to_bzl_test.py @@ -6,6 +6,7 @@ from python.pip_install.parse_requirements_to_bzl import ( generate_parsed_requirements_contents, + parse_whl_library_args, ) @@ -28,7 +29,12 @@ def test_generated_requirements_bzl(self) -> None: args.python_interpreter = "/custom/python3" args.python_interpreter_target = "@custom_python//:exec" args.environment = json.dumps({"arg": {}}) - contents = generate_parsed_requirements_contents(args) + whl_library_args = parse_whl_library_args(args) + contents = generate_parsed_requirements_contents( + requirements_lock=args.requirements_lock, + repo_prefix=args.repo_prefix, + whl_library_args=whl_library_args, + ) library_target = "@pip_parsed_deps_pypi__foo//:pkg" whl_target = "@pip_parsed_deps_pypi__foo//:whl" all_requirements = 'all_requirements = ["{library_target}"]'.format( diff --git a/python/pip_install/pip_repository.bzl b/python/pip_install/pip_repository.bzl index 1dc49c7240..4a3acf5b10 100644 --- a/python/pip_install/pip_repository.bzl +++ b/python/pip_install/pip_repository.bzl @@ -127,6 +127,11 @@ def _pip_repository_impl(rctx): # We need a BUILD file to load the generated requirements.bzl rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS) + # Write the annotations file to pass to the wheel maker + annotations = {package: json.decode(data) for (package, data) in rctx.attr.annotations.items()} + annotations_file = rctx.path("annotations.json") + rctx.file(annotations_file, json.encode_indent(annotations, indent = " " * 4)) + pypath = _construct_pypath(rctx) if rctx.attr.incremental: @@ -141,6 +146,8 @@ def _pip_repository_impl(rctx): str(rctx.attr.quiet), "--timeout", str(rctx.attr.timeout), + "--annotations", + annotations_file, ] args += ["--python_interpreter", _get_python_interpreter_attr(rctx)] @@ -154,6 +161,8 @@ def _pip_repository_impl(rctx): "python.pip_install.extract_wheels", "--requirements", rctx.path(rctx.attr.requirements), + "--annotations", + annotations_file, ] args += ["--repo", rctx.attr.name, "--repo-prefix", rctx.attr.repo_prefix] @@ -253,6 +262,9 @@ For incremental mode the packages will be of the form } pip_repository_attrs = { + "annotations": attr.string_dict( + doc = "Optional annotations to apply to packages", + ), "incremental": attr.bool( default = False, doc = "Create the repository in incremental mode.", @@ -318,7 +330,7 @@ py_binary( environ = common_env, ) -def _impl_whl_library(rctx): +def _whl_library_impl(rctx): python_interpreter = _resolve_python_interpreter(rctx) # pointer to parent repo so these rules rerun if the definitions in requirements.bzl change. @@ -334,6 +346,12 @@ def _impl_whl_library(rctx): "--repo-prefix", rctx.attr.repo_prefix, ] + if rctx.attr.annotation: + args.extend([ + "--annotation", + rctx.path(rctx.attr.annotation), + ]) + args = _parse_optional_attrs(rctx, args) result = rctx.execute( @@ -350,6 +368,13 @@ def _impl_whl_library(rctx): return whl_library_attrs = { + "annotation": attr.label( + doc = ( + "Optional json encoded file containing annotation to apply to the extracted wheel. " + + "See `package_annotation`" + ), + allow_files = True, + ), "repo": attr.string( mandatory = True, doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.", @@ -367,6 +392,40 @@ whl_library = repository_rule( doc = """ Download and extracts a single wheel based into a bazel repo based on the requirement string passed in. Instantiated from pip_repository and inherits config options from there.""", - implementation = _impl_whl_library, + implementation = _whl_library_impl, environ = common_env, ) + +def package_annotation( + build_content = None, + copy_files = {}, + copy_executables = {}, + data = [], + data_exclude_glob = [], + srcs_exclude_glob = []): + """Annotations to apply to the BUILD file content from package generated from a `pip_repository` rule. + + [cf]: https://github.com/bazelbuild/bazel-skylib/blob/main/docs/copy_file_doc.md + + Args: + build_content (str, optional): Raw text to add to the generated `BUILD` file of a package. + copy_files (dict, optional): A mapping of `src` and `out` files for [@bazel_skylib//rules:copy_file.bzl][cf] + copy_executables (dict, optional): A mapping of `src` and `out` files for + [@bazel_skylib//rules:copy_file.bzl][cf]. Targets generated here will also be flagged as + executable. + data (list, optional): A list of labels to add as `data` dependencies to the generated `py_library` target. + data_exclude_glob (list, optional): A list of exclude glob patterns to add as `data` to the generated + `py_library` target. + srcs_exclude_glob (list, optional): A list of labels to add as `srcs` to the generated `py_library` target. + + Returns: + str: A json encoded string of the provided content. + """ + return json.encode(struct( + build_content = build_content, + copy_files = copy_files, + copy_executables = copy_executables, + data = data, + data_exclude_glob = data_exclude_glob, + srcs_exclude_glob = srcs_exclude_glob, + )) diff --git a/third_party/github.com/bazelbuild/bazel-skylib/rules/BUILD b/third_party/github.com/bazelbuild/bazel-skylib/rules/BUILD new file mode 100644 index 0000000000..6857449878 --- /dev/null +++ b/third_party/github.com/bazelbuild/bazel-skylib/rules/BUILD @@ -0,0 +1,36 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +bzl_library( + name = "copy_file", + srcs = ["copy_file.bzl"], + deps = ["//third_party/github.com/bazelbuild/bazel-skylib/rules/private:copy_file_private"], +) + +filegroup( + name = "test_deps", + testonly = True, + srcs = [ + "BUILD", + ] + glob(["*.bzl"]), +) + +# The files needed for distribution +filegroup( + name = "distribution", + srcs = [ + "BUILD", + ] + glob(["*.bzl"]), + visibility = [ + "//:__pkg__", + ], +) + +# export bzl files for the documentation +exports_files( + glob(["*.bzl"]), + visibility = ["//:__subpackages__"], +) diff --git a/third_party/github.com/bazelbuild/bazel-skylib/rules/copy_file.bzl b/third_party/github.com/bazelbuild/bazel-skylib/rules/copy_file.bzl new file mode 100644 index 0000000000..2908fa6e85 --- /dev/null +++ b/third_party/github.com/bazelbuild/bazel-skylib/rules/copy_file.bzl @@ -0,0 +1,29 @@ +# Copyright 2019 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A rule that copies a file to another place. + +native.genrule() is sometimes used to copy files (often wishing to rename them). +The 'copy_file' rule does this with a simpler interface than genrule. + +The rule uses a Bash command on Linux/macOS/non-Windows, and a cmd.exe command +on Windows (no Bash is required). +""" + +load( + "@rules_python//third_party/github.com/bazelbuild/bazel-skylib/rules/private:copy_file_private.bzl", + _copy_file = "copy_file", +) + +copy_file = _copy_file diff --git a/third_party/github.com/bazelbuild/bazel-skylib/rules/private/BUILD b/third_party/github.com/bazelbuild/bazel-skylib/rules/private/BUILD new file mode 100644 index 0000000000..a1aeb39914 --- /dev/null +++ b/third_party/github.com/bazelbuild/bazel-skylib/rules/private/BUILD @@ -0,0 +1,18 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") + +licenses(["notice"]) + +bzl_library( + name = "copy_file_private", + srcs = ["copy_file_private.bzl"], + visibility = ["//third_party/github.com/bazelbuild/bazel-skylib/rules:__pkg__"], +) + +# The files needed for distribution +filegroup( + name = "distribution", + srcs = glob(["*"]), + visibility = [ + "//:__subpackages__", + ], +) diff --git a/third_party/github.com/bazelbuild/bazel-skylib/rules/private/copy_file_private.bzl b/third_party/github.com/bazelbuild/bazel-skylib/rules/private/copy_file_private.bzl new file mode 100644 index 0000000000..d044c9767e --- /dev/null +++ b/third_party/github.com/bazelbuild/bazel-skylib/rules/private/copy_file_private.bzl @@ -0,0 +1,141 @@ +# Copyright 2019 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implementation of copy_file macro and underlying rules. + +These rules copy a file to another location using Bash (on Linux/macOS) or +cmd.exe (on Windows). '_copy_xfile' marks the resulting file executable, +'_copy_file' does not. +""" + +def copy_cmd(ctx, src, dst): + # Most Windows binaries built with MSVC use a certain argument quoting + # scheme. Bazel uses that scheme too to quote arguments. However, + # cmd.exe uses different semantics, so Bazel's quoting is wrong here. + # To fix that we write the command to a .bat file so no command line + # quoting or escaping is required. + bat = ctx.actions.declare_file(ctx.label.name + "-cmd.bat") + ctx.actions.write( + output = bat, + # Do not use lib/shell.bzl's shell.quote() method, because that uses + # Bash quoting syntax, which is different from cmd.exe's syntax. + content = "@copy /Y \"%s\" \"%s\" >NUL" % ( + src.path.replace("/", "\\"), + dst.path.replace("/", "\\"), + ), + is_executable = True, + ) + ctx.actions.run( + inputs = [src], + tools = [bat], + outputs = [dst], + executable = "cmd.exe", + arguments = ["/C", bat.path.replace("/", "\\")], + mnemonic = "CopyFile", + progress_message = "Copying files", + use_default_shell_env = True, + ) + +def copy_bash(ctx, src, dst): + ctx.actions.run_shell( + tools = [src], + outputs = [dst], + command = "cp -f \"$1\" \"$2\"", + arguments = [src.path, dst.path], + mnemonic = "CopyFile", + progress_message = "Copying files", + use_default_shell_env = True, + ) + +def _copy_file_impl(ctx): + if ctx.attr.allow_symlink: + ctx.actions.symlink( + output = ctx.outputs.out, + target_file = ctx.file.src, + is_executable = ctx.attr.is_executable, + ) + elif ctx.attr.is_windows: + copy_cmd(ctx, ctx.file.src, ctx.outputs.out) + else: + copy_bash(ctx, ctx.file.src, ctx.outputs.out) + + files = depset(direct = [ctx.outputs.out]) + runfiles = ctx.runfiles(files = [ctx.outputs.out]) + if ctx.attr.is_executable: + return [DefaultInfo(files = files, runfiles = runfiles, executable = ctx.outputs.out)] + else: + return [DefaultInfo(files = files, runfiles = runfiles)] + +_ATTRS = { + "allow_symlink": attr.bool(mandatory = True), + "is_executable": attr.bool(mandatory = True), + "is_windows": attr.bool(mandatory = True), + "out": attr.output(mandatory = True), + "src": attr.label(mandatory = True, allow_single_file = True), +} + +_copy_file = rule( + implementation = _copy_file_impl, + provides = [DefaultInfo], + attrs = _ATTRS, +) + +_copy_xfile = rule( + implementation = _copy_file_impl, + executable = True, + provides = [DefaultInfo], + attrs = _ATTRS, +) + +def copy_file(name, src, out, is_executable = False, allow_symlink = False, **kwargs): + """Copies a file to another location. + + `native.genrule()` is sometimes used to copy files (often wishing to rename them). The 'copy_file' rule does this with a simpler interface than genrule. + + This rule uses a Bash command on Linux/macOS/non-Windows, and a cmd.exe command on Windows (no Bash is required). + + Args: + name: Name of the rule. + src: A Label. The file to make a copy of. (Can also be the label of a rule + that generates a file.) + out: Path of the output file, relative to this package. + is_executable: A boolean. Whether to make the output file executable. When + True, the rule's output can be executed using `bazel run` and can be + in the srcs of binary and test rules that require executable sources. + WARNING: If `allow_symlink` is True, `src` must also be executable. + allow_symlink: A boolean. Whether to allow symlinking instead of copying. + When False, the output is always a hard copy. When True, the output + *can* be a symlink, but there is no guarantee that a symlink is + created (i.e., at the time of writing, we don't create symlinks on + Windows). Set this to True if you need fast copying and your tools can + handle symlinks (which most UNIX tools can). + **kwargs: further keyword arguments, e.g. `visibility` + """ + + copy_file_impl = _copy_file + if is_executable: + copy_file_impl = _copy_xfile + + copy_file_impl( + name = name, + src = src, + out = out, + is_windows = select({ + "@bazel_tools//src/conditions:host_windows": True, + "//conditions:default": False, + }), + is_executable = is_executable, + allow_symlink = allow_symlink, + **kwargs + )