• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2025 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://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, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""A rule defining importable Python modules that simplify locating runfiles."""
15
16load("@rules_python//python:defs.bzl", "py_library")
17
18_TEMPLATE = """# FILE GENERATED BY {label}, DO NOT EDIT!
19
20from pw_build.python_runfiles import PythonRunfilesLabelAdapter
21
22RLOCATION = PythonRunfilesLabelAdapter(
23    runfiles_path = "{runfiles_path}",
24    source_repo = {source_repo},
25)
26"""
27
28def _generated_runfile_import_impl(ctx):
29    import_path = "/".join(
30        [ctx.attr.import_dir] + ctx.attr.import_location.split("."),
31    )
32
33    generated_import = ctx.actions.declare_file(import_path + ".py")
34
35    if ctx.file.src:
36        f = ctx.file.src
37        runfiles = ctx.runfiles(ctx.files.src)
38        runfiles = runfiles.merge(ctx.attr.src[DefaultInfo].data_runfiles)
39        target_repo_name = ctx.attr.src.label.repo_name
40    elif ctx.executable.bin:
41        f = ctx.executable.bin
42        runfiles = ctx.runfiles()
43        runfiles = runfiles.merge(ctx.attr.bin[DefaultInfo].data_runfiles)
44        target_repo_name = ctx.attr.bin.label.repo_name
45    else:
46        fail(ctx.label, "requires `src` to be set")
47
48    if not target_repo_name:
49        target_repo_name = ctx.attr.module_name
50
51    # It's valid for this to be None. For example, the repo_name of the
52    # root repo defaults to None to support the canonical name of "@@".
53    current_repo_name = ctx.label.repo_name
54    current_repo_name = '"{}"'.format(current_repo_name) if current_repo_name else None
55
56    runfile_path = f.short_path
57
58    # External runfiles are of the form `../+_repo_blah/`, which needs to be
59    # stripped. The `..` is unneeded, and the `+_repo_blah` is redundant.
60    # This is ugly, but rules_python has to do something similar.
61    if runfile_path.startswith(".."):
62        runfile_path = "/".join(runfile_path.split("/")[2:])
63    runfile_path = "{}/{}".format(target_repo_name, runfile_path)
64
65    ctx.actions.write(
66        output = generated_import,
67        content = _TEMPLATE.format(
68            label = ctx.label,
69            runfiles_path = runfile_path,
70            source_repo = current_repo_name,
71        ),
72    )
73
74    return DefaultInfo(
75        files = depset(direct = [generated_import]),
76        runfiles = runfiles,
77    )
78
79_generated_runfile_import = rule(
80    implementation = _generated_runfile_import_impl,
81    attrs = {
82        "bin": attr.label(executable = True, cfg = "target", allow_files = True),
83        "import_dir": attr.string(mandatory = True),
84        "import_location": attr.string(mandatory = True),
85        "module_name": attr.string(),
86        "src": attr.label(allow_single_file = True),
87    },
88)
89
90def pw_py_importable_runfile(*, name, src = None, executable = False, import_location = None, **kwargs):
91    """An importable py_library that makes loading runfiles easier.
92
93    When using Bazel runfiles from Python, ``Rlocation()`` takes two arguments:
94
95    1. The ``path`` of the runfiles. This is the apparent repo name joined with
96       the path within that repo.
97    2. The ``source_repo`` to evaluate ``path`` from. This is related to how
98       apparent repo names and canonical repo names are handled by Bazel.
99
100    Unfortunately, it's easy to get these arguments wrong.
101
102    This generated Python library short-circuits this problem by letting Bazel
103    generate the correct arguments to ``Rlocation()`` so users don't even have
104    to think about what to pass.
105
106    For example:
107
108        ```
109        # In @bloaty//:BUILD.bazel, or wherever is convenient:
110        pw_py_importable_runfile(
111            name = "bloaty_runfiles",
112            src = "//:bin/bloaty",
113            executable = True,
114            import_location = "bloaty.bloaty_binary",
115        )
116
117        # Using the pw_py_importable_runfile from a py_binary in a
118        # BUILD.bazel file:
119        py_binary(
120            name = "my_binary",
121            srcs = ["my_binary.py"],
122            main = "my_binary.py",
123            deps = ["@bloaty//:bloaty_runfiles"],
124        )
125
126        # In my_binary.py:
127        import bloaty.bloaty_binary
128        from python.runfiles import runfiles  # type: ignore
129
130        r = runfiles.Create()
131        bloaty_path = r.Rlocation(*bloaty.bloaty_binary.RLOCATION)
132        ```
133
134    Note: Because this exposes runfiles as importable Python modules,
135    the import paths of the generated libraries may collide with existing
136    Python libraries. When this occurs, you need to extend the import path
137    of modules with generated files.
138
139    Args:
140        name: name of the target.
141        import_location: The final Python import path of the generated module.
142            By default, this is ``path.to.package.label_name``.
143        src: The file this library exposes as runfiles.
144        executable: Whether or not the source file is executable.
145        **kwargs: Common attributes to forward both underlying targets.
146    """
147    _generated_py_file = "{}._generated_py_import".format(name)
148    _virtual_import_dir = "_{}_virtual_imports".format((name))
149    if not import_location:
150        import_location = "/".join((
151            native.package_relative_label(":{}".format(name)).package,
152            name,
153        ))
154    _generated_runfile_import(
155        name = _generated_py_file,
156        src = src if not executable else None,
157        bin = src if executable else None,
158        import_location = import_location,
159        import_dir = _virtual_import_dir,
160        module_name = native.module_name(),
161        **kwargs
162    )
163    py_library(
164        name = name,
165        imports = [_virtual_import_dir],
166        srcs = [":" + _generated_py_file],
167        data = [src],
168        deps = [
169            Label("//pw_build/py:python_runfiles"),
170            Label("@rules_python//python/runfiles"),
171        ],
172        **kwargs
173    )
174