Skip to content

Commit 153c26e

Browse files
committed
feat(rules): allow deriving custom rules from core rules
1 parent c0b5075 commit 153c26e

File tree

12 files changed

+261
-23
lines changed

12 files changed

+261
-23
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ Unreleased changes template.
9191
* (pypi) Direct HTTP urls for wheels and sdists are now supported when using
9292
{obj}`experimental_index_url` (bazel downloader).
9393
Partially fixes [#2363](https://github.com/bazelbuild/rules_python/issues/2363).
94+
* (rules) APIs for creating custom rules based on the core py_binary, py_test,
95+
and py_library rules
96+
([#1647](https://github.com/bazelbuild/rules_python/issues/1647))
9497

9598
{#v0-0-0-removed}
9699
### Removed

docs/_includes/volatile_api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
:::{important}
2+
3+
**Public, but volatile, API.** Some parts are stable, while others are
4+
implementation details and may change more frequently.
5+
:::

docs/extending.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Extending the rules
2+
3+
:::{important}
4+
**This is public, but volatile, functionality.**
5+
6+
Extending and customizing the rules is supported functionality, but with weaker
7+
backwards compatibility guarantees, and is not fully subject to the normal
8+
backwards compatibility procedures and policies. It's simply not feasible to
9+
support every possible customization with strong backwards compatibility
10+
guarantees.
11+
:::
12+
13+
Because of the rich ecosystem of tools and variety of use cases, APIs are
14+
provided to make it easy to create custom rules using the existing rules as a
15+
basis. This allows implementing behaviors that aren't possible using
16+
wrapper macros around the core rules, and can make certain types of changes
17+
much easier and transparent to implement.
18+
19+
:::{note}
20+
It is not required to extend a core rule. The minimum requirement for a custom
21+
rule is to return the appropriate provider (e.g. {bzl:obj}`PyInfo` etc).
22+
Extending the core rules is most useful when you want all or most of the
23+
behavior of a core rule.
24+
:::
25+
26+
Follow or comment on https://github.com/bazelbuild/rules_python/issues/1647
27+
for the development of APIs to support custom derived rules.
28+
29+
## Creating custom rules
30+
31+
Custom rules can be created using the core rules as a basis by using their rule
32+
builder APIs.
33+
34+
* {bzl:obj}`//python/apis:executables.bzl%executables` for builders for executables
35+
* {bzl:obj}`//python/apis:libraries.bzl%libraries` for builders for libraries
36+
37+
These builders create {bzl:obj}`ruleb.Rule` objects, which are thin
38+
wrappers around the keyword arguments eventually passed to the `rule()`
39+
function. These builder APIs give access to the _entire_ rule definition and
40+
allow arbitrary modifications.
41+
42+
### Example: validating a source file
43+
44+
In this example, we derive from `py_library` a custom rule that verifies source
45+
code contains the word "snakes". It does this by:
46+
47+
* Adding an implicit dependency on a checker program
48+
* Calling the base implementation function
49+
* Running the checker on the srcs files
50+
* Adding the result to the `_validation` output group (a special output
51+
group for validation behaviors).
52+
53+
To users, they can use `has_snakes_library` the same as `py_library`. The same
54+
is true for other targets that might consume the rule.
55+
56+
```
57+
load("@rules_python//python/api:libraries.bzl", "libraries")
58+
load("@rules_python//python/api:attr_builders.bzl", "attrb")
59+
60+
def _has_snakes_impl(ctx, base):
61+
providers = base(ctx)
62+
63+
out = ctx.actions.declare_file(ctx.label.name + "_snakes.check")
64+
ctx.actions.run(
65+
inputs = ctx.files.srcs,
66+
outputs = [out],
67+
executable = ctx.attr._checker[DefaultInfo].files_to_run,
68+
args = [out.path] + [f.path for f in ctx.files.srcs],
69+
)
70+
prior_ogi = None
71+
for i, p in enumerate(providers):
72+
if type(p) == "OutputGroupInfo":
73+
prior_ogi = (i, p)
74+
break
75+
if prior_ogi:
76+
groups = {k: getattr(prior_ogi[1], k) for k in dir(prior_ogi)}
77+
if "_validation" in groups:
78+
groups["_validation"] = depset([out], transitive=groups["_validation"])
79+
else:
80+
groups["_validation"] = depset([out])
81+
providers[prior_ogi[0]] = OutputGroupInfo(**groups)
82+
else:
83+
providers.append(OutputGroupInfo(_validation=depset([out])))
84+
return providers
85+
86+
def create_has_snakes_rule():
87+
r = libraries.py_library_builder()
88+
base_impl = r.implementation()
89+
r.set_implementation(lambda ctx: _has_snakes_impl(ctx, base_impl))
90+
r.attrs["_checker"] = attrb.Label(
91+
default="//:checker",
92+
executable = True,
93+
)
94+
return r.build()
95+
has_snakes_library = create_has_snakes_rule()
96+
```
97+
98+
### Example: adding transitions
99+
100+
In this example, we derive from `py_binary` to force building for a particular
101+
platform. We do this by:
102+
103+
* Adding an additional output to the rule's cfg
104+
* Calling the base transition function
105+
* Returning the new transition outputs
106+
107+
```starlark
108+
109+
load("@rules_python//python/api:executables.bzl", "executables")
110+
111+
def _force_linux_impl(settings, attr, base_impl):
112+
settings = base_impl(settings, attr)
113+
settings["//command_line_option:platforms"] = ["//my/platforms:linux"]
114+
return settings
115+
116+
def create_rule():
117+
r = executables.py_binary_rule_builder()
118+
base_impl = r.cfg.implementation()
119+
r.cfg.set_implementation(
120+
lambda settings, attr: _force_linux_impl(settings, attr, base_impl)
121+
)
122+
r.cfg.add_output("//command_line_option:platforms")
123+
return r.build()
124+
125+
py_linux_binary = create_linux_binary_rule()
126+
```
127+
128+
Users can then use `py_linux_binary` the same as a regular py_binary. It will
129+
act as if `--platforms=//my/platforms:linux` was specified when building it.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ pip
101101
coverage
102102
precompiling
103103
gazelle
104+
Extending <extending>
104105
Contributing <contributing>
105106
support
106107
Changelog <changelog>

python/api/executables.bzl

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2025 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://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,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Loading-phase APIs specific to executables (binaries/tests).
16+
17+
:::{versionadded} VERSION_NEXT_FEATURE
18+
:::
19+
"""
20+
21+
load("//python/private:py_binary_rule.bzl", "create_py_binary_rule_builder")
22+
load("//python/private:py_executable.bzl", "create_executable_rule_builder")
23+
load("//python/private:py_test_rule.bzl", "create_test_rule_builder")
24+
25+
executables = struct(
26+
py_binary_rule_builder = create_py_binary_rule_builder,
27+
py_test_rule_builder = create_test_rule_builder,
28+
executable_rule_builder = create_executable_rule_builder,
29+
)

python/api/libraries.bzl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2025 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://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,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Loading-phase APIs specific to executables (binaries/tests).
16+
17+
:::{versionadded} VERSION_NEXT_FEATURE
18+
:::
19+
"""
20+
21+
load("//python/private:py_library.bzl", "create_py_library_rule_builder")
22+
23+
libraries = struct(
24+
py_library_rule_builder = create_py_library_rule_builder,
25+
)

python/private/attr_builders.bzl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Builders for creating attributes et al."""
15+
"""Builders for creating attributes et al.
16+
17+
:::{versionadded} VERSION_NEXT_FEATURE
18+
:::
19+
"""
1620

1721
load("@bazel_skylib//lib:types.bzl", "types")
1822
load(

python/private/py_binary_rule.bzl

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,25 @@ def _py_binary_impl(ctx):
2727
inherited_environment = [],
2828
)
2929

30-
def create_binary_rule_builder():
30+
# NOTE: Exported publicly
31+
def create_py_binary_rule_builder():
32+
"""Create a rule builder for a py_binary.
33+
34+
:::{include} /_includes/volatile_api.md
35+
:::
36+
37+
:::{versionadded} VERSION_NEXT_FEATURE
38+
:::
39+
40+
Returns:
41+
{type}`ruleb.Rule` with the necessary settings
42+
for creating a `py_binary` rule.
43+
"""
3144
builder = create_executable_rule_builder(
3245
implementation = _py_binary_impl,
3346
executable = True,
3447
)
3548
builder.attrs.update(AGNOSTIC_BINARY_ATTRS)
3649
return builder
3750

38-
py_binary = create_binary_rule_builder().build()
51+
py_binary = create_py_binary_rule_builder().build()

python/private/py_executable.bzl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,7 +1737,24 @@ def create_base_executable_rule():
17371737
"""
17381738
return create_executable_rule_builder().build()
17391739

1740+
# NOTE: Exported publicly
17401741
def create_executable_rule_builder(implementation, **kwargs):
1742+
"""Create a rule builder for an executable Python program.
1743+
1744+
:::{include} /_includes/volatile_api.md
1745+
:::
1746+
1747+
An executable rule is one that sets either `executable=True` or `test=True`,
1748+
and the output is something that can be run directly (e.g. `bazel run`,
1749+
`exec(...)` etc)
1750+
1751+
:::{versionadded} VERSION_NEXT_FEATURE
1752+
:::
1753+
1754+
Returns:
1755+
{type}`ruleb.Rule` with the necessary settings
1756+
for creating an executable Python rule.
1757+
"""
17411758
builder = ruleb.Rule(
17421759
implementation = implementation,
17431760
attrs = EXECUTABLE_ATTRS,

python/private/py_library.bzl

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -141,32 +141,28 @@ Source files are no longer added to the runfiles directly.
141141
:::
142142
"""
143143

144-
def create_py_library_rule_builder(*, attrs = {}, **kwargs):
145-
"""Creates a py_library rule.
144+
# NOTE: Exported publicaly
145+
def create_py_library_rule_builder():
146+
"""Create a rule builder for a py_library.
146147
147-
Args:
148-
attrs: dict of rule attributes.
149-
**kwargs: Additional kwargs to pass onto {obj}`ruleb.Rule()`.
148+
:::{include} /_includes/volatile_api.md
149+
:::
150+
151+
:::{versionadded} VERSION_NEXT_FEATURE
152+
:::
150153
151154
Returns:
152-
{type}`ruleb.Rule` builder object.
155+
{type}`ruleb.Rule` with the necessary settings
156+
for creating a `py_library` rule.
153157
"""
154-
155-
# Within Google, the doc attribute is overridden
156-
kwargs.setdefault("doc", _DEFAULT_PY_LIBRARY_DOC)
157-
158-
# TODO: b/253818097 - fragments=py is only necessary so that
159-
# RequiredConfigFragmentsTest passes
160-
fragments = kwargs.pop("fragments", None) or []
161-
kwargs["exec_groups"] = REQUIRED_EXEC_GROUP_BUILDERS | (kwargs.get("exec_groups") or {})
162-
163158
builder = ruleb.Rule(
164-
attrs = dicts.add(LIBRARY_ATTRS, attrs),
165-
fragments = fragments + ["py"],
159+
doc = _DEFAULT_PY_LIBRARY_DOC,
160+
exec_groups = REQUIRED_EXEC_GROUP_BUILDERS,
161+
attrs = LIBRARY_ATTRS,
162+
fragments = ["py"],
166163
toolchains = [
167164
ruleb.ToolchainType(TOOLCHAIN_TYPE, mandatory = False),
168165
ruleb.ToolchainType(EXEC_TOOLS_TOOLCHAIN_TYPE, mandatory = False),
169166
],
170-
**kwargs
171167
)
172168
return builder

0 commit comments

Comments
 (0)