• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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