xref: /aosp_15_r20/external/bazelbuild-rules_python/python/private/pypi/pip_repository.bzl (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
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