diff --git a/e2e/cases/pex-interpreter-exclusion/BUILD.bazel b/e2e/cases/pex-interpreter-exclusion/BUILD.bazel index 406fadf15..4087b7398 100644 --- a/e2e/cases/pex-interpreter-exclusion/BUILD.bazel +++ b/e2e/cases/pex-interpreter-exclusion/BUILD.bazel @@ -1,4 +1,4 @@ -load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_pex_binary", "py_test") +load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_pex_binary", "py_test", "py_unpacked_wheel") py_binary( name = "app", @@ -27,6 +27,63 @@ py_pex_binary( python_interpreter_constraints = [], ) +# Default constraints are stamped from the binary's version (3.13), not the pex +# rule's toolchain (workspace default 3.11). +py_pex_binary( + name = "app_mismatch_constrained_pex", + binary = ":app_mismatch", +) + +# A wheel reached only through a `filegroup(srcs = [...])` data wrapper. The +# closure aspect walks data → filegroup but not the filegroup's `srcs`, so the +# wheel must still be recognized as a distribution (--dependency) rather than +# shipped as loose --source content. +# +# A hand-built wheel (not a uv-hub wheel): its py_unpacked_wheel carries no +# dep_group constraint, so it survives the data edge's dep_group reset — a uv +# wheel would be pruned there before the packaging logic is even exercised. +genrule( + name = "distfg_whl", + srcs = [ + "wheel/distfg/__init__.py", + "wheel/METADATA", + ], + outs = ["distfg-1.0-py3-none-any.whl"], + cmd = " ".join([ + "$(execpath @bazel_tools//tools/zip:zipper) c $@", + "distfg/__init__.py=$(execpath wheel/distfg/__init__.py)", + "distfg-1.0.dist-info/METADATA=$(execpath wheel/METADATA)", + ]), + tools = ["@bazel_tools//tools/zip:zipper"], +) + +py_unpacked_wheel( + name = "distfg", + src = ":distfg-1.0-py3-none-any.whl", + top_levels = [ + "distfg", + "distfg-1.0.dist-info", + ], +) + +filegroup( + name = "wrapped_wheel", + srcs = [":distfg"], +) + +py_binary( + name = "app_wrapped_wheel", + srcs = ["app.py"], + data = [":wrapped_wheel"], + main = "app.py", +) + +py_pex_binary( + name = "app_wrapped_wheel_pex", + binary = ":app_wrapped_wheel", + python_interpreter_constraints = [], +) + py_test( name = "test_pex_excludes_interpreter", srcs = ["test_pex_excludes_interpreter.py"], @@ -36,3 +93,17 @@ py_test( ], main = "test_pex_excludes_interpreter.py", ) + +py_test( + name = "test_pex_constraint_version", + srcs = ["test_pex_constraint_version.py"], + data = [":app_mismatch_constrained_pex"], + main = "test_pex_constraint_version.py", +) + +py_test( + name = "test_pex_wheel_under_filegroup", + srcs = ["test_pex_wheel_under_filegroup.py"], + data = [":app_wrapped_wheel_pex"], + main = "test_pex_wheel_under_filegroup.py", +) diff --git a/e2e/cases/pex-interpreter-exclusion/test_pex_constraint_version.py b/e2e/cases/pex-interpreter-exclusion/test_pex_constraint_version.py new file mode 100644 index 000000000..bcac4e31d --- /dev/null +++ b/e2e/cases/pex-interpreter-exclusion/test_pex_constraint_version.py @@ -0,0 +1,24 @@ +"""The default python_interpreter_constraints must be stamped from the binary's +own interpreter version, not the pex rule's toolchain. + +app_mismatch pins python_version 3.13 while this workspace's default is 3.11. +With the default constraint (`CPython=={major}.{minor}.*`) the PEX must advertise +3.13 — the interpreter it was actually built for — otherwise it would refuse to +run on its own target interpreter. +""" + +import json +import os +import zipfile +from pathlib import Path + +pex = ( + Path(os.environ["RUNFILES_DIR"]) + / "_main/pex-interpreter-exclusion/app_mismatch_constrained_pex.pex" +) +with zipfile.ZipFile(pex) as zf: + info = json.loads(zf.read("PEX-INFO")) + +assert info["interpreter_constraints"] == ["CPython==3.13.*"], info[ + "interpreter_constraints" +] diff --git a/e2e/cases/pex-interpreter-exclusion/test_pex_wheel_under_filegroup.py b/e2e/cases/pex-interpreter-exclusion/test_pex_wheel_under_filegroup.py new file mode 100644 index 000000000..793098da1 --- /dev/null +++ b/e2e/cases/pex-interpreter-exclusion/test_pex_wheel_under_filegroup.py @@ -0,0 +1,32 @@ +"""A wheel reached only through a `filegroup(srcs = [...])` data wrapper must +still be packaged as a PEX distribution, not as loose source. + +app_wrapped_wheel depends on the distfg wheel via `data = [":wrapped_wheel"]`, +a filegroup whose `srcs` hold the wheel. The closure aspect walks the `data` +edge to the filegroup but not the filegroup's `srcs`; if the wheel's +PyWheelsInfo is missed there, its install tree is shipped as loose `--source` +content and never appears among the PEX distributions. +""" + +import json +import os +import zipfile +from pathlib import Path + +pex = ( + Path(os.environ["RUNFILES_DIR"]) + / "_main/pex-interpreter-exclusion/app_wrapped_wheel_pex.pex" +) +with zipfile.ZipFile(pex) as zf: + info = json.loads(zf.read("PEX-INFO")) + names = zf.namelist() + +distributions = info.get("distributions", {}) +assert any("distfg" in key for key in distributions), ( + "distfg wheel was not packaged as a PEX distribution", + list(distributions), +) + +# The distribution lives under `.deps/`, not as loose source files. +loose = [name for name in names if "distfg" in name and not name.startswith(".deps/")] +assert not loose, loose[:10] diff --git a/e2e/cases/pex-interpreter-exclusion/wheel/METADATA b/e2e/cases/pex-interpreter-exclusion/wheel/METADATA new file mode 100644 index 000000000..3e45a5e8e --- /dev/null +++ b/e2e/cases/pex-interpreter-exclusion/wheel/METADATA @@ -0,0 +1,3 @@ +Metadata-Version: 2.1 +Name: distfg +Version: 1.0 diff --git a/e2e/cases/pex-interpreter-exclusion/wheel/distfg/__init__.py b/e2e/cases/pex-interpreter-exclusion/wheel/distfg/__init__.py new file mode 100644 index 000000000..e12aee363 --- /dev/null +++ b/e2e/cases/pex-interpreter-exclusion/wheel/distfg/__init__.py @@ -0,0 +1 @@ +VALUE = "distfg" diff --git a/py/private/BUILD.bazel b/py/private/BUILD.bazel index 1fd5eb05e..e4d179af2 100644 --- a/py/private/BUILD.bazel +++ b/py/private/BUILD.bazel @@ -49,8 +49,11 @@ bzl_library( name = "py_image_layer", srcs = ["py_image_layer.bzl"], deps = [ + ":providers", ":py_info", ":py_info_interop", + "//py/private/py_venv:types", + "//py/private/toolchain:types", "@bazel_lib//lib:transitions", "@tar.bzl//tar:mtree", "@tar.bzl//tar:tar", @@ -142,8 +145,9 @@ bzl_library( name = "py_pex_binary", srcs = ["py_pex_binary.bzl"], deps = [ + ":providers", ":py_info", - ":py_semantics", + "//py/private/py_venv:types", "//py/private/toolchain:types", ], ) diff --git a/py/private/py_image_layer.bzl b/py/private/py_image_layer.bzl index 0ed063c34..86be2958a 100644 --- a/py/private/py_image_layer.bzl +++ b/py/private/py_image_layer.bzl @@ -35,7 +35,8 @@ Sharing model: load("//py/private:providers.bzl", "PyWheelsInfo") load("//py/private:py_info.bzl", "PyInfo") load("//py/private:py_info_interop.bzl", "has_py_info") -load("//py/private/toolchain:types.bzl", "PY_TOOLCHAIN") +load("//py/private/py_venv:types.bzl", "PY_VENV_KINDS") +load("//py/private/toolchain:types.bzl", "PY_TOOLCHAIN", "interpreter_files_and_version") _TAR_TOOLCHAIN = "@tar.bzl//tar/toolchain:type" @@ -182,8 +183,6 @@ _LayerInfo = provider( }, ) -_PY_VENV_KINDS = ("py_venv", "_py_venv", "_py_venv_lib") - def _collect_from_deps(ctx, provider): """Walk deps/data/actual/venv and return a list of provider values from each matching dep.""" results = [] @@ -307,11 +306,9 @@ def _layer_aspect_impl(target, ctx): # (NOT ctx.toolchains) is how the binary reads it back, which correctly # follows the binary's target-platform transition. if platform_common.ToolchainInfo in target: - py3 = getattr(target[platform_common.ToolchainInfo], "py3_runtime", None) - if py3 == None or py3.files == None: + interp_depset, _ = interpreter_files_and_version(target) + if interp_depset == None: return [] - direct = [py3.interpreter] if getattr(py3, "interpreter", None) != None else [] - interp_depset = depset(direct = direct, transitive = [py3.files]) plan = ctx.attr._layer_tier[PyLayerTierInfo] interp_group = plan.interpreter_group interp_layer = None @@ -399,7 +396,7 @@ def _layer_aspect_impl(target, ctx): # Skip PyInfo deps (including wheel-leaf targets, which also emit PyInfo) — # they self-capture via the aspect. - if kind not in _PY_VENV_KINDS and has_py_info(target) and not is_binary: + if kind not in PY_VENV_KINDS and has_py_info(target) and not is_binary: own_parts = [target[DefaultInfo].files] for attr_name in ("data", "deps"): attr_val = getattr(ctx.rule.attr, attr_name, None) @@ -424,7 +421,7 @@ def _layer_aspect_impl(target, ctx): else: own_source.append(own_depset) - if kind in _PY_VENV_KINDS: + if kind in PY_VENV_KINDS: if PY_TOOLCHAIN in ctx.rule.toolchains: py_tc = ctx.rule.toolchains[PY_TOOLCHAIN] if _LayerInfo in py_tc: diff --git a/py/private/py_pex_binary.bzl b/py/private/py_pex_binary.bzl index 6e8606648..b566501be 100644 --- a/py/private/py_pex_binary.bzl +++ b/py/private/py_pex_binary.bzl @@ -19,9 +19,10 @@ py_pex_binary( """ load("@bazel_lib//lib:paths.bzl", "to_rlocation_path") +load("//py/private:providers.bzl", "PyWheelsInfo") load("//py/private:py_info.bzl", "PyInfo") -load("//py/private:py_semantics.bzl", _py_semantics = "semantics") -load("//py/private/toolchain:types.bzl", "PY_TOOLCHAIN") +load("//py/private/py_venv:types.bzl", "PY_VENV_KINDS", "VirtualenvInfo", "venv_root") +load("//py/private/toolchain:types.bzl", "PY_TOOLCHAIN", "interpreter_files_and_version") def _runfiles_path(file, workspace): if file.short_path.startswith("../"): @@ -29,103 +30,216 @@ def _runfiles_path(file, workspace): else: return workspace + "/" + file.short_path -exclude_paths = [ - # following two lines will match paths we want to exclude in non-bzlmod setup - "toolchain", - "aspect_rules_py/py/tools/", - # these will match in bzlmod setup - "rules_python~~python~", - "aspect_rules_py~/py/tools/", - # these will match in bzlmod setup with --incompatible_use_plus_in_repo_names flag flipped. - "rules_python++python+", - "aspect_rules_py+/py/tools/", - # The hermetic interpreter repo includes bundled pip/setuptools site-packages. - "aspect_rules_py++python_interpreters+", - "aspect_rules_py~~python_interpreters~", - # Skip the sibling py_venv's tree (`..venv/`) — its `.pth` - # and pyvenv.cfg are launcher plumbing; pex discovers wheels via - # `--dep`, not by walking the venv. - ".venv/", -] - -# determines if the given file is a `dep` or a `source` -# this required to allow PEX to put files into different places. -# -# --dep: into `/.deps/` -# --source: into `//` -def _map_srcs(f, workspace): - dest_path = _runfiles_path(f, workspace) - - # We exclude files from hermetic python toolchain. - for exclude in exclude_paths: - if dest_path.find(exclude) != -1: - return [] - - site_packages_i = f.path.find("site-packages") - - # If the path contains 'site-packages', treat it as a third party dep. - # Top-level dist-info files contribute nothing: pex reads the package - # metadata itself via Distribution.load() on the `--dep` directory. - if site_packages_i != -1: - if f.path.find("dist-info", site_packages_i) != -1 and f.path.count("/", site_packages_i) == 2: - return [] - - return ["--dep={}".format(f.path[:site_packages_i + len("site-packages")])] - - # If the path does not have a `site-packages` in it, then put it into the standard runfiles tree. - return ["--source={}={}".format(f.path, dest_path)] +def _repo_root_prefix(short_path): + # Runfiles-relative repo root (`..//`) of an external file, or None for + # a main-repo file. Excluding the whole hermetic interpreter repo this way + # also drops its bundled pip/setuptools site-packages. + if not short_path.startswith("../"): + return None + parts = short_path.split("/", 2) + if len(parts) < 2: + return None + return parts[0] + "/" + parts[1] + "/" -def _py_python_pex_impl(ctx): - py_toolchain = _py_semantics.resolve_toolchain(ctx) +# Carries what no public provider exposes on the binary target: wheels reached +# through `data`, sibling venv roots, and the interpreter resolved under the +# binary's `python_version`. A private provider because a leaf that already emits +# PyWheelsInfo may not re-`provides` it ("provided twice"). +_PexClosureInfo = provider( + doc = "Private: wheels, venv roots, interpreter repo roots, and interpreter version aggregated across a binary's closure by _closure_aspect.", + fields = { + "wheels": "depset of wheel record structs (see PyWheelsInfo.wheels).", + "venv_roots": "depset[str] — runfiles-relative roots of every sibling py_venv in the closure.", + "interpreter_roots": "depset[str] — runfiles-relative repo roots (`..//`) of every py_venv toolchain's hermetic interpreter in the closure.", + "version": "struct(major, minor, micro) | None — the binary's own interpreter version; propagated only along the `venv`/`actual` edge, so it is the version the PEX is built for.", + }, +) + +def _label_targets(attr_val): + # The aspect reaches arbitrary rules through runtime data, where an attr + # named deps/data/srcs is not guaranteed to be a label_list — it may be a + # scalar label, a dict, or an unrelated value. Keep only Targets; ignore + # every other shape. + if type(attr_val) == "list": + return [v for v in attr_val if type(v) == "Target"] + if type(attr_val) == "Target": + return [attr_val] + return [] + +def _closure_aspect_impl(target, ctx): + # Toolchain node, reached via toolchains_aspects: surface the interpreter's + # repo roots and version for the venv node below to read under its config. + if platform_common.ToolchainInfo in target: + files, version = interpreter_files_and_version(target) + roots = {} + if files != None: + for f in files.to_list(): + r = _repo_root_prefix(f.short_path) + if r != None: + roots[r] = True + return [_PexClosureInfo( + wheels = depset(), + venv_roots = depset(), + interpreter_roots = depset(roots.keys()), + version = version, + )] + + wheels = [] + roots = [] + interp = [] + + # `version` is the binary's own interpreter version: it flows only from the + # `venv`/`actual` hop or a venv node's toolchain, never from deps/data (which + # carry other binaries' interpreters). + version = None + for attr_name in ["deps", "data"]: + for dep in _label_targets(getattr(ctx.rule.attr, attr_name, None)): + if _PexClosureInfo in dep: + wheels.append(dep[_PexClosureInfo].wheels) + roots.append(dep[_PexClosureInfo].venv_roots) + interp.append(dep[_PexClosureInfo].interpreter_roots) + + # `srcs` is not an aspect edge, so a wheel wrapped there (e.g. the common + # `filegroup(srcs = [wheel])` data wrapper) is never visited and carries no + # _PexClosureInfo. Read its own PyWheelsInfo directly; a py_library there + # already aggregates its subtree's wheels, so one hop suffices. + for dep in _label_targets(getattr(ctx.rule.attr, "srcs", None)): + if PyWheelsInfo in dep: + wheels.append(dep[PyWheelsInfo].wheels) + + # py_venv_exec (what the py_binary macro expands to) routes srcs/deps onto a + # sibling py_venv reached via `venv`; that venv also carries VirtualenvInfo. + venv = getattr(ctx.rule.attr, "venv", None) + if venv != None: + if _PexClosureInfo in venv: + wheels.append(venv[_PexClosureInfo].wheels) + roots.append(venv[_PexClosureInfo].venv_roots) + interp.append(venv[_PexClosureInfo].interpreter_roots) + version = venv[_PexClosureInfo].version + if VirtualenvInfo in venv: + roots.append(depset([venv_root(venv[VirtualenvInfo].bin_python)])) + + # `actual` is a scalar Label on aliases; some rules expose it as a + # label_list, which no venv alias uses, so we only hop the scalar form. + actual = getattr(ctx.rule.attr, "actual", None) + if actual != None and type(actual) != "list" and _PexClosureInfo in actual: + wheels.append(actual[_PexClosureInfo].wheels) + roots.append(actual[_PexClosureInfo].venv_roots) + interp.append(actual[_PexClosureInfo].interpreter_roots) + if version == None: + version = actual[_PexClosureInfo].version + + # Fires on every py_library, not just wheel leaves: its PyWheelsInfo already + # aggregates deps + `resolutions`, which is the only path by which + # resolution-only wheels (the aspect doesn't walk that edge) reach here. + if PyWheelsInfo in target: + wheels.append(target[PyWheelsInfo].wheels) + + if ctx.rule.kind in PY_VENV_KINDS and PY_TOOLCHAIN in ctx.rule.toolchains: + py_tc = ctx.rule.toolchains[PY_TOOLCHAIN] + if _PexClosureInfo in py_tc: + interp.append(py_tc[_PexClosureInfo].interpreter_roots) + version = py_tc[_PexClosureInfo].version + + return [_PexClosureInfo( + wheels = depset(transitive = wheels), + venv_roots = depset(transitive = roots), + interpreter_roots = depset(transitive = interp), + version = version, + )] + +_closure_aspect = aspect( + implementation = _closure_aspect_impl, + attr_aspects = ["deps", "data", "actual", "venv"], + # Lets the aspect read `ctx.rule.toolchains[PY_TOOLCHAIN]` at py_venv nodes. + toolchains_aspects = [PY_TOOLCHAIN], + provides = [_PexClosureInfo], +) +def _dep_arg(wheel): + # pex `--dependency` wants the exec-root site-packages dir. Graft the + # trailing `lib//site-packages` (copied verbatim from the producer's + # site_packages_rfpath) onto the install tree; one dist per tree. + suffix = "/".join(wheel.site_packages_rfpath.rsplit("/", 3)[1:]) + return "--dependency={}/{}".format(wheel.install_tree.path, suffix) + +def _py_python_pex_impl(ctx): binary = ctx.attr.binary binary_default = binary[DefaultInfo] - runfiles = binary_default.data_runfiles - # py_venv_exec emits depset([launcher, main]) — the non-executable - # file is the Python entrypoint the launcher exec's. + # py_venv_exec emits depset([launcher, main]) — the non-executable file is + # the Python entrypoint the launcher exec's. entrypoint_files = [f for f in binary_default.files.to_list() if f != binary_default.files_to_run.executable] if len(entrypoint_files) != 1: fail("py_pex_binary {}: expected exactly one entrypoint file in `binary` DefaultInfo.files, got {}".format(ctx.label, entrypoint_files)) entrypoint = entrypoint_files[0] + closure = binary[_PexClosureInfo] + if closure.version == None: + fail("py_pex_binary {}: could not resolve the binary's interpreter version from its venv toolchain.".format(ctx.label)) + + wheels = closure.wheels + wheels_list = wheels.to_list() + runfiles = binary_default.data_runfiles + + # --source packages everything in runfiles except what is packaged another + # way: wheel trees go out as --dependency; the interpreter repos and venv + # plumbing aren't packaged. `add_all` expands the wheel tree artifacts before + # `map_each`, so we match the expanded children against the tree's exec-root + # path prefix (the unexpanded tree artifact never would). + wheel_tree_prefixes = [w.install_tree.path + "/" for w in wheels_list] + interpreter_prefixes = closure.interpreter_roots.to_list() + venv_prefixes = [r + "/" for r in closure.venv_roots.to_list()] + output = ctx.actions.declare_file(ctx.attr.name + ".pex") args = ctx.actions.args() - args.use_param_file(param_file_arg = "@%s") args.set_param_file_format("multiline") - # Copy workspace name here to prevent ctx - # being transferred to the execution phase. - workspace_name = str(ctx.workspace_name) + # A local (not ctx.workspace_name inline) keeps ctx out of the map_each + # closures below, which allow_closure ships to the execution phase. + workspace_name = ctx.workspace_name args.add_all( ctx.attr.inject_env.items(), map_each = lambda e: "--inject-env={}={}".format(e[0], e[1]), - # this is needed to allow passing a lambda to map_each allow_closure = True, ) - args.add_all( - binary[PyInfo].imports, - format_each = "--sys-path=%s", - ) + args.add_all(binary[PyInfo].imports, format_each = "--sys-path=%s") + + args.add_all(wheels, map_each = _dep_arg) + + def _map_source(f): + sp = f.short_path + for prefix in interpreter_prefixes: + if sp.startswith(prefix): + return [] + for prefix in venv_prefixes: + if sp.startswith(prefix): + return [] + p = f.path + for prefix in wheel_tree_prefixes: + if p.startswith(prefix): + return [] + return ["--source={}={}".format(p, _runfiles_path(f, workspace_name))] args.add_all( runfiles.files, - map_each = lambda f: _map_srcs(f, workspace_name), - uniquify = True, - # this is needed to allow passing a lambda (with workspace_name) to map_each + map_each = _map_source, allow_closure = True, ) + args.add(to_rlocation_path(ctx, entrypoint), format = "--entrypoint=%s") args.add(ctx.attr.python_shebang, format = "--python-shebang=%s") if ctx.attr.inherit_path != "": args.add(ctx.attr.inherit_path, format = "--inherit-path=%s") - py_version = py_toolchain.interpreter_version_info + # Stamp constraints from the binary's own interpreter version, the one the + # PEX is built for. + py_version = closure.version args.add_all( [ constraint.format(major = py_version.major, minor = py_version.minor, patch = py_version.micro) @@ -150,7 +264,13 @@ def _py_python_pex_impl(ctx): ] _attrs = dict({ - "binary": attr.label(executable = True, cfg = "target", mandatory = True, doc = "A py_binary target"), + "binary": attr.label( + executable = True, + cfg = "target", + mandatory = True, + doc = "The py_binary target to package.", + aspects = [_closure_aspect], + ), "inject_env": attr.string_dict( doc = "Environment variables to set when running the pex binary.", default = {}, @@ -168,9 +288,9 @@ dependencies; and use `prefer` to inherit `sys.path` before packaged dependencie "python_interpreter_constraints": attr.string_list( default = ["CPython=={major}.{minor}.*"], doc = """\ -Python interpreter versions this PEX binary is compatible with. A list of semver strings. -The placeholder strings `{major}`, `{minor}`, `{patch}` can be used for gathering version -information from the hermetic python toolchain. +Python interpreter versions this PEX binary is compatible with. A list of semver strings. +The placeholder strings `{major}`, `{minor}`, `{patch}` are substituted with the version of +the `binary`'s own interpreter, the one the PEX is built for. """, ), "_pex": attr.label(executable = True, cfg = "exec", default = "//py/tools/pex"), @@ -180,8 +300,5 @@ py_pex_binary = rule( doc = "Build a pex executable from a py_binary", implementation = _py_python_pex_impl, attrs = _attrs, - toolchains = [ - PY_TOOLCHAIN, - ], executable = True, ) diff --git a/py/private/py_venv/types.bzl b/py/private/py_venv/types.bzl index 569281870..a5d63f5e7 100644 --- a/py/private/py_venv/types.bzl +++ b/py/private/py_venv/types.bzl @@ -1,5 +1,10 @@ """quasi-public types.""" +# py_venv rule kinds that carry PY_TOOLCHAIN. Reading +# `ctx.rule.toolchains[PY_TOOLCHAIN]` at these nodes yields the interpreter +# resolved under the consumer's Python version transition. +PY_VENV_KINDS = ("py_venv", "_py_venv", "_py_venv_lib") + def venv_root(bin_python): """The venv root's runfiles-relative rootpath, derived from the `bin/python` symlink's short_path (drop the trailing `bin/python`). diff --git a/py/private/toolchain/types.bzl b/py/private/toolchain/types.bzl index 1431dcf29..3cfdb5ddc 100644 --- a/py/private/toolchain/types.bzl +++ b/py/private/toolchain/types.bzl @@ -3,3 +3,26 @@ PY_TOOLCHAIN = "@bazel_tools//tools/python:toolchain_type" EXEC_TOOLS_TOOLCHAIN = "@aspect_rules_py//py/private/toolchain:exec_tools_toolchain_type" NATIVE_BUILD_TOOLCHAIN = "@aspect_rules_py//py/private/toolchain:native_build_toolchain_type" + +def interpreter_files_and_version(toolchain): + """Interpreter files and version from a resolved PY_TOOLCHAIN target. + + Reads the target's `ToolchainInfo.py3_runtime`, so the result tracks the + consumer's Python version transition. + + Args: + toolchain: a resolved PY_TOOLCHAIN target carrying `ToolchainInfo`. + + Returns: + `(depset[File] | None, struct(major, minor, micro) | None)`. `None` + files means the toolchain has no usable py3 runtime; the caller should + skip the node. + """ + py3 = getattr(toolchain[platform_common.ToolchainInfo], "py3_runtime", None) + if py3 == None or py3.files == None: + return None, None + direct = [py3.interpreter] if getattr(py3, "interpreter", None) != None else [] + files = depset(direct = direct, transitive = [py3.files]) + vi = getattr(py3, "interpreter_version_info", None) + version = struct(major = vi.major, minor = vi.minor, micro = vi.micro) if vi != None else None + return files, version diff --git a/py/tests/py-pex-binary/pex_info_test.py b/py/tests/py-pex-binary/pex_info_test.py index a85530c84..191988609 100644 --- a/py/tests/py-pex-binary/pex_info_test.py +++ b/py/tests/py-pex-binary/pex_info_test.py @@ -82,7 +82,12 @@ def pex_names(name: str) -> list[str]: assert not interpreter, interpreter[:10] # The wheels arrive as `--dependency` under `.deps/`, not as loose sources. -assert any("cowsay" in n for n in names), "cowsay missing from pex" +assert any(n.startswith(".deps/") and "cowsay" in n for n in names), "cowsay missing from .deps/" + +# A wheel's install-tree must be packaged only once (as a `--dependency` under +# `.deps/`); a duplicate `--source` would reappear under its `whl_install` path. +install_tree_dupes = [n for n in names if "whl_install" in n] +assert not install_tree_dupes, install_tree_dupes[:10] # Plain data files travel with the sources (transitive_sources is .py-only). assert any(n.endswith("/data.txt") for n in names), "data.txt missing from pex"