1# Copyright 2017 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"""Import pip requirements into Bazel.""" 15 16load("//python/pip_install:pip_repository.bzl", "pip_repository", _package_annotation = "package_annotation") 17load("//python/pip_install:repositories.bzl", "pip_install_dependencies") 18load("//python/pip_install:requirements.bzl", _compile_pip_requirements = "compile_pip_requirements") 19load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") 20load("//python/private:render_pkg_aliases.bzl", "NO_MATCH_ERROR_MESSAGE_TEMPLATE") 21load(":versions.bzl", "MINOR_MAPPING") 22 23compile_pip_requirements = _compile_pip_requirements 24package_annotation = _package_annotation 25 26def pip_install(requirements = None, name = "pip", **kwargs): 27 """Accepts a locked/compiled requirements file and installs the dependencies listed within. 28 29 ```python 30 load("@rules_python//python:pip.bzl", "pip_install") 31 32 pip_install( 33 name = "pip_deps", 34 requirements = ":requirements.txt", 35 ) 36 37 load("@pip_deps//:requirements.bzl", "install_deps") 38 39 install_deps() 40 ``` 41 42 Args: 43 requirements (Label): A 'requirements.txt' pip requirements file. 44 name (str, optional): A unique name for the created external repository (default 'pip'). 45 **kwargs (dict): Additional arguments to the [`pip_repository`](./pip_repository.md) repository rule. 46 """ 47 48 # buildifier: disable=print 49 print("pip_install is deprecated. Please switch to pip_parse. pip_install will be removed in a future release.") 50 pip_parse(requirements = requirements, name = name, **kwargs) 51 52def pip_parse(requirements = None, requirements_lock = None, name = "pip_parsed_deps", **kwargs): 53 """Accepts a locked/compiled requirements file and installs the dependencies listed within. 54 55 Those dependencies become available in a generated `requirements.bzl` file. 56 You can instead check this `requirements.bzl` file into your repo, see the "vendoring" section below. 57 58 This macro wraps the [`pip_repository`](./pip_repository.md) rule that invokes `pip`. 59 In your WORKSPACE file: 60 61 ```python 62 load("@rules_python//python:pip.bzl", "pip_parse") 63 64 pip_parse( 65 name = "pip_deps", 66 requirements_lock = ":requirements.txt", 67 ) 68 69 load("@pip_deps//:requirements.bzl", "install_deps") 70 71 install_deps() 72 ``` 73 74 You can then reference installed dependencies from a `BUILD` file with: 75 76 ```python 77 load("@pip_deps//:requirements.bzl", "requirement") 78 79 py_library( 80 name = "bar", 81 ... 82 deps = [ 83 "//my/other:dep", 84 requirement("requests"), 85 requirement("numpy"), 86 ], 87 ) 88 ``` 89 90 In addition to the `requirement` macro, which is used to access the generated `py_library` 91 target generated from a package's wheel, The generated `requirements.bzl` file contains 92 functionality for exposing [entry points][whl_ep] as `py_binary` targets as well. 93 94 [whl_ep]: https://packaging.python.org/specifications/entry-points/ 95 96 ```python 97 load("@pip_deps//:requirements.bzl", "entry_point") 98 99 alias( 100 name = "pip-compile", 101 actual = entry_point( 102 pkg = "pip-tools", 103 script = "pip-compile", 104 ), 105 ) 106 ``` 107 108 Note that for packages whose name and script are the same, only the name of the package 109 is needed when calling the `entry_point` macro. 110 111 ```python 112 load("@pip_deps//:requirements.bzl", "entry_point") 113 114 alias( 115 name = "flake8", 116 actual = entry_point("flake8"), 117 ) 118 ``` 119 120 ## Vendoring the requirements.bzl file 121 122 In some cases you may not want to generate the requirements.bzl file as a repository rule 123 while Bazel is fetching dependencies. For example, if you produce a reusable Bazel module 124 such as a ruleset, you may want to include the requirements.bzl file rather than make your users 125 install the WORKSPACE setup to generate it. 126 See https://github.com/bazelbuild/rules_python/issues/608 127 128 This is the same workflow as Gazelle, which creates `go_repository` rules with 129 [`update-repos`](https://github.com/bazelbuild/bazel-gazelle#update-repos) 130 131 To do this, use the "write to source file" pattern documented in 132 https://blog.aspect.dev/bazel-can-write-to-the-source-folder 133 to put a copy of the generated requirements.bzl into your project. 134 Then load the requirements.bzl file directly rather than from the generated repository. 135 See the example in rules_python/examples/pip_parse_vendored. 136 137 Args: 138 requirements_lock (Label): A fully resolved 'requirements.txt' pip requirement file 139 containing the transitive set of your dependencies. If this file is passed instead 140 of 'requirements' no resolve will take place and pip_repository will create 141 individual repositories for each of your dependencies so that wheels are 142 fetched/built only for the targets specified by 'build/run/test'. 143 Note that if your lockfile is platform-dependent, you can use the `requirements_[platform]` 144 attributes. 145 requirements (Label): Deprecated. See requirements_lock. 146 name (str, optional): The name of the generated repository. The generated repositories 147 containing each requirement will be of the form `<name>_<requirement-name>`. 148 **kwargs (dict): Additional arguments to the [`pip_repository`](./pip_repository.md) repository rule. 149 """ 150 pip_install_dependencies() 151 152 # Temporary compatibility shim. 153 # pip_install was previously document to use requirements while pip_parse was using requirements_lock. 154 # We would prefer everyone move to using requirements_lock, but we maintain a temporary shim. 155 reqs_to_use = requirements_lock if requirements_lock else requirements 156 157 pip_repository( 158 name = name, 159 requirements_lock = reqs_to_use, 160 **kwargs 161 ) 162 163def _multi_pip_parse_impl(rctx): 164 rules_python = rctx.attr._rules_python_workspace.workspace_name 165 load_statements = [] 166 install_deps_calls = [] 167 process_requirements_calls = [] 168 for python_version, pypi_repository in rctx.attr.pip_parses.items(): 169 sanitized_python_version = python_version.replace(".", "_") 170 load_statement = """\ 171load( 172 "@{pypi_repository}//:requirements.bzl", 173 _{sanitized_python_version}_install_deps = "install_deps", 174 _{sanitized_python_version}_all_requirements = "all_requirements", 175)""".format( 176 pypi_repository = pypi_repository, 177 sanitized_python_version = sanitized_python_version, 178 ) 179 load_statements.append(load_statement) 180 process_requirements_call = """\ 181_process_requirements( 182 pkg_labels = _{sanitized_python_version}_all_requirements, 183 python_version = "{python_version}", 184 repo_prefix = "{pypi_repository}_", 185)""".format( 186 pypi_repository = pypi_repository, 187 python_version = python_version, 188 sanitized_python_version = sanitized_python_version, 189 ) 190 process_requirements_calls.append(process_requirements_call) 191 install_deps_call = """ _{sanitized_python_version}_install_deps(**whl_library_kwargs)""".format( 192 sanitized_python_version = sanitized_python_version, 193 ) 194 install_deps_calls.append(install_deps_call) 195 196 requirements_bzl = """\ 197# Generated by python/pip.bzl 198 199load("@{rules_python}//python:pip.bzl", "whl_library_alias") 200{load_statements} 201 202_wheel_names = [] 203_version_map = dict() 204def _process_requirements(pkg_labels, python_version, repo_prefix): 205 for pkg_label in pkg_labels: 206 workspace_name = Label(pkg_label).workspace_name 207 wheel_name = workspace_name[len(repo_prefix):] 208 _wheel_names.append(wheel_name) 209 if not wheel_name in _version_map: 210 _version_map[wheel_name] = dict() 211 _version_map[wheel_name][python_version] = repo_prefix 212 213{process_requirements_calls} 214 215def _clean_name(name): 216 return name.replace("-", "_").replace(".", "_").lower() 217 218def requirement(name): 219 return "@{name}_" + _clean_name(name) + "//:pkg" 220 221def whl_requirement(name): 222 return "@{name}_" + _clean_name(name) + "//:whl" 223 224def data_requirement(name): 225 return "@{name}_" + _clean_name(name) + "//:data" 226 227def dist_info_requirement(name): 228 return "@{name}_" + _clean_name(name) + "//:dist_info" 229 230def entry_point(pkg, script = None): 231 fail("Not implemented yet") 232 233def install_deps(**whl_library_kwargs): 234{install_deps_calls} 235 for wheel_name in _wheel_names: 236 whl_library_alias( 237 name = "{name}_" + wheel_name, 238 wheel_name = wheel_name, 239 default_version = "{default_version}", 240 version_map = _version_map[wheel_name], 241 ) 242""".format( 243 name = rctx.attr.name, 244 install_deps_calls = "\n".join(install_deps_calls), 245 load_statements = "\n".join(load_statements), 246 process_requirements_calls = "\n".join(process_requirements_calls), 247 rules_python = rules_python, 248 default_version = rctx.attr.default_version, 249 ) 250 rctx.file("requirements.bzl", requirements_bzl) 251 rctx.file("BUILD.bazel", "exports_files(['requirements.bzl'])") 252 253_multi_pip_parse = repository_rule( 254 _multi_pip_parse_impl, 255 attrs = { 256 "default_version": attr.string(), 257 "pip_parses": attr.string_dict(), 258 "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")), 259 }, 260) 261 262def _whl_library_alias_impl(rctx): 263 rules_python = rctx.attr._rules_python_workspace.workspace_name 264 if rctx.attr.default_version: 265 default_repo_prefix = rctx.attr.version_map[rctx.attr.default_version] 266 else: 267 default_repo_prefix = None 268 version_map = rctx.attr.version_map.items() 269 build_content = ["# Generated by python/pip.bzl"] 270 for alias_name in ["pkg", "whl", "data", "dist_info"]: 271 build_content.append(_whl_library_render_alias_target( 272 alias_name = alias_name, 273 default_repo_prefix = default_repo_prefix, 274 rules_python = rules_python, 275 version_map = version_map, 276 wheel_name = rctx.attr.wheel_name, 277 )) 278 rctx.file("BUILD.bazel", "\n".join(build_content)) 279 280def _whl_library_render_alias_target( 281 alias_name, 282 default_repo_prefix, 283 rules_python, 284 version_map, 285 wheel_name): 286 # The template below adds one @, but under bzlmod, the name 287 # is canonical, so we have to add a second @. 288 if BZLMOD_ENABLED: 289 rules_python = "@" + rules_python 290 291 alias = ["""\ 292alias( 293 name = "{alias_name}", 294 actual = select({{""".format(alias_name = alias_name)] 295 for [python_version, repo_prefix] in version_map: 296 alias.append("""\ 297 "@{rules_python}//python/config_settings:is_python_{full_python_version}": "{actual}",""".format( 298 full_python_version = MINOR_MAPPING[python_version] if python_version in MINOR_MAPPING else python_version, 299 actual = "@{repo_prefix}{wheel_name}//:{alias_name}".format( 300 repo_prefix = repo_prefix, 301 wheel_name = wheel_name, 302 alias_name = alias_name, 303 ), 304 rules_python = rules_python, 305 )) 306 if default_repo_prefix: 307 default_actual = "@{repo_prefix}{wheel_name}//:{alias_name}".format( 308 repo_prefix = default_repo_prefix, 309 wheel_name = wheel_name, 310 alias_name = alias_name, 311 ) 312 alias.append(' "//conditions:default": "{default_actual}",'.format( 313 default_actual = default_actual, 314 )) 315 316 alias.append(" },") # Close select expression condition dict 317 if not default_repo_prefix: 318 supported_versions = sorted([python_version for python_version, _ in version_map]) 319 alias.append(' no_match_error="""{}""",'.format( 320 NO_MATCH_ERROR_MESSAGE_TEMPLATE.format( 321 supported_versions = ", ".join(supported_versions), 322 rules_python = rules_python, 323 ), 324 )) 325 alias.append(" ),") # Close the select expression 326 alias.append(' visibility = ["//visibility:public"],') 327 alias.append(")") # Close the alias() expression 328 return "\n".join(alias) 329 330whl_library_alias = repository_rule( 331 _whl_library_alias_impl, 332 attrs = { 333 "default_version": attr.string( 334 mandatory = False, 335 doc = "Optional Python version in major.minor format, e.g. '3.10'." + 336 "The Python version of the wheel to use when the versions " + 337 "from `version_map` don't match. This allows the default " + 338 "(version unaware) rules to match and select a wheel. If " + 339 "not specified, then the default rules won't be able to " + 340 "resolve a wheel and an error will occur.", 341 ), 342 "version_map": attr.string_dict(mandatory = True), 343 "wheel_name": attr.string(mandatory = True), 344 "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")), 345 }, 346) 347 348def multi_pip_parse(name, default_version, python_versions, python_interpreter_target, requirements_lock, **kwargs): 349 """NOT INTENDED FOR DIRECT USE! 350 351 This is intended to be used by the multi_pip_parse implementation in the template of the 352 multi_toolchain_aliases repository rule. 353 354 Args: 355 name: the name of the multi_pip_parse repository. 356 default_version: the default Python version. 357 python_versions: all Python toolchain versions currently registered. 358 python_interpreter_target: a dictionary which keys are Python versions and values are resolved host interpreters. 359 requirements_lock: a dictionary which keys are Python versions and values are locked requirements files. 360 **kwargs: extra arguments passed to all wrapped pip_parse. 361 362 Returns: 363 The internal implementation of multi_pip_parse repository rule. 364 """ 365 pip_parses = {} 366 for python_version in python_versions: 367 if not python_version in python_interpreter_target: 368 fail("Missing python_interpreter_target for Python version %s in '%s'" % (python_version, name)) 369 if not python_version in requirements_lock: 370 fail("Missing requirements_lock for Python version %s in '%s'" % (python_version, name)) 371 372 pip_parse_name = name + "_" + python_version.replace(".", "_") 373 pip_parse( 374 name = pip_parse_name, 375 python_interpreter_target = python_interpreter_target[python_version], 376 requirements_lock = requirements_lock[python_version], 377 **kwargs 378 ) 379 pip_parses[python_version] = pip_parse_name 380 381 return _multi_pip_parse( 382 name = name, 383 default_version = default_version, 384 pip_parses = pip_parses, 385 ) 386