• 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""
16
17load("//python:repositories.bzl", "get_interpreter_dirname", "is_standalone_interpreter")
18load("//python:versions.bzl", "WINDOWS_NAME")
19load("//python/pip_install:repositories.bzl", "all_requirements")
20load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse")
21load("//python/pip_install/private:generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel")
22load("//python/pip_install/private:srcs.bzl", "PIP_INSTALL_PY_SRCS")
23load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")
24load("//python/private:normalize_name.bzl", "normalize_name")
25load("//python/private:render_pkg_aliases.bzl", "render_pkg_aliases")
26load("//python/private:toolchains_repo.bzl", "get_host_os_arch")
27load("//python/private:which.bzl", "which_with_fail")
28
29CPPFLAGS = "CPPFLAGS"
30
31COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools"
32
33_WHEEL_ENTRY_POINT_PREFIX = "rules_python_wheel_entry_point"
34
35def _construct_pypath(rctx):
36    """Helper function to construct a PYTHONPATH.
37
38    Contains entries for code in this repo as well as packages downloaded from //python/pip_install:repositories.bzl.
39    This allows us to run python code inside repository rule implementations.
40
41    Args:
42        rctx: Handle to the repository_context.
43    Returns: String of the PYTHONPATH.
44    """
45
46    # Get the root directory of these rules
47    rules_root = rctx.path(Label("//:BUILD.bazel")).dirname
48    thirdparty_roots = [
49        # Includes all the external dependencies from repositories.bzl
50        rctx.path(Label("@" + repo + "//:BUILD.bazel")).dirname
51        for repo in all_requirements
52    ]
53    separator = ":" if not "windows" in rctx.os.name.lower() else ";"
54    pypath = separator.join([str(p) for p in [rules_root] + thirdparty_roots])
55    return pypath
56
57def _get_python_interpreter_attr(rctx):
58    """A helper function for getting the `python_interpreter` attribute or it's default
59
60    Args:
61        rctx (repository_ctx): Handle to the rule repository context.
62
63    Returns:
64        str: The attribute value or it's default
65    """
66    if rctx.attr.python_interpreter:
67        return rctx.attr.python_interpreter
68
69    if "win" in rctx.os.name:
70        return "python.exe"
71    else:
72        return "python3"
73
74def _resolve_python_interpreter(rctx):
75    """Helper function to find the python interpreter from the common attributes
76
77    Args:
78        rctx: Handle to the rule repository context.
79    Returns: Python interpreter path.
80    """
81    python_interpreter = _get_python_interpreter_attr(rctx)
82
83    if rctx.attr.python_interpreter_target != None:
84        python_interpreter = rctx.path(rctx.attr.python_interpreter_target)
85
86        if BZLMOD_ENABLED:
87            (os, _) = get_host_os_arch(rctx)
88
89            # On Windows, the symlink doesn't work because Windows attempts to find
90            # Python DLLs where the symlink is, not where the symlink points.
91            if os == WINDOWS_NAME:
92                python_interpreter = python_interpreter.realpath
93    elif "/" not in python_interpreter:
94        found_python_interpreter = rctx.which(python_interpreter)
95        if not found_python_interpreter:
96            fail("python interpreter `{}` not found in PATH".format(python_interpreter))
97        python_interpreter = found_python_interpreter
98    return python_interpreter
99
100def _get_xcode_location_cflags(rctx):
101    """Query the xcode sdk location to update cflags
102
103    Figure out if this interpreter target comes from rules_python, and patch the xcode sdk location if so.
104    Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg
105    otherwise. See https://github.com/indygreg/python-build-standalone/issues/103
106    """
107
108    # Only run on MacOS hosts
109    if not rctx.os.name.lower().startswith("mac os"):
110        return []
111
112    xcode_sdk_location = rctx.execute([which_with_fail("xcode-select", rctx), "--print-path"])
113    if xcode_sdk_location.return_code != 0:
114        return []
115
116    xcode_root = xcode_sdk_location.stdout.strip()
117    if COMMAND_LINE_TOOLS_PATH_SLUG not in xcode_root.lower():
118        # This is a full xcode installation somewhere like /Applications/Xcode13.0.app/Contents/Developer
119        # so we need to change the path to to the macos specific tools which are in a different relative
120        # path than xcode installed command line tools.
121        xcode_root = "{}/Platforms/MacOSX.platform/Developer".format(xcode_root)
122    return [
123        "-isysroot {}/SDKs/MacOSX.sdk".format(xcode_root),
124    ]
125
126def _get_toolchain_unix_cflags(rctx):
127    """Gather cflags from a standalone toolchain for unix systems.
128
129    Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg
130    otherwise. See https://github.com/indygreg/python-build-standalone/issues/103
131    """
132
133    # Only run on Unix systems
134    if not rctx.os.name.lower().startswith(("mac os", "linux")):
135        return []
136
137    # Only update the location when using a standalone toolchain.
138    if not is_standalone_interpreter(rctx, rctx.attr.python_interpreter_target):
139        return []
140
141    er = rctx.execute([
142        rctx.path(rctx.attr.python_interpreter_target).realpath,
143        "-c",
144        "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}', end='')",
145    ])
146    if er.return_code != 0:
147        fail("could not get python version from interpreter (status {}): {}".format(er.return_code, er.stderr))
148    _python_version = er.stdout
149    include_path = "{}/include/python{}".format(
150        get_interpreter_dirname(rctx, rctx.attr.python_interpreter_target),
151        _python_version,
152    )
153
154    return ["-isystem {}".format(include_path)]
155
156def use_isolated(ctx, attr):
157    """Determine whether or not to pass the pip `--isolated` flag to the pip invocation.
158
159    Args:
160        ctx: repository or module context
161        attr: attributes for the repo rule or tag extension
162
163    Returns:
164        True if --isolated should be passed
165    """
166    use_isolated = attr.isolated
167
168    # The environment variable will take precedence over the attribute
169    isolated_env = ctx.os.environ.get("RULES_PYTHON_PIP_ISOLATED", None)
170    if isolated_env != None:
171        if isolated_env.lower() in ("0", "false"):
172            use_isolated = False
173        else:
174            use_isolated = True
175
176    return use_isolated
177
178def _parse_optional_attrs(rctx, args):
179    """Helper function to parse common attributes of pip_repository and whl_library repository rules.
180
181    This function also serializes the structured arguments as JSON
182    so they can be passed on the command line to subprocesses.
183
184    Args:
185        rctx: Handle to the rule repository context.
186        args: A list of parsed args for the rule.
187    Returns: Augmented args list.
188    """
189
190    if use_isolated(rctx, rctx.attr):
191        args.append("--isolated")
192
193    # Check for None so we use empty default types from our attrs.
194    # Some args want to be list, and some want to be dict.
195    if rctx.attr.extra_pip_args != None:
196        args += [
197            "--extra_pip_args",
198            json.encode(struct(arg = rctx.attr.extra_pip_args)),
199        ]
200
201    if rctx.attr.download_only:
202        args.append("--download_only")
203
204    if rctx.attr.pip_data_exclude != None:
205        args += [
206            "--pip_data_exclude",
207            json.encode(struct(arg = rctx.attr.pip_data_exclude)),
208        ]
209
210    if rctx.attr.enable_implicit_namespace_pkgs:
211        args.append("--enable_implicit_namespace_pkgs")
212
213    if rctx.attr.environment != None:
214        args += [
215            "--environment",
216            json.encode(struct(arg = rctx.attr.environment)),
217        ]
218
219    return args
220
221def _create_repository_execution_environment(rctx):
222    """Create a environment dictionary for processes we spawn with rctx.execute.
223
224    Args:
225        rctx: The repository context.
226    Returns:
227        Dictionary of environment variable suitable to pass to rctx.execute.
228    """
229
230    # Gather any available CPPFLAGS values
231    cppflags = []
232    cppflags.extend(_get_xcode_location_cflags(rctx))
233    cppflags.extend(_get_toolchain_unix_cflags(rctx))
234
235    env = {
236        "PYTHONPATH": _construct_pypath(rctx),
237        CPPFLAGS: " ".join(cppflags),
238    }
239
240    return env
241
242_BUILD_FILE_CONTENTS = """\
243package(default_visibility = ["//visibility:public"])
244
245# Ensure the `requirements.bzl` source can be accessed by stardoc, since users load() from it
246exports_files(["requirements.bzl"])
247"""
248
249def locked_requirements_label(ctx, attr):
250    """Get the preferred label for a locked requirements file based on platform.
251
252    Args:
253        ctx: repository or module context
254        attr: attributes for the repo rule or tag extension
255
256    Returns:
257        Label
258    """
259    os = ctx.os.name.lower()
260    requirements_txt = attr.requirements_lock
261    if os.startswith("mac os") and attr.requirements_darwin != None:
262        requirements_txt = attr.requirements_darwin
263    elif os.startswith("linux") and attr.requirements_linux != None:
264        requirements_txt = attr.requirements_linux
265    elif "win" in os and attr.requirements_windows != None:
266        requirements_txt = attr.requirements_windows
267    if not requirements_txt:
268        fail("""\
269A requirements_lock attribute must be specified, or a platform-specific lockfile using one of the requirements_* attributes.
270""")
271    return requirements_txt
272
273def _create_pip_repository_bzlmod(rctx, bzl_packages, requirements):
274    repo_name = rctx.attr.repo_name
275    build_contents = _BUILD_FILE_CONTENTS
276    aliases = render_pkg_aliases(repo_name = repo_name, bzl_packages = bzl_packages)
277    for path, contents in aliases.items():
278        rctx.file(path, contents)
279
280    # NOTE: we are using the canonical name with the double '@' in order to
281    # always uniquely identify a repository, as the labels are being passed as
282    # a string and the resolution of the label happens at the call-site of the
283    # `requirement`, et al. macros.
284    macro_tmpl = "@@{name}//{{}}:{{}}".format(name = rctx.attr.name)
285
286    rctx.file("BUILD.bazel", build_contents)
287    rctx.template("requirements.bzl", rctx.attr._template, substitutions = {
288        "%%ALL_DATA_REQUIREMENTS%%": _format_repr_list([
289            macro_tmpl.format(p, "data")
290            for p in bzl_packages
291        ]),
292        "%%ALL_REQUIREMENTS%%": _format_repr_list([
293            macro_tmpl.format(p, p)
294            for p in bzl_packages
295        ]),
296        "%%ALL_WHL_REQUIREMENTS%%": _format_repr_list([
297            macro_tmpl.format(p, "whl")
298            for p in bzl_packages
299        ]),
300        "%%MACRO_TMPL%%": macro_tmpl,
301        "%%NAME%%": rctx.attr.name,
302        "%%REQUIREMENTS_LOCK%%": requirements,
303    })
304
305def _pip_hub_repository_bzlmod_impl(rctx):
306    bzl_packages = rctx.attr.whl_library_alias_names
307    _create_pip_repository_bzlmod(rctx, bzl_packages, "")
308
309pip_hub_repository_bzlmod_attrs = {
310    "repo_name": attr.string(
311        mandatory = True,
312        doc = "The apparent name of the repo. This is needed because in bzlmod, the name attribute becomes the canonical name.",
313    ),
314    "whl_library_alias_names": attr.string_list(
315        mandatory = True,
316        doc = "The list of whl alias that we use to build aliases and the whl names",
317    ),
318    "_template": attr.label(
319        default = ":pip_hub_repository_requirements_bzlmod.bzl.tmpl",
320    ),
321}
322
323pip_hub_repository_bzlmod = repository_rule(
324    attrs = pip_hub_repository_bzlmod_attrs,
325    doc = """A rule for bzlmod mulitple pip repository creation. PRIVATE USE ONLY.""",
326    implementation = _pip_hub_repository_bzlmod_impl,
327)
328
329def _pip_repository_bzlmod_impl(rctx):
330    requirements_txt = locked_requirements_label(rctx, rctx.attr)
331    content = rctx.read(requirements_txt)
332    parsed_requirements_txt = parse_requirements(content)
333
334    packages = [(normalize_name(name), requirement) for name, requirement in parsed_requirements_txt.requirements]
335
336    bzl_packages = sorted([name for name, _ in packages])
337    _create_pip_repository_bzlmod(rctx, bzl_packages, str(requirements_txt))
338
339pip_repository_bzlmod_attrs = {
340    "repo_name": attr.string(
341        mandatory = True,
342        doc = "The apparent name of the repo. This is needed because in bzlmod, the name attribute becomes the canonical name",
343    ),
344    "requirements_darwin": attr.label(
345        allow_single_file = True,
346        doc = "Override the requirements_lock attribute when the host platform is Mac OS",
347    ),
348    "requirements_linux": attr.label(
349        allow_single_file = True,
350        doc = "Override the requirements_lock attribute when the host platform is Linux",
351    ),
352    "requirements_lock": attr.label(
353        allow_single_file = True,
354        doc = """
355A fully resolved 'requirements.txt' pip requirement file containing the transitive set of your dependencies. If this file is passed instead
356of 'requirements' no resolve will take place and pip_repository will create individual repositories for each of your dependencies so that
357wheels are fetched/built only for the targets specified by 'build/run/test'.
358""",
359    ),
360    "requirements_windows": attr.label(
361        allow_single_file = True,
362        doc = "Override the requirements_lock attribute when the host platform is Windows",
363    ),
364    "_template": attr.label(
365        default = ":pip_repository_requirements_bzlmod.bzl.tmpl",
366    ),
367}
368
369pip_repository_bzlmod = repository_rule(
370    attrs = pip_repository_bzlmod_attrs,
371    doc = """A rule for bzlmod pip_repository creation. Intended for private use only.""",
372    implementation = _pip_repository_bzlmod_impl,
373)
374
375def _pip_repository_impl(rctx):
376    requirements_txt = locked_requirements_label(rctx, rctx.attr)
377    content = rctx.read(requirements_txt)
378    parsed_requirements_txt = parse_requirements(content)
379
380    packages = [(normalize_name(name), requirement) for name, requirement in parsed_requirements_txt.requirements]
381
382    bzl_packages = sorted([name for name, _ in packages])
383
384    imports = [
385        'load("@rules_python//python/pip_install:pip_repository.bzl", "whl_library")',
386    ]
387
388    annotations = {}
389    for pkg, annotation in rctx.attr.annotations.items():
390        filename = "{}.annotation.json".format(normalize_name(pkg))
391        rctx.file(filename, json.encode_indent(json.decode(annotation)))
392        annotations[pkg] = "@{name}//:{filename}".format(name = rctx.attr.name, filename = filename)
393
394    tokenized_options = []
395    for opt in parsed_requirements_txt.options:
396        for p in opt.split(" "):
397            tokenized_options.append(p)
398
399    options = tokenized_options + rctx.attr.extra_pip_args
400
401    config = {
402        "download_only": rctx.attr.download_only,
403        "enable_implicit_namespace_pkgs": rctx.attr.enable_implicit_namespace_pkgs,
404        "environment": rctx.attr.environment,
405        "extra_pip_args": options,
406        "isolated": use_isolated(rctx, rctx.attr),
407        "pip_data_exclude": rctx.attr.pip_data_exclude,
408        "python_interpreter": _get_python_interpreter_attr(rctx),
409        "quiet": rctx.attr.quiet,
410        "repo": rctx.attr.name,
411        "repo_prefix": "{}_".format(rctx.attr.name),
412        "timeout": rctx.attr.timeout,
413    }
414
415    if rctx.attr.python_interpreter_target:
416        config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target)
417
418    if rctx.attr.incompatible_generate_aliases:
419        aliases = render_pkg_aliases(repo_name = rctx.attr.name, bzl_packages = bzl_packages)
420        for path, contents in aliases.items():
421            rctx.file(path, contents)
422
423    rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS)
424    rctx.template("requirements.bzl", rctx.attr._template, substitutions = {
425        "%%ALL_DATA_REQUIREMENTS%%": _format_repr_list([
426            "@{}//{}:data".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:data".format(rctx.attr.name, p)
427            for p in bzl_packages
428        ]),
429        "%%ALL_REQUIREMENTS%%": _format_repr_list([
430            "@{}//{}".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:pkg".format(rctx.attr.name, p)
431            for p in bzl_packages
432        ]),
433        "%%ALL_WHL_REQUIREMENTS%%": _format_repr_list([
434            "@{}//{}:whl".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:whl".format(rctx.attr.name, p)
435            for p in bzl_packages
436        ]),
437        "%%ANNOTATIONS%%": _format_dict(_repr_dict(annotations)),
438        "%%CONFIG%%": _format_dict(_repr_dict(config)),
439        "%%EXTRA_PIP_ARGS%%": json.encode(options),
440        "%%IMPORTS%%": "\n".join(sorted(imports)),
441        "%%NAME%%": rctx.attr.name,
442        "%%PACKAGES%%": _format_repr_list(
443            [
444                ("{}_{}".format(rctx.attr.name, p), r)
445                for p, r in packages
446            ],
447        ),
448        "%%REQUIREMENTS_LOCK%%": str(requirements_txt),
449    })
450
451    return
452
453common_env = [
454    "RULES_PYTHON_PIP_ISOLATED",
455]
456
457common_attrs = {
458    "download_only": attr.bool(
459        doc = """
460Whether to use "pip download" instead of "pip wheel". Disables building wheels from source, but allows use of
461--platform, --python-version, --implementation, and --abi in --extra_pip_args to download wheels for a different
462platform from the host platform.
463        """,
464    ),
465    "enable_implicit_namespace_pkgs": attr.bool(
466        default = False,
467        doc = """
468If true, disables conversion of native namespace packages into pkg-util style namespace packages. When set all py_binary
469and py_test targets must specify either `legacy_create_init=False` or the global Bazel option
470`--incompatible_default_to_explicit_init_py` to prevent `__init__.py` being automatically generated in every directory.
471
472This option is required to support some packages which cannot handle the conversion to pkg-util style.
473            """,
474    ),
475    "environment": attr.string_dict(
476        doc = """
477Environment variables to set in the pip subprocess.
478Can be used to set common variables such as `http_proxy`, `https_proxy` and `no_proxy`
479Note that pip is run with "--isolated" on the CLI so `PIP_<VAR>_<NAME>`
480style env vars are ignored, but env vars that control requests and urllib3
481can be passed.
482        """,
483        default = {},
484    ),
485    "extra_pip_args": attr.string_list(
486        doc = "Extra arguments to pass on to pip. Must not contain spaces.",
487    ),
488    "isolated": attr.bool(
489        doc = """\
490Whether or not to pass the [--isolated](https://pip.pypa.io/en/stable/cli/pip/#cmdoption-isolated) flag to
491the underlying pip command. Alternatively, the `RULES_PYTHON_PIP_ISOLATED` environment variable can be used
492to control this flag.
493""",
494        default = True,
495    ),
496    "pip_data_exclude": attr.string_list(
497        doc = "Additional data exclusion parameters to add to the pip packages BUILD file.",
498    ),
499    "python_interpreter": attr.string(
500        doc = """\
501The python interpreter to use. This can either be an absolute path or the name
502of a binary found on the host's `PATH` environment variable. If no value is set
503`python3` is defaulted for Unix systems and `python.exe` for Windows.
504""",
505        # NOTE: This attribute should not have a default. See `_get_python_interpreter_attr`
506        # default = "python3"
507    ),
508    "python_interpreter_target": attr.label(
509        allow_single_file = True,
510        doc = """
511If you are using a custom python interpreter built by another repository rule,
512use this attribute to specify its BUILD target. This allows pip_repository to invoke
513pip using the same interpreter as your toolchain. If set, takes precedence over
514python_interpreter. An example value: "@python3_x86_64-unknown-linux-gnu//:python".
515""",
516    ),
517    "quiet": attr.bool(
518        default = True,
519        doc = "If True, suppress printing stdout and stderr output to the terminal.",
520    ),
521    "repo_prefix": attr.string(
522        doc = """
523Prefix for the generated packages will be of the form `@<prefix><sanitized-package-name>//...`
524""",
525    ),
526    # 600 is documented as default here: https://docs.bazel.build/versions/master/skylark/lib/repository_ctx.html#execute
527    "timeout": attr.int(
528        default = 600,
529        doc = "Timeout (in seconds) on the rule's execution duration.",
530    ),
531    "_py_srcs": attr.label_list(
532        doc = "Python sources used in the repository rule",
533        allow_files = True,
534        default = PIP_INSTALL_PY_SRCS,
535    ),
536}
537
538pip_repository_attrs = {
539    "annotations": attr.string_dict(
540        doc = "Optional annotations to apply to packages",
541    ),
542    "incompatible_generate_aliases": attr.bool(
543        default = False,
544        doc = "Allow generating aliases '@pip//<pkg>' -> '@pip_<pkg>//:pkg'.",
545    ),
546    "requirements_darwin": attr.label(
547        allow_single_file = True,
548        doc = "Override the requirements_lock attribute when the host platform is Mac OS",
549    ),
550    "requirements_linux": attr.label(
551        allow_single_file = True,
552        doc = "Override the requirements_lock attribute when the host platform is Linux",
553    ),
554    "requirements_lock": attr.label(
555        allow_single_file = True,
556        doc = """
557A fully resolved 'requirements.txt' pip requirement file containing the transitive set of your dependencies. If this file is passed instead
558of 'requirements' no resolve will take place and pip_repository will create individual repositories for each of your dependencies so that
559wheels are fetched/built only for the targets specified by 'build/run/test'.
560""",
561    ),
562    "requirements_windows": attr.label(
563        allow_single_file = True,
564        doc = "Override the requirements_lock attribute when the host platform is Windows",
565    ),
566    "_template": attr.label(
567        default = ":pip_repository_requirements.bzl.tmpl",
568    ),
569}
570
571pip_repository_attrs.update(**common_attrs)
572
573pip_repository = repository_rule(
574    attrs = pip_repository_attrs,
575    doc = """A rule for importing `requirements.txt` dependencies into Bazel.
576
577This rule imports a `requirements.txt` file and generates a new
578`requirements.bzl` file.  This is used via the `WORKSPACE` pattern:
579
580```python
581pip_repository(
582    name = "foo",
583    requirements = ":requirements.txt",
584)
585```
586
587You can then reference imported dependencies from your `BUILD` file with:
588
589```python
590load("@foo//:requirements.bzl", "requirement")
591py_library(
592    name = "bar",
593    ...
594    deps = [
595       "//my/other:dep",
596       requirement("requests"),
597       requirement("numpy"),
598    ],
599)
600```
601
602Or alternatively:
603```python
604load("@foo//:requirements.bzl", "all_requirements")
605py_binary(
606    name = "baz",
607    ...
608    deps = [
609       ":foo",
610    ] + all_requirements,
611)
612```
613""",
614    implementation = _pip_repository_impl,
615    environ = common_env,
616)
617
618def _whl_library_impl(rctx):
619    python_interpreter = _resolve_python_interpreter(rctx)
620    args = [
621        python_interpreter,
622        "-m",
623        "python.pip_install.tools.wheel_installer.wheel_installer",
624        "--requirement",
625        rctx.attr.requirement,
626    ]
627
628    args = _parse_optional_attrs(rctx, args)
629
630    result = rctx.execute(
631        args,
632        # Manually construct the PYTHONPATH since we cannot use the toolchain here
633        environment = _create_repository_execution_environment(rctx),
634        quiet = rctx.attr.quiet,
635        timeout = rctx.attr.timeout,
636    )
637
638    if result.return_code:
639        fail("whl_library %s failed: %s (%s) error code: '%s'" % (rctx.attr.name, result.stdout, result.stderr, result.return_code))
640
641    metadata = json.decode(rctx.read("metadata.json"))
642    rctx.delete("metadata.json")
643
644    entry_points = {}
645    for item in metadata["entry_points"]:
646        name = item["name"]
647        module = item["module"]
648        attribute = item["attribute"]
649
650        # There is an extreme edge-case with entry_points that end with `.py`
651        # See: https://github.com/bazelbuild/bazel/blob/09c621e4cf5b968f4c6cdf905ab142d5961f9ddc/src/test/java/com/google/devtools/build/lib/rules/python/PyBinaryConfiguredTargetTest.java#L174
652        entry_point_without_py = name[:-3] + "_py" if name.endswith(".py") else name
653        entry_point_target_name = (
654            _WHEEL_ENTRY_POINT_PREFIX + "_" + entry_point_without_py
655        )
656        entry_point_script_name = entry_point_target_name + ".py"
657
658        rctx.file(
659            entry_point_script_name,
660            _generate_entry_point_contents(module, attribute),
661        )
662        entry_points[entry_point_without_py] = entry_point_script_name
663
664    build_file_contents = generate_whl_library_build_bazel(
665        repo_prefix = rctx.attr.repo_prefix,
666        dependencies = metadata["deps"],
667        data_exclude = rctx.attr.pip_data_exclude,
668        tags = [
669            "pypi_name=" + metadata["name"],
670            "pypi_version=" + metadata["version"],
671        ],
672        entry_points = entry_points,
673        annotation = None if not rctx.attr.annotation else struct(**json.decode(rctx.read(rctx.attr.annotation))),
674    )
675    rctx.file("BUILD.bazel", build_file_contents)
676
677    return
678
679def _generate_entry_point_contents(
680        module,
681        attribute,
682        shebang = "#!/usr/bin/env python3"):
683    """Generate the contents of an entry point script.
684
685    Args:
686        module (str): The name of the module to use.
687        attribute (str): The name of the attribute to call.
688        shebang (str, optional): The shebang to use for the entry point python
689            file.
690
691    Returns:
692        str: A string of python code.
693    """
694    contents = """\
695{shebang}
696import sys
697from {module} import {attribute}
698if __name__ == "__main__":
699    sys.exit({attribute}())
700""".format(
701        shebang = shebang,
702        module = module,
703        attribute = attribute,
704    )
705    return contents
706
707whl_library_attrs = {
708    "annotation": attr.label(
709        doc = (
710            "Optional json encoded file containing annotation to apply to the extracted wheel. " +
711            "See `package_annotation`"
712        ),
713        allow_files = True,
714    ),
715    "repo": attr.string(
716        mandatory = True,
717        doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.",
718    ),
719    "requirement": attr.string(
720        mandatory = True,
721        doc = "Python requirement string describing the package to make available",
722    ),
723}
724
725whl_library_attrs.update(**common_attrs)
726
727whl_library = repository_rule(
728    attrs = whl_library_attrs,
729    doc = """
730Download and extracts a single wheel based into a bazel repo based on the requirement string passed in.
731Instantiated from pip_repository and inherits config options from there.""",
732    implementation = _whl_library_impl,
733    environ = common_env,
734)
735
736def package_annotation(
737        additive_build_content = None,
738        copy_files = {},
739        copy_executables = {},
740        data = [],
741        data_exclude_glob = [],
742        srcs_exclude_glob = []):
743    """Annotations to apply to the BUILD file content from package generated from a `pip_repository` rule.
744
745    [cf]: https://github.com/bazelbuild/bazel-skylib/blob/main/docs/copy_file_doc.md
746
747    Args:
748        additive_build_content (str, optional): Raw text to add to the generated `BUILD` file of a package.
749        copy_files (dict, optional): A mapping of `src` and `out` files for [@bazel_skylib//rules:copy_file.bzl][cf]
750        copy_executables (dict, optional): A mapping of `src` and `out` files for
751            [@bazel_skylib//rules:copy_file.bzl][cf]. Targets generated here will also be flagged as
752            executable.
753        data (list, optional): A list of labels to add as `data` dependencies to the generated `py_library` target.
754        data_exclude_glob (list, optional): A list of exclude glob patterns to add as `data` to the generated
755            `py_library` target.
756        srcs_exclude_glob (list, optional): A list of labels to add as `srcs` to the generated `py_library` target.
757
758    Returns:
759        str: A json encoded string of the provided content.
760    """
761    return json.encode(struct(
762        additive_build_content = additive_build_content,
763        copy_files = copy_files,
764        copy_executables = copy_executables,
765        data = data,
766        data_exclude_glob = data_exclude_glob,
767        srcs_exclude_glob = srcs_exclude_glob,
768    ))
769
770# pip_repository implementation
771
772def _format_list(items):
773    return "[{}]".format(", ".join(items))
774
775def _format_repr_list(strings):
776    return _format_list(
777        [repr(s) for s in strings],
778    )
779
780def _repr_dict(items):
781    return {k: repr(v) for k, v in items.items()}
782
783def _format_dict(items):
784    return "{{{}}}".format(", ".join(sorted(['"{}": {}'.format(k, v) for k, v in items.items()])))
785