Skip to content

Commit f20cf52

Browse files
authored
fix(py): stamp py_pex_binary interpreter constraints from the binary's own interpreter (#1344)
### 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 No API changes. py_pex_binary no longer resolves a Python toolchain itself. - Interpreter constraints now track the binary's own interpreter version. The {major}/{minor}/{patch} placeholders in python_interpreter_constraints are substituted from the version of the packaged binary (its python_version), not the py_pex_binary rule's toolchain. Previously a binary pinned to e.g. 3.13 in a 3.11 workspace produced a PEX advertising 3.11 — a version it wasn't built for. No action needed; PEXes built from version-mismatched binaries now advertise the correct interpreter. - The brittle repo-name exclusion list (bzlmod vs. WORKSPACE, + vs. ~ separators) is gone, so interpreter/venv exclusion is robust across Bazel versions and setups. ### Test plan - Covered by existing test cases - New test cases added
1 parent 27f6b1b commit f20cf52

11 files changed

Lines changed: 366 additions & 84 deletions

File tree

e2e/cases/pex-interpreter-exclusion/BUILD.bazel

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_pex_binary", "py_test")
1+
load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_pex_binary", "py_test", "py_unpacked_wheel")
22

33
py_binary(
44
name = "app",
@@ -27,6 +27,63 @@ py_pex_binary(
2727
python_interpreter_constraints = [],
2828
)
2929

30+
# Default constraints are stamped from the binary's version (3.13), not the pex
31+
# rule's toolchain (workspace default 3.11).
32+
py_pex_binary(
33+
name = "app_mismatch_constrained_pex",
34+
binary = ":app_mismatch",
35+
)
36+
37+
# A wheel reached only through a `filegroup(srcs = [...])` data wrapper. The
38+
# closure aspect walks data → filegroup but not the filegroup's `srcs`, so the
39+
# wheel must still be recognized as a distribution (--dependency) rather than
40+
# shipped as loose --source content.
41+
#
42+
# A hand-built wheel (not a uv-hub wheel): its py_unpacked_wheel carries no
43+
# dep_group constraint, so it survives the data edge's dep_group reset — a uv
44+
# wheel would be pruned there before the packaging logic is even exercised.
45+
genrule(
46+
name = "distfg_whl",
47+
srcs = [
48+
"wheel/distfg/__init__.py",
49+
"wheel/METADATA",
50+
],
51+
outs = ["distfg-1.0-py3-none-any.whl"],
52+
cmd = " ".join([
53+
"$(execpath @bazel_tools//tools/zip:zipper) c $@",
54+
"distfg/__init__.py=$(execpath wheel/distfg/__init__.py)",
55+
"distfg-1.0.dist-info/METADATA=$(execpath wheel/METADATA)",
56+
]),
57+
tools = ["@bazel_tools//tools/zip:zipper"],
58+
)
59+
60+
py_unpacked_wheel(
61+
name = "distfg",
62+
src = ":distfg-1.0-py3-none-any.whl",
63+
top_levels = [
64+
"distfg",
65+
"distfg-1.0.dist-info",
66+
],
67+
)
68+
69+
filegroup(
70+
name = "wrapped_wheel",
71+
srcs = [":distfg"],
72+
)
73+
74+
py_binary(
75+
name = "app_wrapped_wheel",
76+
srcs = ["app.py"],
77+
data = [":wrapped_wheel"],
78+
main = "app.py",
79+
)
80+
81+
py_pex_binary(
82+
name = "app_wrapped_wheel_pex",
83+
binary = ":app_wrapped_wheel",
84+
python_interpreter_constraints = [],
85+
)
86+
3087
py_test(
3188
name = "test_pex_excludes_interpreter",
3289
srcs = ["test_pex_excludes_interpreter.py"],
@@ -36,3 +93,17 @@ py_test(
3693
],
3794
main = "test_pex_excludes_interpreter.py",
3895
)
96+
97+
py_test(
98+
name = "test_pex_constraint_version",
99+
srcs = ["test_pex_constraint_version.py"],
100+
data = [":app_mismatch_constrained_pex"],
101+
main = "test_pex_constraint_version.py",
102+
)
103+
104+
py_test(
105+
name = "test_pex_wheel_under_filegroup",
106+
srcs = ["test_pex_wheel_under_filegroup.py"],
107+
data = [":app_wrapped_wheel_pex"],
108+
main = "test_pex_wheel_under_filegroup.py",
109+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""The default python_interpreter_constraints must be stamped from the binary's
2+
own interpreter version, not the pex rule's toolchain.
3+
4+
app_mismatch pins python_version 3.13 while this workspace's default is 3.11.
5+
With the default constraint (`CPython=={major}.{minor}.*`) the PEX must advertise
6+
3.13 — the interpreter it was actually built for — otherwise it would refuse to
7+
run on its own target interpreter.
8+
"""
9+
10+
import json
11+
import os
12+
import zipfile
13+
from pathlib import Path
14+
15+
pex = (
16+
Path(os.environ["RUNFILES_DIR"])
17+
/ "_main/pex-interpreter-exclusion/app_mismatch_constrained_pex.pex"
18+
)
19+
with zipfile.ZipFile(pex) as zf:
20+
info = json.loads(zf.read("PEX-INFO"))
21+
22+
assert info["interpreter_constraints"] == ["CPython==3.13.*"], info[
23+
"interpreter_constraints"
24+
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""A wheel reached only through a `filegroup(srcs = [...])` data wrapper must
2+
still be packaged as a PEX distribution, not as loose source.
3+
4+
app_wrapped_wheel depends on the distfg wheel via `data = [":wrapped_wheel"]`,
5+
a filegroup whose `srcs` hold the wheel. The closure aspect walks the `data`
6+
edge to the filegroup but not the filegroup's `srcs`; if the wheel's
7+
PyWheelsInfo is missed there, its install tree is shipped as loose `--source`
8+
content and never appears among the PEX distributions.
9+
"""
10+
11+
import json
12+
import os
13+
import zipfile
14+
from pathlib import Path
15+
16+
pex = (
17+
Path(os.environ["RUNFILES_DIR"])
18+
/ "_main/pex-interpreter-exclusion/app_wrapped_wheel_pex.pex"
19+
)
20+
with zipfile.ZipFile(pex) as zf:
21+
info = json.loads(zf.read("PEX-INFO"))
22+
names = zf.namelist()
23+
24+
distributions = info.get("distributions", {})
25+
assert any("distfg" in key for key in distributions), (
26+
"distfg wheel was not packaged as a PEX distribution",
27+
list(distributions),
28+
)
29+
30+
# The distribution lives under `.deps/`, not as loose source files.
31+
loose = [name for name in names if "distfg" in name and not name.startswith(".deps/")]
32+
assert not loose, loose[:10]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Metadata-Version: 2.1
2+
Name: distfg
3+
Version: 1.0
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VALUE = "distfg"

py/private/BUILD.bazel

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ bzl_library(
5050
name = "py_image_layer",
5151
srcs = ["py_image_layer.bzl"],
5252
deps = [
53+
":providers",
5354
":py_info",
5455
":py_info_interop",
56+
"//py/private/py_venv:types",
57+
"//py/private/toolchain:types",
5558
"@bazel_lib//lib:transitions",
5659
"@tar.bzl//tar:mtree",
5760
"@tar.bzl//tar:tar",
@@ -143,8 +146,9 @@ bzl_library(
143146
name = "py_pex_binary",
144147
srcs = ["py_pex_binary.bzl"],
145148
deps = [
149+
":providers",
146150
":py_info",
147-
":py_semantics",
151+
"//py/private/py_venv:types",
148152
"//py/private/toolchain:types",
149153
],
150154
)

py/private/py_image_layer.bzl

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ Sharing model:
2929
load("//py/private:providers.bzl", "PyWheelsInfo")
3030
load("//py/private:py_info.bzl", "PyInfo")
3131
load("//py/private:py_info_interop.bzl", "has_py_info")
32-
load("//py/private/toolchain:types.bzl", "PY_TOOLCHAIN")
32+
load("//py/private/py_venv:types.bzl", "PY_VENV_KINDS")
33+
load("//py/private/toolchain:types.bzl", "PY_TOOLCHAIN", "interpreter_files_and_version")
3334

3435
_TAR_TOOLCHAIN = "@tar.bzl//tar/toolchain:type"
3536

@@ -175,8 +176,6 @@ _LayerInfo = provider(
175176
},
176177
)
177178

178-
_PY_VENV_KINDS = ("py_venv", "_py_venv", "_py_venv_lib")
179-
180179
def _collect_from_deps(ctx, provider):
181180
"""Walk deps/data/actual/venv and return a list of provider values from each matching dep."""
182181
results = []
@@ -300,11 +299,9 @@ def _layer_aspect_impl(target, ctx):
300299
# (NOT ctx.toolchains) is how the binary reads it back, which correctly
301300
# follows the binary's target-platform transition.
302301
if platform_common.ToolchainInfo in target:
303-
py3 = getattr(target[platform_common.ToolchainInfo], "py3_runtime", None)
304-
if py3 == None or py3.files == None:
302+
interp_depset, _ = interpreter_files_and_version(target)
303+
if interp_depset == None:
305304
return []
306-
direct = [py3.interpreter] if getattr(py3, "interpreter", None) != None else []
307-
interp_depset = depset(direct = direct, transitive = [py3.files])
308305
plan = ctx.attr._layer_tier[PyLayerTierInfo]
309306
interp_group = plan.interpreter_group
310307
interp_layer = None
@@ -389,7 +386,7 @@ def _layer_aspect_impl(target, ctx):
389386

390387
# Skip PyInfo deps (including wheel-leaf targets, which also emit PyInfo) —
391388
# they self-capture via the aspect.
392-
if kind not in _PY_VENV_KINDS and has_py_info(target) and not is_binary:
389+
if kind not in PY_VENV_KINDS and has_py_info(target) and not is_binary:
393390
own_parts = [target[DefaultInfo].files]
394391
for attr_name in ("data", "deps"):
395392
attr_val = getattr(ctx.rule.attr, attr_name, None)
@@ -414,7 +411,7 @@ def _layer_aspect_impl(target, ctx):
414411
else:
415412
own_source.append(own_depset)
416413

417-
if kind in _PY_VENV_KINDS:
414+
if kind in PY_VENV_KINDS:
418415
if PY_TOOLCHAIN in ctx.rule.toolchains:
419416
py_tc = ctx.rule.toolchains[PY_TOOLCHAIN]
420417
if _LayerInfo in py_tc:

0 commit comments

Comments
 (0)