Skip to content

Commit c155afa

Browse files
authored
feat: add py_image_layer (#402)
Replaces #349 ### Changes are visible to end-users: yes - Searched for relevant documentation and updated as needed: yes - Breaking change (forces users to change their own code or config): no - Suggested release notes appear below: yes Add `py_image_layer` macro for creating py container images. ### Test plan I will add a test in a follow-up.
1 parent fb114ab commit c155afa

File tree

9 files changed

+264
-6
lines changed

9 files changed

+264
-6
lines changed

MODULE.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module(
88

99
# Lower-bound versions of direct dependencies.
1010
# When bumping, add a comment explaining what's required from the newer release.
11-
bazel_dep(name = "aspect_bazel_lib", version = "1.40.0")
11+
bazel_dep(name = "aspect_bazel_lib", version = "2.9.1") # py_image_layer requires 2.x for the `tar` rule.
1212
bazel_dep(name = "bazel_skylib", version = "1.4.2")
1313
bazel_dep(name = "rules_python", version = "0.29.0")
1414
bazel_dep(name = "platforms", version = "0.0.7")

docs/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ stardoc_with_diff_test(
3131
bzl_library_target = "//py/private:py_pex_binary",
3232
)
3333

34+
stardoc_with_diff_test(
35+
name = "py_image_layer",
36+
bzl_library_target = "//py/private:py_image_layer",
37+
)
38+
3439
stardoc_with_diff_test(
3540
name = "venv",
3641
bzl_library_target = "//py/private:py_venv",

docs/py_image_layer.md

Lines changed: 81 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

py/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ bzl_library(
3838
"//py/private:py_wheel",
3939
"//py/private:virtual",
4040
"//py/private:py_pex_binary",
41+
"//py/private:py_image_layer",
4142
"@aspect_bazel_lib//lib:utils",
4243
],
4344
)

py/defs.bzl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@ python.toolchain(python_version = "3.9", is_default = True)
3838
load("@aspect_bazel_lib//lib:utils.bzl", "propagate_common_rule_attributes")
3939
load("//py/private:py_binary.bzl", _py_binary = "py_binary", _py_test = "py_test")
4040
load("//py/private:py_executable.bzl", "determine_main")
41+
load("//py/private:py_image_layer.bzl", _py_image_layer = "py_image_layer")
4142
load("//py/private:py_library.bzl", _py_library = "py_library")
4243
load("//py/private:py_pex_binary.bzl", _py_pex_binary = "py_pex_binary")
4344
load("//py/private:py_pytest_main.bzl", _py_pytest_main = "py_pytest_main")
4445
load("//py/private:py_unpacked_wheel.bzl", _py_unpacked_wheel = "py_unpacked_wheel")
45-
load("//py/private:virtual.bzl", _resolutions = "resolutions")
4646
load("//py/private:py_venv.bzl", _py_venv = "py_venv")
47+
load("//py/private:virtual.bzl", _resolutions = "resolutions")
4748

4849
py_pex_binary = _py_pex_binary
4950
py_pytest_main = _py_pytest_main
@@ -54,6 +55,8 @@ py_test_rule = _py_test
5455
py_library = _py_library
5556
py_unpacked_wheel = _py_unpacked_wheel
5657

58+
py_image_layer = _py_image_layer
59+
5760
resolutions = _resolutions
5861

5962
def _py_binary_or_test(name, rule, srcs, main, deps = [], resolutions = {}, **kwargs):

py/private/BUILD.bazel

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ exports_files(
2222
visibility = ["//docs:__pkg__"],
2323
)
2424

25+
bzl_library(
26+
name = "py_image_layer",
27+
srcs = ["py_image_layer.bzl"],
28+
deps = [
29+
"@aspect_bazel_lib//lib:tar",
30+
],
31+
)
32+
2533
bzl_library(
2634
name = "py_binary",
2735
srcs = ["py_binary.bzl"],

py/private/py_image_layer.bzl

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""py_image_layer macro for creating multiple layers from a py_binary
2+
3+
> [!WARNING]
4+
> This macro is EXPERIMENTAL and is not subject to our SemVer guarantees.
5+
6+
A py_binary that uses `torch` and `numpy` can use the following layer groups:
7+
8+
```
9+
load("@rules_oci//oci:defs.bzl", "oci_image")
10+
load("@aspect_rules_py//py:defs.bzl", "py_image_layer", "py_binary")
11+
12+
py_binary(
13+
name = "my_app_bin",
14+
deps = [
15+
"@pip_deps//numpy",
16+
"@pip_deps//torch"
17+
]
18+
)
19+
20+
oci_image(
21+
tars = py_image_layer(
22+
name = "my_app",
23+
py_binary = ":my_app_bin",
24+
layer_groups = {
25+
"torch": "pip_deps_torch.*",
26+
"numpy": "pip_deps_numpy.*",
27+
}
28+
)
29+
)
30+
```
31+
"""
32+
33+
load("@aspect_bazel_lib//lib:tar.bzl", "mtree_spec", "tar")
34+
35+
default_layer_groups = {
36+
# match *only* external pip like repositories that contain the string "site-packages"
37+
"packages": "\\.runfiles/.*/site-packages",
38+
# match *only* external repositories that begins with the string "python"
39+
# e.g. this will match
40+
# `/hello_world/hello_world_bin.runfiles/rules_python~0.21.0~python~python3_9_aarch64-unknown-linux-gnu/bin/python3`
41+
# but not match
42+
# `/hello_world/hello_world_bin.runfiles/_main/python_app`
43+
"interpreter": "\\.runfiles/python.*-.*/",
44+
}
45+
46+
def _split_mtree_into_layer_groups(name, root, groups, group_names, **kwargs):
47+
mtree_begin_blocks = "\n".join([
48+
'print "#mtree" >> "$(RULEDIR)/%s.%s.manifest.spec";' % (name, gn)
49+
for gn in group_names
50+
])
51+
52+
# When an mtree entry matches a layer group, it will be moved into the mtree
53+
# for that group.
54+
ifs = "\n".join([
55+
"""\
56+
if ($$1 ~ "%s") {
57+
print $$0 >> "$(RULEDIR)/%s.%s.manifest.spec";
58+
next
59+
}""" % (regex, name, gn)
60+
for (gn, regex) in groups.items()
61+
])
62+
63+
cmd = """\
64+
awk < $< 'BEGIN {
65+
%s
66+
}
67+
{
68+
# Exclude .whl files from container images
69+
if ($$1 ~ ".whl") {
70+
next
71+
}
72+
# Move everything under the specified root
73+
sub(/^/, ".%s")
74+
# Match by regexes and write to the destination.
75+
%s
76+
# Every line that did not match the layer groups will go into the default layer.
77+
print $$0 >> "$(RULEDIR)/%s.default.manifest.spec"
78+
}'
79+
""" % (mtree_begin_blocks, root, ifs, name)
80+
81+
native.genrule(
82+
name = "_{}_manifests".format(name),
83+
srcs = [name + ".manifest"],
84+
outs = [
85+
"{}.{}.manifest.spec".format(name, group_name)
86+
for group_name in group_names
87+
],
88+
cmd = cmd,
89+
**kwargs
90+
)
91+
92+
93+
def py_image_layer(name, py_binary, root = None, layer_groups = {}, compress = "gzip", tar_args = ["--options", "gzip:!timestamp"], **kwargs):
94+
"""Produce a separate tar output for each layer of a python app
95+
96+
> Requires `awk` to be installed on the host machine/rbe runner.
97+
98+
For better performance, it is recommended to split the output of a py_binary into multiple layers.
99+
This can be done by grouping files into layers based on their path by using the `layer_groups` attribute.
100+
101+
The matching order for layer groups is as follows:
102+
1. `layer_groups` are checked first.
103+
2. If no match is found for `layer_groups`, the `default layer groups` are checked.
104+
3. Any remaining files are placed into the default layer.
105+
106+
The default layer groups are:
107+
```
108+
{
109+
"packages": "\\.runfiles/.*/site-packages",, # contains third-party deps
110+
"interpreter": "\\.runfiles/python.*-.*/", # contains the python interpreter
111+
}
112+
```
113+
114+
Args:
115+
name: base name for targets
116+
py_binary: a py_binary target
117+
root: Path to where the layers should be rooted. If not specified, the layers will be rooted at the workspace root.
118+
layer_groups: Additional layer groups to create. They are used to group files into layers based on their path. In the form of: ```{"<name>": "regex_to_match_against_file_paths"}```
119+
compress: Compression algorithm to use. Default is gzip. See: https://github.com/bazel-contrib/bazel-lib/blob/main/docs/tar.md#tar_rule
120+
tar_args: Additional arguments to pass to the tar rule. Default is `["--options", "gzip:!timestamp"]`. See: https://github.com/bazel-contrib/bazel-lib/blob/main/docs/tar.md#tar_rule
121+
**kwargs: attribute that apply to all targets expanded by the macro
122+
123+
Returns:
124+
A list of labels for each layer.
125+
"""
126+
if root != None and not root.startswith("/"):
127+
fail("root path must start with '/' but got '{root}', expected '/{root}'".format(root = root))
128+
129+
# Produce the manifest for a tar file of our py_binary, but don't tar it up yet, so we can split
130+
# into fine-grained layers for better pull, push and remote cache performance.
131+
mtree_spec(
132+
name = name + ".manifest",
133+
srcs = [py_binary],
134+
**kwargs
135+
)
136+
137+
groups = dict(**layer_groups)
138+
group_names = groups.keys() + ["default"]
139+
140+
_split_mtree_into_layer_groups(name, root, groups, group_names, **kwargs)
141+
142+
# Finally create layers using the tar rule
143+
result = []
144+
for group_name in group_names:
145+
tar_target = "_{}_{}".format(name, group_name)
146+
tar(
147+
name = tar_target,
148+
srcs = [py_binary],
149+
mtree = "{}.{}.manifest.spec".format(name, group_name),
150+
compress = compress,
151+
args = tar_args,
152+
**kwargs
153+
)
154+
result.append(tar_target)
155+
156+
return result

py/repositories.bzl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,12 @@ def rules_py_dependencies():
3030
url = "https://github.com/bazelbuild/bazel-skylib/archive/refs/tags/1.5.0.tar.gz",
3131
)
3232

33+
# py_image_layer requires 2.x for the `tar` rule.
3334
http_archive(
3435
name = "aspect_bazel_lib",
35-
sha256 = "6e6f8ac3c601d6df25810cd51e51d85831e3437e873b152c5c4ecd3b96964bc8",
36-
strip_prefix = "bazel-lib-1.42.3",
37-
url = "https://github.com/aspect-build/bazel-lib/archive/refs/tags/v1.42.3.tar.gz",
36+
sha256 = "f93d386d8d0b0149031175e81df42a488be4267c3ca2249ba5321c23c60bc1f0",
37+
strip_prefix = "bazel-lib-2.9.1",
38+
url = "https://github.com/bazel-contrib/bazel-lib/releases/download/v2.9.1/bazel-lib-v2.9.1.tar.gz",
3839
)
3940

4041
http_archive(

py/toolchains.bzl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Declare toolchains"""
22

3+
load("@aspect_bazel_lib//lib:repositories.bzl", "register_tar_toolchains")
34
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
45
load("//py/private/toolchain:autodetecting.bzl", _register_autodetecting_python_toolchain = "register_autodetecting_python_toolchain")
56
load("//py/private/toolchain:repo.bzl", "prerelease_toolchains_repo", "toolchains_repo")
67
load("//py/private/toolchain:tools.bzl", "TOOLCHAIN_PLATFORMS", "prebuilt_tool_repo")
78
load("//tools:version.bzl", "IS_PRERELEASE")
89

9-
1010
register_autodetecting_python_toolchain = _register_autodetecting_python_toolchain
1111

1212
DEFAULT_TOOLS_REPOSITORY = "rules_py_tools"
@@ -19,6 +19,9 @@ def rules_py_toolchains(name = DEFAULT_TOOLS_REPOSITORY, register = True, is_pre
1919
register: whether to call the register_toolchains, should be True for WORKSPACE and False for bzlmod.
2020
is_prerelease: True iff there are no pre-built tool binaries for this version of rules_py
2121
"""
22+
23+
register_tar_toolchains(register = register)
24+
2225
if is_prerelease:
2326
prerelease_toolchains_repo(name = name)
2427
if register:

0 commit comments

Comments
 (0)