How to optionally load symbols using features.loadable_symbols
When writing Bazel rules, macros, or repository extensions that support
multiple versions of rules_python, you may want to detect whether a public
symbol (such as py_extension in //python/cc:py_extension.bzl) is
available before attempting to load or use it.
Because Starlark load() statements are evaluated at parse time and must be at
the top level of a .bzl file, unconditionally loading a symbol that does not
exist in older versions of rules_python will cause a build error.
The features.loadable_symbols dictionary in //python:features.bzl
allows you to programmatically inspect which symbols are exported by .bzl
files in the current rules_python version.
The features.loadable_symbols structure
features.loadable_symbols is a dict[str, list[str]] mapping label
strings of .bzl files to the list of public symbols they export:
load("@rules_python//python:features.bzl", "features")
# Example structure of features.loadable_symbols:
# {
# "//python/cc:py_extension.bzl": [
# "py_extension",
# ],
# "//python:py_info.bzl": [
# "PyInfo",
# ],
# }
Using load() with optional symbols
In repository rules or Bazel module extensions (repository_ctx or
module_ctx), you generate .bzl files dynamically. You can inspect
features.loadable_symbols to determine which load() statements to write into
a generated compatibility repository.
Re-export the symbol under its standard name if available, or set it to None
if it is absent. By generating compatibility files and empty BUILD.bazel
files at the exact same relative package paths as rules_python, the only
difference in downstream load() statements is the repository name:
load("@rules_python//python:features.bzl", "features")
def _rules_python_compat_impl(rctx):
for bzl, symbol_list in rctx.attr.symbols.items():
loadable = features.loadable_symbols.get(bzl, [])
lines = []
for symbol in symbol_list:
if symbol in loadable:
lines.append(
'load("{}", _{} = "{}")'.format(bzl, symbol, symbol),
)
lines.append("{} = _{}".format(symbol, symbol))
else:
lines.append("{} = None".format(symbol))
package, _, filename = bzl.lstrip("/").partition(":")
path = package + "/" + filename if package else filename
build_path = package + "/BUILD.bazel" if package else "BUILD.bazel"
rctx.file(path, content = "\n".join(lines) + "\n")
rctx.file(build_path, content = "")
rules_python_compat = repository_rule(
implementation = _rules_python_compat_impl,
attrs = {
"symbols": attr.string_list_dict(
mandatory = True,
doc = "Map of bzl paths to lists of symbols to optionally load",
),
},
)
Instantiate the repository rule by providing a mapping of .bzl paths to their
symbols of interest:
rules_python_compat(
name = "rules_python_compat",
symbols = {
"//python/cc:py_extension.bzl": ["py_extension"],
},
)
Using the generated compatibility files
Your macros and rules can load from @rules_python_compat using the same
file path as @rules_python, testing whether the symbol is None before
using it:
load("@rules_python_compat//python/cc:py_extension.bzl", "py_extension")
def my_macro(name, **kwargs):
if py_extension != None:
py_extension(
name = name + "_ext",
**kwargs
)
else:
# Fall back to default behavior for older rules_python versions
pass
Handling optional targets
In addition to symbol loading, you may need to check whether a specific Bazel
target exists in rules_python before referencing its label in dependencies,
toolchains, or attribute defaults.
The features.targets dictionary in //python:features.bzl is a
dict[str, bool] mapping public API target labels to True when available.
In a macro:
load("@rules_python//python:features.bzl", "features")
def my_cc_extension_macro(name, deps = [], **kwargs):
if features.targets.get("//python/cc:current_py_cc_headers_abi3"):
deps = deps + ["@rules_python//python/cc:current_py_cc_headers_abi3"]
# ... define target with deps
In a BUILD file:
load("@rules_python//python:features.bzl", "features")
load("@rules_python//python:py_library.bzl", "py_library")
py_library(
name = "my_lib",
srcs = ["my_lib.py"],
deps = [
"//my/app:base_lib",
] + (
["@rules_python//python/cc:current_py_cc_headers_abi3"]
if features.targets.get("//python/cc:current_py_cc_headers_abi3")
else []
),
)
Checking versions with features.version
When a behavioral change or capability is not directly reflected by a public
target or loadable symbol, you can inspect features.version in
//python:features.bzl.
features.version returns a semver-formatted version string (such as
"1.0.0", "2.0.0-rc2", or "" for unreleased development builds):
load("@rules_python//python:features.bzl", "features")
def _to_tuple(v):
return tuple([
int(x) if x.isdigit() else x
for x in v.replace("-", ".").split(".")
])
def has_foo():
# If version is empty, it is an unreleased build from main which includes
# all features.
if not features.version:
return True
return _to_tuple(features.version) >= _to_tuple("0.38.0")