Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ Unreleased changes template.
* (toolchains) Do not try to run `chmod` when downloading non-windows hermetic toolchain
repositories on Windows. Fixes
[#2660](https://github.com/bazel-contrib/rules_python/issues/2660).
* (toolchains) The toolchain matching is has been fixed when writing
transitions transitioning on the `python_version` flag.
Fixes [#2685](https://github.com/bazel-contrib/rules_python/issues/2685).

{#v0-0-0-added}
### Added
Expand Down
17 changes: 16 additions & 1 deletion python/private/python.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,25 @@ def parse_modules(*, module_ctx, _fail = fail):
if len(toolchains) > _MAX_NUM_TOOLCHAINS:
fail("more than {} python versions are not supported".format(_MAX_NUM_TOOLCHAINS))

# sort the toolchains so that the toolchain versions that are in the
# `minor_mapping` are coming first. This ensures that `python_version =
# "3.X"` transitions work as expected.
minor_version_toolchains = []
other_toolchains = []
minor_mapping = list(config.minor_mapping.values())
for t in toolchains:
# FIXME @aignas 2025-04-04: How can we unit test that this ordering is
# consistent with what would actually work?
if config.minor_mapping.get(t.python_version, t.python_version) in minor_mapping:
minor_version_toolchains.append(t)
else:
other_toolchains.append(t)
toolchains = minor_version_toolchains + other_toolchains

return struct(
config = config,
debug_info = debug_info,
default_python_version = toolchains[-1].python_version,
default_python_version = default_toolchain.python_version,
toolchains = [
struct(
python_version = t.python_version,
Expand Down
2 changes: 0 additions & 2 deletions python/uv/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,8 @@ bzl_library(
":toolchain_types_bzl",
"//python:py_binary_bzl",
"//python/private:bzlmod_enabled_bzl",
"//python/private:full_version_bzl",
"//python/private:toolchain_types_bzl",
"@bazel_skylib//lib:shell",
"@pythons_hub//:versions_bzl",
],
)

Expand Down
31 changes: 9 additions & 22 deletions python/uv/private/lock.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@
"""

load("@bazel_skylib//lib:shell.bzl", "shell")
load("@pythons_hub//:versions.bzl", "DEFAULT_PYTHON_VERSION", "MINOR_MAPPING")
load("//python:py_binary.bzl", "py_binary")
load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility
load("//python/private:full_version.bzl", "full_version")
load("//python/private:toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE") # buildifier: disable=bzl-visibility
load(":toolchain_types.bzl", "UV_TOOLCHAIN_TYPE")

Expand Down Expand Up @@ -75,15 +73,15 @@ def _args(ctx):

def _lock_impl(ctx):
srcs = ctx.files.srcs
python_version = full_version(
version = ctx.attr.python_version or DEFAULT_PYTHON_VERSION,
minor_mapping = MINOR_MAPPING,
)
output = ctx.actions.declare_file("{}.{}.out".format(
ctx.label.name,
python_version.replace(".", "_"),
))
fname = "{}.out".format(ctx.label.name)
python_version = ctx.attr.python_version
if python_version:
fname = "{}.{}.out".format(
ctx.label.name,
python_version.replace(".", "_"),
)

output = ctx.actions.declare_file(fname)
toolchain_info = ctx.toolchains[UV_TOOLCHAIN_TYPE]
uv = toolchain_info.uv_toolchain_info.uv[DefaultInfo].files_to_run.executable

Expand Down Expand Up @@ -166,15 +164,7 @@ def _transition_impl(input_settings, attr):
_PYTHON_VERSION_FLAG: input_settings[_PYTHON_VERSION_FLAG],
}
if attr.python_version:
# FIXME @aignas 2025-03-20: using `full_version` is a workaround for a bug in
# how we order toolchains in bazel. If I set the `python_version` flag
# to `3.12`, I would expect the latest version to be selected, i.e. the
# one that is in MINOR_MAPPING, but it seems that 3.12.0 is selected,
# because of how the targets are ordered.
settings[_PYTHON_VERSION_FLAG] = full_version(
version = attr.python_version,
minor_mapping = MINOR_MAPPING,
)
settings[_PYTHON_VERSION_FLAG] = attr.python_version
return settings

_python_version_transition = transition(
Expand Down Expand Up @@ -436,9 +426,6 @@ def lock(
if not BZLMOD_ENABLED:
kwargs["target_compatible_with"] = ["@platforms//:incompatible"]

# FIXME @aignas 2025-03-17: should we have one more target that transitions
# the python_version to ensure that if somebody calls `bazel build
# :requirements` that it is locked with the right `python_version`?
_lock(
name = name,
args = args,
Expand Down
3 changes: 2 additions & 1 deletion tests/config_settings/transition/multi_version_tests.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.
"""Tests for py_test."""

load("@pythons_hub//:versions.bzl", "DEFAULT_PYTHON_VERSION")
load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
load("@rules_testing//lib:test_suite.bzl", "test_suite")
load("@rules_testing//lib:util.bzl", "TestingAspectInfo", rt_util = "util")
Expand All @@ -29,7 +30,7 @@ load("//tests/support:support.bzl", "CC_TOOLCHAIN")
# If the toolchain is not resolved then you will have a weird message telling
# you that your transition target does not have a PyRuntime provider, which is
# caused by there not being a toolchain detected for the target.
_PYTHON_VERSION = "3.11"
_PYTHON_VERSION = DEFAULT_PYTHON_VERSION

_tests = []

Expand Down
52 changes: 52 additions & 0 deletions tests/python/python_tests.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,58 @@ def _test_default_non_rules_python_ignore_root_user_error_non_root_module(env):

_tests.append(_test_default_non_rules_python_ignore_root_user_error_non_root_module)

def _test_toolchain_ordering(env):
py = parse_modules(
module_ctx = _mock_mctx(
_mod(
name = "my_module",
toolchain = [
_toolchain("3.10"),
_toolchain("3.10.15"),
_toolchain("3.10.16"),
_toolchain("3.10.11"),
_toolchain("3.11.1"),
_toolchain("3.11.10"),
_toolchain("3.11.11", is_default = True),
],
),
_mod(name = "rules_python", toolchain = [_toolchain("3.11")]),
),
)
got_versions = [
t.python_version
for t in py.toolchains
]

env.expect.that_str(py.default_python_version).equals("3.11.11")
env.expect.that_dict(py.config.minor_mapping).contains_exactly({
"3.10": "3.10.16",
"3.11": "3.11.11",
"3.12": "3.12.9",
"3.13": "3.13.2",
"3.8": "3.8.20",
"3.9": "3.9.21",
})
env.expect.that_collection(got_versions).contains_exactly([
# First the full-version toolchains that are in minor_mapping
# so that they get matched first if only the `python_version` is in MINOR_MAPPING
#
# The default version is always set in the `python_version` flag, so know, that
# the default match will be somewhere in the first bunch.
"3.10",
"3.10.16",
"3.11",
"3.11.11",
# Next, the rest, where we will match things based on the `python_version` being
# the same
"3.10.15",
"3.10.11",
"3.11.1",
"3.11.10",
]).in_order()

_tests.append(_test_toolchain_ordering)

def _test_default_from_defaults(env):
py = parse_modules(
module_ctx = _mock_mctx(
Expand Down
46 changes: 42 additions & 4 deletions tests/toolchains/defs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

""

load("@pythons_hub//:versions.bzl", "DEFAULT_PYTHON_VERSION", "MINOR_MAPPING")
load("//python:versions.bzl", "PLATFORMS", "TOOL_VERSIONS")
load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility
load("//python/private:full_version.bzl", "full_version") # buildifier: disable=bzl-visibility
load("//tests/support:sh_py_run_test.bzl", "py_reconfig_test")

def define_toolchain_tests(name):
Expand All @@ -30,7 +33,42 @@ def define_toolchain_tests(name):
constraint_values = platform_info.compatible_with,
)

for python_version, meta in TOOL_VERSIONS.items():
# First we expect the transitions with a specific version to always
# give us that specific version
exact_version_tests = {
(v, v): "python_{}_test".format(v)
for v in TOOL_VERSIONS
}
native.test_suite(
name = "exact_version_tests",
tests = exact_version_tests.values(),
)

# Then we expect to get the version in the MINOR_MAPPING if we provide
# the version from the MINOR_MAPPING
minor_mapping_tests = {
(minor, full): "python_{}_test".format(minor)
for minor, full in MINOR_MAPPING.items()
}
native.test_suite(
name = "minor_mapping_tests",
tests = minor_mapping_tests.values(),
)

# Lastly, if we don't provide any version to the transition, we should
# get the default version
default_version = full_version(
# note, this hard codes the version that is in //:WORKSPACE
version = DEFAULT_PYTHON_VERSION if BZLMOD_ENABLED else "3.11",
minor_mapping = MINOR_MAPPING,
)
default_version_tests = {
(None, default_version): "default_version_test",
}
tests = exact_version_tests | minor_mapping_tests | default_version_tests

for (input_python_version, expect_python_version), test_name in tests.items():
meta = TOOL_VERSIONS[expect_python_version]
target_compatible_with = {
"//conditions:default": ["@platforms//:incompatible"],
}
Expand All @@ -39,12 +77,12 @@ def define_toolchain_tests(name):
target_compatible_with[is_platform] = []

py_reconfig_test(
name = "python_{}_test".format(python_version),
name = test_name,
srcs = ["python_toolchain_test.py"],
main = "python_toolchain_test.py",
python_version = python_version,
python_version = input_python_version,
env = {
"EXPECT_PYTHON_VERSION": python_version,
"EXPECT_PYTHON_VERSION": expect_python_version,
},
deps = ["//python/runfiles"],
data = ["//tests/support:current_build_settings"],
Expand Down