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