• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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"pip module extension for use with bzlmod"
16
17load("@pythons_hub//:interpreters.bzl", "DEFAULT_PYTHON_VERSION", "INTERPRETER_LABELS")
18load("//python:pip.bzl", "whl_library_alias")
19load(
20    "//python/pip_install:pip_repository.bzl",
21    "locked_requirements_label",
22    "pip_hub_repository_bzlmod",
23    "pip_repository_attrs",
24    "pip_repository_bzlmod",
25    "use_isolated",
26    "whl_library",
27)
28load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse")
29load("//python/private:normalize_name.bzl", "normalize_name")
30load("//python/private:version_label.bzl", "version_label")
31
32def _whl_mods_impl(mctx):
33    """Implementation of the pip.whl_mods tag class.
34
35    This creates the JSON files used to modify the creation of different wheels.
36"""
37    whl_mods_dict = {}
38    for mod in mctx.modules:
39        for whl_mod_attr in mod.tags.whl_mods:
40            if whl_mod_attr.hub_name not in whl_mods_dict.keys():
41                whl_mods_dict[whl_mod_attr.hub_name] = {whl_mod_attr.whl_name: whl_mod_attr}
42            elif whl_mod_attr.whl_name in whl_mods_dict[whl_mod_attr.hub_name].keys():
43                # We cannot have the same wheel name in the same hub, as we
44                # will create the same JSON file name.
45                fail("""\
46Found same whl_name '{}' in the same hub '{}', please use a different hub_name.""".format(
47                    whl_mod_attr.whl_name,
48                    whl_mod_attr.hub_name,
49                ))
50            else:
51                whl_mods_dict[whl_mod_attr.hub_name][whl_mod_attr.whl_name] = whl_mod_attr
52
53    for hub_name, whl_maps in whl_mods_dict.items():
54        whl_mods = {}
55
56        # create a struct that we can pass to the _whl_mods_repo rule
57        # to create the different JSON files.
58        for whl_name, mods in whl_maps.items():
59            build_content = mods.additive_build_content
60            if mods.additive_build_content_file != None and mods.additive_build_content != "":
61                fail("""\
62You cannot use both the additive_build_content and additive_build_content_file arguments at the same time.
63""")
64            elif mods.additive_build_content_file != None:
65                build_content = mctx.read(mods.additive_build_content_file)
66
67            whl_mods[whl_name] = json.encode(struct(
68                additive_build_content = build_content,
69                copy_files = mods.copy_files,
70                copy_executables = mods.copy_executables,
71                data = mods.data,
72                data_exclude_glob = mods.data_exclude_glob,
73                srcs_exclude_glob = mods.srcs_exclude_glob,
74            ))
75
76        _whl_mods_repo(
77            name = hub_name,
78            whl_mods = whl_mods,
79        )
80
81def _create_versioned_pip_and_whl_repos(module_ctx, pip_attr, whl_map):
82    python_interpreter_target = pip_attr.python_interpreter_target
83
84    # if we do not have the python_interpreter set in the attributes
85    # we programtically find it.
86    hub_name = pip_attr.hub_name
87    if python_interpreter_target == None:
88        python_name = "python_" + version_label(pip_attr.python_version, sep = "_")
89        if python_name not in INTERPRETER_LABELS.keys():
90            fail((
91                "Unable to find interpreter for pip hub '{hub_name}' for " +
92                "python_version={version}: Make sure a corresponding " +
93                '`python.toolchain(python_version="{version}")` call exists'
94            ).format(
95                hub_name = hub_name,
96                version = pip_attr.python_version,
97            ))
98        python_interpreter_target = INTERPRETER_LABELS[python_name]
99
100    pip_name = "{}_{}".format(
101        hub_name,
102        version_label(pip_attr.python_version),
103    )
104    requrements_lock = locked_requirements_label(module_ctx, pip_attr)
105
106    # Parse the requirements file directly in starlark to get the information
107    # needed for the whl_libary declarations below. This is needed to contain
108    # the pip_repository logic to a single module extension.
109    requirements_lock_content = module_ctx.read(requrements_lock)
110    parse_result = parse_requirements(requirements_lock_content)
111    requirements = parse_result.requirements
112    extra_pip_args = pip_attr.extra_pip_args + parse_result.options
113
114    # Create the repository where users load the `requirement` macro. Under bzlmod
115    # this does not create the install_deps() macro.
116    # TODO: we may not need this repository once we have entry points
117    # supported. For now a user can access this repository and use
118    # the entrypoint functionality.
119    pip_repository_bzlmod(
120        name = pip_name,
121        repo_name = pip_name,
122        requirements_lock = pip_attr.requirements_lock,
123    )
124    if hub_name not in whl_map:
125        whl_map[hub_name] = {}
126
127    whl_modifications = {}
128    if pip_attr.whl_modifications != None:
129        for mod, whl_name in pip_attr.whl_modifications.items():
130            whl_modifications[whl_name] = mod
131
132    # Create a new wheel library for each of the different whls
133    for whl_name, requirement_line in requirements:
134        # We are not using the "sanitized name" because the user
135        # would need to guess what name we modified the whl name
136        # to.
137        annotation = whl_modifications.get(whl_name)
138        whl_name = normalize_name(whl_name)
139        whl_library(
140            name = "%s_%s" % (pip_name, whl_name),
141            requirement = requirement_line,
142            repo = pip_name,
143            repo_prefix = pip_name + "_",
144            annotation = annotation,
145            python_interpreter = pip_attr.python_interpreter,
146            python_interpreter_target = python_interpreter_target,
147            quiet = pip_attr.quiet,
148            timeout = pip_attr.timeout,
149            isolated = use_isolated(module_ctx, pip_attr),
150            extra_pip_args = extra_pip_args,
151            download_only = pip_attr.download_only,
152            pip_data_exclude = pip_attr.pip_data_exclude,
153            enable_implicit_namespace_pkgs = pip_attr.enable_implicit_namespace_pkgs,
154            environment = pip_attr.environment,
155        )
156
157        if whl_name not in whl_map[hub_name]:
158            whl_map[hub_name][whl_name] = {}
159
160        whl_map[hub_name][whl_name][pip_attr.python_version] = pip_name + "_"
161
162def _pip_impl(module_ctx):
163    """Implementation of a class tag that creates the pip hub(s) and corresponding pip spoke, alias and whl repositories.
164
165    This implmentation iterates through all of the `pip.parse` calls and creates
166    different pip hub repositories based on the "hub_name".  Each of the
167    pip calls create spoke repos that uses a specific Python interpreter.
168
169    In a MODULES.bazel file we have:
170
171    pip.parse(
172        hub_name = "pip",
173        python_version = 3.9,
174        requirements_lock = "//:requirements_lock_3_9.txt",
175        requirements_windows = "//:requirements_windows_3_9.txt",
176    )
177    pip.parse(
178        hub_name = "pip",
179        python_version = 3.10,
180        requirements_lock = "//:requirements_lock_3_10.txt",
181        requirements_windows = "//:requirements_windows_3_10.txt",
182    )
183
184    For instance, we have a hub with the name of "pip".
185    A repository named the following is created. It is actually called last when
186    all of the pip spokes are collected.
187
188    - @@rules_python~override~pip~pip
189
190    As shown in the example code above we have the following.
191    Two different pip.parse statements exist in MODULE.bazel provide the hub_name "pip".
192    These definitions create two different pip spoke repositories that are
193    related to the hub "pip".
194    One spoke uses Python 3.9 and the other uses Python 3.10. This code automatically
195    determines the Python version and the interpreter.
196    Both of these pip spokes contain requirements files that includes websocket
197    and its dependencies.
198
199    Two different repositories are created for the two spokes:
200
201    - @@rules_python~override~pip~pip_39
202    - @@rules_python~override~pip~pip_310
203
204    The different spoke names are a combination of the hub_name and the Python version.
205    In the future we may remove this repository, but we do not support entry points.
206    yet, and that functionality exists in these repos.
207
208    We also need repositories for the wheels that the different pip spokes contain.
209    For each Python version a different wheel repository is created. In our example
210    each pip spoke had a requirments file that contained websockets. We
211    then create two different wheel repositories that are named the following.
212
213    - @@rules_python~override~pip~pip_39_websockets
214    - @@rules_python~override~pip~pip_310_websockets
215
216    And if the wheel has any other dependies subsequest wheels are created in the same fashion.
217
218    We also create a repository for the wheel alias.  We want to just use the syntax
219    'requirement("websockets")' we need to have an alias repository that is named:
220
221    - @@rules_python~override~pip~pip_websockets
222
223    This repository contains alias statements for the different wheel components (pkg, data, etc).
224    Each of those aliases has a select that resolves to a spoke repository depending on
225    the Python version.
226
227    Also we may have more than one hub as defined in a MODULES.bazel file.  So we could have multiple
228    hubs pointing to various different pip spokes.
229
230    Some other business rules notes.  A hub can only have one spoke per Python version.  We cannot
231    have a hub named "pip" that has two spokes that use the Python 3.9 interpreter.  Second
232    we cannot have the same hub name used in submodules.  The hub name has to be globally
233    unique.
234
235    This implementation reuses elements of non-bzlmod code and also reuses the first implementation
236    of pip bzlmod, but adds the capability to have multiple pip.parse calls.
237
238    This implementation also handles the creation of whl_modification JSON files that are used
239    during the creation of wheel libraries.  These JSON files used via the annotations argument
240    when calling wheel_installer.py.
241
242    Args:
243        module_ctx: module contents
244
245    """
246
247    # Build all of the wheel modifications if the tag class is called.
248    _whl_mods_impl(module_ctx)
249
250    # Used to track all the different pip hubs and the spoke pip Python
251    # versions.
252    pip_hub_map = {}
253
254    # Keeps track of all the hub's whl repos across the different versions.
255    # dict[hub, dict[whl, dict[version, str pip]]]
256    # Where hub, whl, and pip are the repo names
257    hub_whl_map = {}
258
259    for mod in module_ctx.modules:
260        for pip_attr in mod.tags.parse:
261            hub_name = pip_attr.hub_name
262            if hub_name in pip_hub_map:
263                # We cannot have two hubs with the same name in different
264                # modules.
265                if pip_hub_map[hub_name].module_name != mod.name:
266                    fail((
267                        "Duplicate cross-module pip hub named '{hub}': pip hub " +
268                        "names must be unique across modules. First defined " +
269                        "by module '{first_module}', second attempted by " +
270                        "module '{second_module}'"
271                    ).format(
272                        hub = hub_name,
273                        first_module = pip_hub_map[hub_name].module_name,
274                        second_module = mod.name,
275                    ))
276
277                if pip_attr.python_version in pip_hub_map[hub_name].python_versions:
278                    fail((
279                        "Duplicate pip python version '{version}' for hub " +
280                        "'{hub}' in module '{module}': the Python versions " +
281                        "used for a hub must be unique"
282                    ).format(
283                        hub = hub_name,
284                        module = mod.name,
285                        version = pip_attr.python_version,
286                    ))
287                else:
288                    pip_hub_map[pip_attr.hub_name].python_versions.append(pip_attr.python_version)
289            else:
290                pip_hub_map[pip_attr.hub_name] = struct(
291                    module_name = mod.name,
292                    python_versions = [pip_attr.python_version],
293                )
294
295            _create_versioned_pip_and_whl_repos(module_ctx, pip_attr, hub_whl_map)
296
297    for hub_name, whl_map in hub_whl_map.items():
298        for whl_name, version_map in whl_map.items():
299            if DEFAULT_PYTHON_VERSION in version_map:
300                whl_default_version = DEFAULT_PYTHON_VERSION
301            else:
302                whl_default_version = None
303
304            # Create the alias repositories which contains different select
305            # statements  These select statements point to the different pip
306            # whls that are based on a specific version of Python.
307            whl_library_alias(
308                name = hub_name + "_" + whl_name,
309                wheel_name = whl_name,
310                default_version = whl_default_version,
311                version_map = version_map,
312            )
313
314        # Create the hub repository for pip.
315        pip_hub_repository_bzlmod(
316            name = hub_name,
317            repo_name = hub_name,
318            whl_library_alias_names = whl_map.keys(),
319        )
320
321def _pip_parse_ext_attrs():
322    attrs = dict({
323        "hub_name": attr.string(
324            mandatory = True,
325            doc = """
326The name of the repo pip dependencies will be accessible from.
327
328This name must be unique between modules; unless your module is guaranteed to
329always be the root module, it's highly recommended to include your module name
330in the hub name. Repo mapping, `use_repo(..., pip="my_modules_pip_deps")`, can
331be used for shorter local names within your module.
332
333Within a module, the same `hub_name` can be specified to group different Python
334versions of pip dependencies under one repository name. This allows using a
335Python version-agnostic name when referring to pip dependencies; the
336correct version will be automatically selected.
337
338Typically, a module will only have a single hub of pip dependencies, but this
339is not required. Each hub is a separate resolution of pip dependencies. This
340means if different programs need different versions of some library, separate
341hubs can be created, and each program can use its respective hub's targets.
342Targets from different hubs should not be used together.
343""",
344        ),
345        "python_version": attr.string(
346            mandatory = True,
347            doc = """
348The Python version to use for resolving the pip dependencies, in Major.Minor
349format (e.g. "3.11"). Patch level granularity (e.g. "3.11.1") is not supported.
350If not specified, then the default Python version (as set by the root module or
351rules_python) will be used.
352
353The version specified here must have a corresponding `python.toolchain()`
354configured.
355""",
356        ),
357        "whl_modifications": attr.label_keyed_string_dict(
358            mandatory = False,
359            doc = """\
360A dict of labels to wheel names that is typically generated by the whl_modifications.
361The labels are JSON config files describing the modifications.
362""",
363        ),
364    }, **pip_repository_attrs)
365
366    # Like the pip_repository rule, we end up setting this manually so
367    # don't allow users to override it.
368    attrs.pop("repo_prefix")
369
370    # incompatible_generate_aliases is always True in bzlmod
371    attrs.pop("incompatible_generate_aliases")
372
373    return attrs
374
375def _whl_mod_attrs():
376    attrs = {
377        "additive_build_content": attr.string(
378            doc = "(str, optional): Raw text to add to the generated `BUILD` file of a package.",
379        ),
380        "additive_build_content_file": attr.label(
381            doc = """\
382(label, optional): path to a BUILD file to add to the generated
383`BUILD` file of a package. You cannot use both additive_build_content and additive_build_content_file
384arguments at the same time.""",
385        ),
386        "copy_executables": attr.string_dict(
387            doc = """\
388(dict, optional): A mapping of `src` and `out` files for
389[@bazel_skylib//rules:copy_file.bzl][cf]. Targets generated here will also be flagged as
390executable.""",
391        ),
392        "copy_files": attr.string_dict(
393            doc = """\
394(dict, optional): A mapping of `src` and `out` files for
395[@bazel_skylib//rules:copy_file.bzl][cf]""",
396        ),
397        "data": attr.string_list(
398            doc = """\
399(list, optional): A list of labels to add as `data` dependencies to
400the generated `py_library` target.""",
401        ),
402        "data_exclude_glob": attr.string_list(
403            doc = """\
404(list, optional): A list of exclude glob patterns to add as `data` to
405the generated `py_library` target.""",
406        ),
407        "hub_name": attr.string(
408            doc = """\
409Name of the whl modification, hub we use this name to set the modifications for
410pip.parse. If you have different pip hubs you can use a different name,
411otherwise it is best practice to just use one.
412
413You cannot have the same `hub_name` in different modules.  You can reuse the same
414name in the same module for different wheels that you put in the same hub, but you
415cannot have a child module that uses the same `hub_name`.
416""",
417            mandatory = True,
418        ),
419        "srcs_exclude_glob": attr.string_list(
420            doc = """\
421(list, optional): A list of labels to add as `srcs` to the generated
422`py_library` target.""",
423        ),
424        "whl_name": attr.string(
425            doc = "The whl name that the modifications are used for.",
426            mandatory = True,
427        ),
428    }
429    return attrs
430
431pip = module_extension(
432    doc = """\
433This extension is used to make dependencies from pip available.
434
435pip.parse:
436To use, call `pip.parse()` and specify `hub_name` and your requirements file.
437Dependencies will be downloaded and made available in a repo named after the
438`hub_name` argument.
439
440Each `pip.parse()` call configures a particular Python version. Multiple calls
441can be made to configure different Python versions, and will be grouped by
442the `hub_name` argument. This allows the same logical name, e.g. `@pip//numpy`
443to automatically resolve to different, Python version-specific, libraries.
444
445pip.whl_mods:
446This tag class is used to help create JSON files to describe modifications to
447the BUILD files for wheels.
448""",
449    implementation = _pip_impl,
450    tag_classes = {
451        "parse": tag_class(
452            attrs = _pip_parse_ext_attrs(),
453            doc = """\
454This tag class is used to create a pip hub and all of the spokes that are part of that hub.
455This tag class reuses most of the pip attributes that are found in
456@rules_python//python/pip_install:pip_repository.bzl.
457The exceptions are it does not use the args 'repo_prefix',
458and 'incompatible_generate_aliases'.  We set the repository prefix
459for the user and the alias arg is always True in bzlmod.
460""",
461        ),
462        "whl_mods": tag_class(
463            attrs = _whl_mod_attrs(),
464            doc = """\
465This tag class is used to create JSON file that are used when calling wheel_builder.py.  These
466JSON files contain instructions on how to modify a wheel's project.  Each of the attributes
467create different modifications based on the type of attribute. Previously to bzlmod these
468JSON files where referred to as annotations, and were renamed to whl_modifications in this
469extension.
470""",
471        ),
472    },
473)
474
475def _whl_mods_repo_impl(rctx):
476    rctx.file("BUILD.bazel", "")
477    for whl_name, mods in rctx.attr.whl_mods.items():
478        rctx.file("{}.json".format(whl_name), mods)
479
480_whl_mods_repo = repository_rule(
481    doc = """\
482This rule creates json files based on the whl_mods attribute.
483""",
484    implementation = _whl_mods_repo_impl,
485    attrs = {
486        "whl_mods": attr.string_dict(
487            mandatory = True,
488            doc = "JSON endcoded string that is provided to wheel_builder.py",
489        ),
490    },
491)
492