Skip to content

Commit a2bdf6b

Browse files
authored
docs(howto): add optional feature loading guide (bazel-contrib#4003)
Macro and rule authors need clear guidance on writing Bazel code that remains compatible across multiple rules_python releases as new APIs are introduced. Previously, using features.loadable_symbols, targets, and version lacked dedicated documentation, making it harder for developers to know how to use these feature-detection APIs. Add a how-to guide (docs/howto/optional-feature-loading.md) showing how to inspect //python:features.bzl and generate compatibility repo rules, BUILD files, and macro version checks.
1 parent b84447f commit a2bdf6b

1 file changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
:::{default-domain} bzl
2+
:::
3+
4+
# How to optionally load symbols using `features.loadable_symbols`
5+
6+
When writing Bazel rules, macros, or repository extensions that support
7+
multiple versions of `rules_python`, you may want to detect whether a public
8+
symbol (such as {obj}`py_extension` in `//python/cc:py_extension.bzl`) is
9+
available before attempting to load or use it.
10+
11+
Because Starlark `load()` statements are evaluated at parse time and must be at
12+
the top level of a `.bzl` file, unconditionally loading a symbol that does not
13+
exist in older versions of `rules_python` will cause a build error.
14+
15+
The {bzl:obj}`features.loadable_symbols` dictionary in `//python:features.bzl`
16+
allows you to programmatically inspect which symbols are exported by `.bzl`
17+
files in the current `rules_python` version.
18+
19+
## The `features.loadable_symbols` structure
20+
21+
{bzl:obj}`features.loadable_symbols` is a `dict[str, list[str]]` mapping label
22+
strings of `.bzl` files to the list of public symbols they export:
23+
24+
```starlark
25+
load("@rules_python//python:features.bzl", "features")
26+
27+
# Example structure of features.loadable_symbols:
28+
# {
29+
# "//python/cc:py_extension.bzl": [
30+
# "py_extension",
31+
# ],
32+
# "//python:py_info.bzl": [
33+
# "PyInfo",
34+
# ],
35+
# }
36+
```
37+
38+
## Using load() with optional symbols
39+
40+
In repository rules or Bazel module extensions (`repository_ctx` or
41+
`module_ctx`), you generate `.bzl` files dynamically. You can inspect
42+
`features.loadable_symbols` to determine which `load()` statements to write into
43+
a generated compatibility repository.
44+
45+
Re-export the symbol under its standard name if available, or set it to `None`
46+
if it is absent. By generating compatibility files and empty `BUILD.bazel`
47+
files at the exact same relative package paths as `rules_python`, the only
48+
difference in downstream `load()` statements is the repository name:
49+
50+
```starlark
51+
load("@rules_python//python:features.bzl", "features")
52+
53+
def _rules_python_compat_impl(rctx):
54+
for bzl, symbol_list in rctx.attr.symbols.items():
55+
loadable = features.loadable_symbols.get(bzl, [])
56+
lines = []
57+
for symbol in symbol_list:
58+
if symbol in loadable:
59+
lines.append(
60+
'load("{}", _{} = "{}")'.format(bzl, symbol, symbol),
61+
)
62+
lines.append("{} = _{}".format(symbol, symbol))
63+
else:
64+
lines.append("{} = None".format(symbol))
65+
66+
package, _, filename = bzl.lstrip("/").partition(":")
67+
path = package + "/" + filename if package else filename
68+
build_path = package + "/BUILD.bazel" if package else "BUILD.bazel"
69+
70+
rctx.file(path, content = "\n".join(lines) + "\n")
71+
rctx.file(build_path, content = "")
72+
73+
rules_python_compat = repository_rule(
74+
implementation = _rules_python_compat_impl,
75+
attrs = {
76+
"symbols": attr.string_list_dict(
77+
mandatory = True,
78+
doc = "Map of bzl paths to lists of symbols to optionally load",
79+
),
80+
},
81+
)
82+
```
83+
84+
Instantiate the repository rule by providing a mapping of `.bzl` paths to their
85+
symbols of interest:
86+
87+
```starlark
88+
rules_python_compat(
89+
name = "rules_python_compat",
90+
symbols = {
91+
"//python/cc:py_extension.bzl": ["py_extension"],
92+
},
93+
)
94+
```
95+
96+
### Using the generated compatibility files
97+
98+
Your macros and rules can load from `@rules_python_compat` using the same
99+
file path as `@rules_python`, testing whether the symbol is `None` before
100+
using it:
101+
102+
```starlark
103+
load("@rules_python_compat//python/cc:py_extension.bzl", "py_extension")
104+
105+
def my_macro(name, **kwargs):
106+
if py_extension != None:
107+
py_extension(
108+
name = name + "_ext",
109+
**kwargs
110+
)
111+
else:
112+
# Fall back to default behavior for older rules_python versions
113+
pass
114+
```
115+
116+
## Handling optional targets
117+
118+
In addition to symbol loading, you may need to check whether a specific Bazel
119+
target exists in `rules_python` before referencing its label in dependencies,
120+
toolchains, or attribute defaults.
121+
122+
The {bzl:obj}`features.targets` dictionary in `//python:features.bzl` is a
123+
`dict[str, bool]` mapping public API target labels to `True` when available.
124+
125+
In a macro:
126+
127+
```starlark
128+
load("@rules_python//python:features.bzl", "features")
129+
130+
def my_cc_extension_macro(name, deps = [], **kwargs):
131+
if features.targets.get("//python/cc:current_py_cc_headers_abi3"):
132+
deps = deps + ["@rules_python//python/cc:current_py_cc_headers_abi3"]
133+
134+
# ... define target with deps
135+
```
136+
137+
In a `BUILD` file:
138+
139+
```starlark
140+
load("@rules_python//python:features.bzl", "features")
141+
load("@rules_python//python:py_library.bzl", "py_library")
142+
143+
py_library(
144+
name = "my_lib",
145+
srcs = ["my_lib.py"],
146+
deps = [
147+
"//my/app:base_lib",
148+
] + (
149+
["@rules_python//python/cc:current_py_cc_headers_abi3"]
150+
if features.targets.get("//python/cc:current_py_cc_headers_abi3")
151+
else []
152+
),
153+
)
154+
```
155+
156+
## Checking versions with `features.version`
157+
158+
When a behavioral change or capability is not directly reflected by a public
159+
target or loadable symbol, you can inspect {bzl:obj}`features.version` in
160+
`//python:features.bzl`.
161+
162+
{bzl:obj}`features.version` returns a semver-formatted version string (such as
163+
`"1.0.0"`, `"2.0.0-rc2"`, or `""` for unreleased development builds):
164+
165+
```starlark
166+
load("@rules_python//python:features.bzl", "features")
167+
168+
def _to_tuple(v):
169+
return tuple([
170+
int(x) if x.isdigit() else x
171+
for x in v.replace("-", ".").split(".")
172+
])
173+
174+
def has_foo():
175+
# If version is empty, it is an unreleased build from main which includes
176+
# all features.
177+
if not features.version:
178+
return True
179+
return _to_tuple(features.version) >= _to_tuple("0.38.0")
180+
```

0 commit comments

Comments
 (0)