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("@bazel_skylib//lib:sets.bzl", "sets") 18load("//python/private:normalize_name.bzl", "normalize_name") 19load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR") 20load("//python/private:text_util.bzl", "render") 21load(":evaluate_markers.bzl", "evaluate_markers", EVALUATE_MARKERS_SRCS = "SRCS") 22load(":parse_requirements.bzl", "host_platform", "parse_requirements", "select_requirement") 23load(":pip_repository_attrs.bzl", "ATTRS") 24load(":render_pkg_aliases.bzl", "render_pkg_aliases", "whl_alias") 25load(":requirements_files_by_platform.bzl", "requirements_files_by_platform") 26 27def _get_python_interpreter_attr(rctx): 28 """A helper function for getting the `python_interpreter` attribute or it's default 29 30 Args: 31 rctx (repository_ctx): Handle to the rule repository context. 32 33 Returns: 34 str: The attribute value or it's default 35 """ 36 if rctx.attr.python_interpreter: 37 return rctx.attr.python_interpreter 38 39 if "win" in rctx.os.name: 40 return "python.exe" 41 else: 42 return "python3" 43 44def use_isolated(ctx, attr): 45 """Determine whether or not to pass the pip `--isolated` flag to the pip invocation. 46 47 Args: 48 ctx: repository or module context 49 attr: attributes for the repo rule or tag extension 50 51 Returns: 52 True if --isolated should be passed 53 """ 54 use_isolated = attr.isolated 55 56 # The environment variable will take precedence over the attribute 57 isolated_env = ctx.os.environ.get("RULES_PYTHON_PIP_ISOLATED", None) 58 if isolated_env != None: 59 if isolated_env.lower() in ("0", "false"): 60 use_isolated = False 61 else: 62 use_isolated = True 63 64 return use_isolated 65 66_BUILD_FILE_CONTENTS = """\ 67package(default_visibility = ["//visibility:public"]) 68 69# Ensure the `requirements.bzl` source can be accessed by stardoc, since users load() from it 70exports_files(["requirements.bzl"]) 71""" 72 73def _pip_repository_impl(rctx): 74 requirements_by_platform = parse_requirements( 75 rctx, 76 requirements_by_platform = requirements_files_by_platform( 77 requirements_by_platform = rctx.attr.requirements_by_platform, 78 requirements_linux = rctx.attr.requirements_linux, 79 requirements_lock = rctx.attr.requirements_lock, 80 requirements_osx = rctx.attr.requirements_darwin, 81 requirements_windows = rctx.attr.requirements_windows, 82 extra_pip_args = rctx.attr.extra_pip_args, 83 ), 84 extra_pip_args = rctx.attr.extra_pip_args, 85 evaluate_markers = lambda rctx, requirements: evaluate_markers( 86 rctx, 87 requirements = requirements, 88 python_interpreter = rctx.attr.python_interpreter, 89 python_interpreter_target = rctx.attr.python_interpreter_target, 90 srcs = rctx.attr._evaluate_markers_srcs, 91 ), 92 ) 93 selected_requirements = {} 94 options = None 95 repository_platform = host_platform(rctx) 96 for name, requirements in requirements_by_platform.items(): 97 r = select_requirement( 98 requirements, 99 platform = None if rctx.attr.download_only else repository_platform, 100 ) 101 if not r: 102 continue 103 options = options or r.extra_pip_args 104 selected_requirements[name] = r.requirement_line 105 106 bzl_packages = sorted(selected_requirements.keys()) 107 108 # Normalize cycles first 109 requirement_cycles = { 110 name: sorted(sets.to_list(sets.make(deps))) 111 for name, deps in rctx.attr.experimental_requirement_cycles.items() 112 } 113 114 # Check for conflicts between cycles _before_ we normalize package names so 115 # that reported errors use the names the user specified 116 for i in range(len(requirement_cycles)): 117 left_group = requirement_cycles.keys()[i] 118 left_deps = requirement_cycles.values()[i] 119 for j in range(len(requirement_cycles) - (i + 1)): 120 right_deps = requirement_cycles.values()[1 + i + j] 121 right_group = requirement_cycles.keys()[1 + i + j] 122 for d in left_deps: 123 if d in right_deps: 124 fail("Error: Requirement %s cannot be repeated between cycles %s and %s; please merge the cycles." % (d, left_group, right_group)) 125 126 # And normalize the names as used in the cycle specs 127 # 128 # NOTE: We must check that a listed dependency is actually in the actual 129 # requirements set for the current platform so that we can support cycles in 130 # platform-conditional requirements. Otherwise we'll blindly generate a 131 # label referencing a package which may not be installed on the current 132 # platform. 133 requirement_cycles = { 134 normalize_name(name): sorted([normalize_name(d) for d in group if normalize_name(d) in bzl_packages]) 135 for name, group in requirement_cycles.items() 136 } 137 138 imports = [ 139 # NOTE: Maintain the order consistent with `buildifier` 140 'load("@rules_python//python:pip.bzl", "pip_utils")', 141 'load("@rules_python//python/pip_install:pip_repository.bzl", "group_library", "whl_library")', 142 ] 143 144 annotations = {} 145 for pkg, annotation in rctx.attr.annotations.items(): 146 filename = "{}.annotation.json".format(normalize_name(pkg)) 147 rctx.file(filename, json.encode_indent(json.decode(annotation))) 148 annotations[pkg] = "@{name}//:{filename}".format(name = rctx.attr.name, filename = filename) 149 150 config = { 151 "download_only": rctx.attr.download_only, 152 "enable_implicit_namespace_pkgs": rctx.attr.enable_implicit_namespace_pkgs, 153 "environment": rctx.attr.environment, 154 "envsubst": rctx.attr.envsubst, 155 "extra_pip_args": options, 156 "isolated": use_isolated(rctx, rctx.attr), 157 "pip_data_exclude": rctx.attr.pip_data_exclude, 158 "python_interpreter": _get_python_interpreter_attr(rctx), 159 "quiet": rctx.attr.quiet, 160 "repo": rctx.attr.name, 161 "timeout": rctx.attr.timeout, 162 } 163 if rctx.attr.use_hub_alias_dependencies: 164 config["dep_template"] = "@{}//{{name}}:{{target}}".format(rctx.attr.name) 165 else: 166 config["repo_prefix"] = "{}_".format(rctx.attr.name) 167 168 if rctx.attr.python_interpreter_target: 169 config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target) 170 if rctx.attr.experimental_target_platforms: 171 config["experimental_target_platforms"] = rctx.attr.experimental_target_platforms 172 173 macro_tmpl = "@%s//{}:{}" % rctx.attr.name 174 175 aliases = render_pkg_aliases( 176 aliases = { 177 pkg: [whl_alias(repo = rctx.attr.name + "_" + pkg)] 178 for pkg in bzl_packages or [] 179 }, 180 ) 181 for path, contents in aliases.items(): 182 rctx.file(path, contents) 183 184 rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS) 185 rctx.template("requirements.bzl", rctx.attr._template, substitutions = { 186 " # %%GROUP_LIBRARY%%": """\ 187 group_repo = "{name}__groups" 188 group_library( 189 name = group_repo, 190 repo_prefix = "{name}_", 191 groups = all_requirement_groups, 192 )""".format(name = rctx.attr.name) if not rctx.attr.use_hub_alias_dependencies else "", 193 "%%ALL_DATA_REQUIREMENTS%%": render.list([ 194 macro_tmpl.format(p, "data") 195 for p in bzl_packages 196 ]), 197 "%%ALL_REQUIREMENTS%%": render.list([ 198 macro_tmpl.format(p, "pkg") 199 for p in bzl_packages 200 ]), 201 "%%ALL_REQUIREMENT_GROUPS%%": render.dict(requirement_cycles), 202 "%%ALL_WHL_REQUIREMENTS_BY_PACKAGE%%": render.dict({ 203 p: macro_tmpl.format(p, "whl") 204 for p in bzl_packages 205 }), 206 "%%ANNOTATIONS%%": render.dict(dict(sorted(annotations.items()))), 207 "%%CONFIG%%": render.dict(dict(sorted(config.items()))), 208 "%%EXTRA_PIP_ARGS%%": json.encode(options), 209 "%%IMPORTS%%": "\n".join(imports), 210 "%%MACRO_TMPL%%": macro_tmpl, 211 "%%NAME%%": rctx.attr.name, 212 "%%PACKAGES%%": render.list( 213 [ 214 ("{}_{}".format(rctx.attr.name, p), r) 215 for p, r in sorted(selected_requirements.items()) 216 ], 217 ), 218 }) 219 220 return 221 222pip_repository = repository_rule( 223 attrs = dict( 224 annotations = attr.string_dict( 225 doc = """\ 226Optional annotations to apply to packages. Keys should be package names, with 227capitalization matching the input requirements file, and values should be 228generated using the `package_name` macro. For example usage, see [this WORKSPACE 229file](https://github.com/bazelbuild/rules_python/blob/main/examples/pip_repository_annotations/WORKSPACE). 230""", 231 ), 232 _template = attr.label( 233 default = ":requirements.bzl.tmpl.workspace", 234 ), 235 _evaluate_markers_srcs = attr.label_list( 236 default = EVALUATE_MARKERS_SRCS, 237 doc = """\ 238The list of labels to use as SRCS for the marker evaluation code. This ensures that the 239code will be re-evaluated when any of files in the default changes. 240""", 241 ), 242 **ATTRS 243 ), 244 doc = """Accepts a locked/compiled requirements file and installs the dependencies listed within. 245 246Those dependencies become available in a generated `requirements.bzl` file. 247You can instead check this `requirements.bzl` file into your repo, see the "vendoring" section below. 248 249In your WORKSPACE file: 250 251```starlark 252load("@rules_python//python:pip.bzl", "pip_parse") 253 254pip_parse( 255 name = "pypi", 256 requirements_lock = ":requirements.txt", 257) 258 259load("@pypi//:requirements.bzl", "install_deps") 260 261install_deps() 262``` 263 264You can then reference installed dependencies from a `BUILD` file with the alias targets generated in the same repo, for example, for `PyYAML` we would have the following: 265- `@pypi//pyyaml` and `@pypi//pyyaml:pkg` both point to the `py_library` 266 created after extracting the `PyYAML` package. 267- `@pypi//pyyaml:data` points to the extra data included in the package. 268- `@pypi//pyyaml:dist_info` points to the `dist-info` files in the package. 269- `@pypi//pyyaml:whl` points to the wheel file that was extracted. 270 271```starlark 272py_library( 273 name = "bar", 274 ... 275 deps = [ 276 "//my/other:dep", 277 "@pypi//numpy", 278 "@pypi//requests", 279 ], 280) 281``` 282 283or 284 285```starlark 286load("@pypi//:requirements.bzl", "requirement") 287 288py_library( 289 name = "bar", 290 ... 291 deps = [ 292 "//my/other:dep", 293 requirement("numpy"), 294 requirement("requests"), 295 ], 296) 297``` 298 299In addition to the `requirement` macro, which is used to access the generated `py_library` 300target generated from a package's wheel, The generated `requirements.bzl` file contains 301functionality for exposing [entry points][whl_ep] as `py_binary` targets as well. 302 303[whl_ep]: https://packaging.python.org/specifications/entry-points/ 304 305```starlark 306load("@pypi//:requirements.bzl", "entry_point") 307 308alias( 309 name = "pip-compile", 310 actual = entry_point( 311 pkg = "pip-tools", 312 script = "pip-compile", 313 ), 314) 315``` 316 317Note that for packages whose name and script are the same, only the name of the package 318is needed when calling the `entry_point` macro. 319 320```starlark 321load("@pip//:requirements.bzl", "entry_point") 322 323alias( 324 name = "flake8", 325 actual = entry_point("flake8"), 326) 327``` 328 329:::{rubric} Vendoring the requirements.bzl file 330:heading-level: 3 331::: 332 333In some cases you may not want to generate the requirements.bzl file as a repository rule 334while Bazel is fetching dependencies. For example, if you produce a reusable Bazel module 335such as a ruleset, you may want to include the requirements.bzl file rather than make your users 336install the WORKSPACE setup to generate it. 337See https://github.com/bazelbuild/rules_python/issues/608 338 339This is the same workflow as Gazelle, which creates `go_repository` rules with 340[`update-repos`](https://github.com/bazelbuild/bazel-gazelle#update-repos) 341 342To do this, use the "write to source file" pattern documented in 343https://blog.aspect.dev/bazel-can-write-to-the-source-folder 344to put a copy of the generated requirements.bzl into your project. 345Then load the requirements.bzl file directly rather than from the generated repository. 346See the example in rules_python/examples/pip_parse_vendored. 347""", 348 implementation = _pip_repository_impl, 349 environ = [ 350 "RULES_PYTHON_PIP_ISOLATED", 351 REPO_DEBUG_ENV_VAR, 352 ], 353) 354