| # Copyright 2025 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """A rule defining importable Python modules that simplify locating runfiles.""" |
| |
| load("@rules_python//python:defs.bzl", "py_library") |
| |
| _TEMPLATE = """# pylint: disable=line-too-long |
| ''' |
| FILE GENERATED BY {label}, DO NOT EDIT! |
| ''' |
| |
| from pw_build.python_runfiles import PythonRunfilesLabelAdapter |
| |
| RLOCATION = PythonRunfilesLabelAdapter( |
| runfiles_path = "{runfiles_path}", |
| source_repo = {source_repo}, |
| ) |
| """ |
| |
| def _generated_runfile_import_impl(ctx): |
| import_path = "/".join( |
| [ctx.attr.import_dir] + ctx.attr.import_location.split("."), |
| ) |
| |
| generated_import = ctx.actions.declare_file(import_path + ".py") |
| |
| if ctx.file.src: |
| f = ctx.file.src |
| runfiles = ctx.runfiles(ctx.files.src) |
| runfiles = runfiles.merge(ctx.attr.src[DefaultInfo].default_runfiles) |
| target_repo_name = ctx.attr.src.label.repo_name |
| elif ctx.executable.bin: |
| f = ctx.executable.bin |
| runfiles = ctx.runfiles() |
| runfiles = runfiles.merge(ctx.attr.bin[DefaultInfo].default_runfiles) |
| target_repo_name = ctx.attr.bin.label.repo_name |
| else: |
| fail(ctx.label, "requires `src` to be set") |
| |
| if not target_repo_name: |
| target_repo_name = ctx.attr.module_name |
| |
| # It's valid for this to be None. For example, the repo_name of the |
| # root repo defaults to None to support the canonical name of "@@". |
| current_repo_name = ctx.label.repo_name |
| |
| # This is strange, but intentional. `None` behaves differently from |
| # the empty string `""`. In Bazel, the root module is always an empty |
| # string, so this makes sense, but also goodness gracious this is why |
| # we abstract away runfiles handling. |
| # In case you're curious, a value of `None` rather than `""` will |
| # derive the repository from the caller of `Rlocation()`, which for |
| # libraries that wrap `Rlocation()` is usually technically wrong, but |
| # works so long as you're operating within Pigweed, masking the correctness |
| # issue. |
| if not current_repo_name: |
| current_repo_name = "" |
| current_repo_name = '"{}"'.format(current_repo_name) |
| |
| runfile_path = f.short_path |
| |
| # External runfiles are of the form `../+_repo_blah/`, which needs to be |
| # stripped. The `..` is unneeded, and the `+_repo_blah` is redundant. |
| # This is ugly, but rules_python has to do something similar. |
| if runfile_path.startswith(".."): |
| runfile_path = "/".join(runfile_path.split("/")[2:]) |
| runfile_path = "{}/{}".format(target_repo_name, runfile_path) |
| |
| ctx.actions.write( |
| output = generated_import, |
| content = _TEMPLATE.format( |
| label = ctx.label, |
| runfiles_path = runfile_path, |
| source_repo = current_repo_name, |
| ), |
| ) |
| |
| return DefaultInfo( |
| files = depset(direct = [generated_import]), |
| runfiles = runfiles, |
| ) |
| |
| _generated_runfile_import = rule( |
| implementation = _generated_runfile_import_impl, |
| attrs = { |
| "bin": attr.label(executable = True, cfg = "target", allow_files = True), |
| "import_dir": attr.string(mandatory = True), |
| "import_location": attr.string(mandatory = True), |
| "module_name": attr.string(), |
| "src": attr.label(allow_single_file = True), |
| }, |
| ) |
| |
| def pw_py_importable_runfile(*, name, src = None, executable = False, import_location = None, **kwargs): |
| """An importable py_library that makes loading runfiles easier. |
| |
| When using Bazel runfiles from Python, ``Rlocation()`` takes two arguments: |
| |
| 1. The ``path`` of the runfiles. This is the apparent repo name joined with |
| the path within that repo. |
| 2. The ``source_repo`` to evaluate ``path`` from. This is related to how |
| apparent repo names and canonical repo names are handled by Bazel. |
| |
| Unfortunately, it's easy to get these arguments wrong. |
| |
| This generated Python library short-circuits this problem by letting Bazel |
| generate the correct arguments to ``Rlocation()`` so users don't even have |
| to think about what to pass. |
| |
| For example: |
| |
| ``` |
| # In @bloaty//:BUILD.bazel, or wherever is convenient: |
| pw_py_importable_runfile( |
| name = "bloaty_runfiles", |
| src = "//:bin/bloaty", |
| executable = True, |
| import_location = "bloaty.bloaty_binary", |
| ) |
| |
| # Using the pw_py_importable_runfile from a py_binary in a |
| # BUILD.bazel file: |
| py_binary( |
| name = "my_binary", |
| srcs = ["my_binary.py"], |
| main = "my_binary.py", |
| deps = ["@bloaty//:bloaty_runfiles"], |
| ) |
| |
| # In my_binary.py: |
| import bloaty.bloaty_binary |
| from python.runfiles import runfiles # type: ignore |
| |
| r = runfiles.Create() |
| bloaty_path = r.Rlocation(*bloaty.bloaty_binary.RLOCATION) |
| ``` |
| |
| Note: Because this exposes runfiles as importable Python modules, |
| the import paths of the generated libraries may collide with existing |
| Python libraries. When this occurs, you need to extend the import path |
| of modules with generated files. |
| |
| Args: |
| name: name of the target. |
| import_location: The final Python import path of the generated module. |
| By default, this is ``path.to.package.label_name``. |
| src: The file this library exposes as runfiles. |
| executable: Whether or not the source file is executable. |
| **kwargs: Common attributes to forward both underlying targets. |
| """ |
| _generated_py_file = "{}._generated_py_import".format(name) |
| _virtual_import_dir = "_{}_virtual_imports".format((name)) |
| if not import_location: |
| import_location = "/".join(( |
| native.package_relative_label(":{}".format(name)).package, |
| name, |
| )) |
| _generated_runfile_import( |
| name = _generated_py_file, |
| src = src if not executable else None, |
| bin = src if executable else None, |
| import_location = import_location, |
| import_dir = _virtual_import_dir, |
| module_name = native.module_name(), |
| **kwargs |
| ) |
| py_library( |
| name = name, |
| imports = [_virtual_import_dir], |
| srcs = [":" + _generated_py_file], |
| data = [src], |
| deps = [ |
| Label("//pw_build/py:python_runfiles"), |
| Label("@rules_python//python/runfiles"), |
| ], |
| **kwargs |
| ) |