| :::{default-domain} bzl | |
| ::: | |
| # 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 {obj}`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 {bzl:obj}`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 | |
| {bzl:obj}`features.loadable_symbols` is a `dict[str, list[str]]` mapping label | |
| strings of `.bzl` files to the list of public symbols they export: | |
| ```starlark | |
| 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: | |
| ```starlark | |
| 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: | |
| ```starlark | |
| 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: | |
| ```starlark | |
| 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 {bzl:obj}`features.targets` dictionary in `//python:features.bzl` is a | |
| `dict[str, bool]` mapping public API target labels to `True` when available. | |
| In a macro: | |
| ```starlark | |
| 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: | |
| ```starlark | |
| 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 {bzl:obj}`features.version` in | |
| `//python:features.bzl`. | |
| {bzl:obj}`features.version` returns a semver-formatted version string (such as | |
| `"1.0.0"`, `"2.0.0-rc2"`, or `""` for unreleased development builds): | |
| ```starlark | |
| 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") | |
| ``` |