Skip to content

Commit c9c2768

Browse files
aignasrickeylev
andauthored
internal(config_settings): make config_setting creation reusable (#1750)
The PR #1743 explored the idea of creating extra config settings for each target platform that our toolchain is targetting, however that has a drawback of not being usable in `bzlmod` if someone built Python for a platform that we don't provide a toolchain for and tried to use the `pip.parse` machinery with that by providing the `python_interpreter_target`. That is a niche usecase, but `rules_python` is a core ruleset that should only provide abstractions/helpers that work in all cases or make it possible to extend things. This explores a way to decouple the definition of the available `config_settings` values and how they are constructed by adding an extra `is_python_config_setting` macro, that could be used to declare the config settings from within the `pip.parse` hub repo. This makes the work in #1744 to support whl-only hub repos more self-contained. Supersedes #1743. --------- Co-authored-by: Richard Levasseur <[email protected]>
1 parent bdb2aa2 commit c9c2768

File tree

5 files changed

+303
-115
lines changed

5 files changed

+303
-115
lines changed

python/config_settings/BUILD.bazel

-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
load("//python:versions.bzl", "TOOL_VERSIONS")
21
load(":config_settings.bzl", "construct_config_settings")
32

43
filegroup(
@@ -12,5 +11,4 @@ filegroup(
1211

1312
construct_config_settings(
1413
name = "construct_config_settings",
15-
python_versions = TOOL_VERSIONS.keys(),
1614
)

python/config_settings/config_settings.bzl

+13-102
Original file line numberDiff line numberDiff line change
@@ -15,105 +15,16 @@
1515
"""This module is used to construct the config settings in the BUILD file in this same package.
1616
"""
1717

18-
load("@bazel_skylib//lib:selects.bzl", "selects")
19-
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
20-
load("//python:versions.bzl", "MINOR_MAPPING")
21-
22-
def construct_config_settings(name, python_versions):
23-
"""Constructs a set of configs for all Python versions.
24-
25-
Args:
26-
name: str, unused; only specified to satisfy buildifier lint checks
27-
and allow programatic modification of the target.
28-
python_versions: list of all (x.y.z) Python versions supported by rules_python.
29-
"""
30-
31-
# Maps e.g. "3.8" -> ["3.8.1", "3.8.2", etc]
32-
minor_to_micro_versions = {}
33-
34-
allowed_flag_values = []
35-
for micro_version in python_versions:
36-
minor, _, _ = micro_version.rpartition(".")
37-
minor_to_micro_versions.setdefault(minor, []).append(micro_version)
38-
allowed_flag_values.append(micro_version)
39-
40-
allowed_flag_values.extend(list(minor_to_micro_versions))
41-
42-
string_flag(
43-
name = "python_version",
44-
# TODO: The default here should somehow match the MODULE config. Until
45-
# then, use the empty string to indicate an unknown version. This
46-
# also prevents version-unaware targets from inadvertently matching
47-
# a select condition when they shouldn't.
48-
build_setting_default = "",
49-
values = [""] + sorted(allowed_flag_values),
50-
visibility = ["//visibility:public"],
51-
)
52-
53-
for minor_version, micro_versions in minor_to_micro_versions.items():
54-
# This matches the raw flag value, e.g. --//python/config_settings:python_version=3.8
55-
# It's private because matching the concept of e.g. "3.8" value is done
56-
# using the `is_python_X.Y` config setting group, which is aware of the
57-
# minor versions that could match instead.
58-
equals_minor_version_name = "_python_version_flag_equals_" + minor_version
59-
native.config_setting(
60-
name = equals_minor_version_name,
61-
flag_values = {":python_version": minor_version},
62-
)
63-
matches_minor_version_names = [equals_minor_version_name]
64-
65-
default_micro_version = MINOR_MAPPING[minor_version]
66-
67-
for micro_version in micro_versions:
68-
is_micro_version_name = "is_python_" + micro_version
69-
if default_micro_version != micro_version:
70-
native.config_setting(
71-
name = is_micro_version_name,
72-
flag_values = {":python_version": micro_version},
73-
visibility = ["//visibility:public"],
74-
)
75-
matches_minor_version_names.append(is_micro_version_name)
76-
continue
77-
78-
# Ensure that is_python_3.9.8 is matched if python_version is set
79-
# to 3.9 if MINOR_MAPPING points to 3.9.8
80-
equals_micro_name = "_python_version_flag_equals_" + micro_version
81-
native.config_setting(
82-
name = equals_micro_name,
83-
flag_values = {":python_version": micro_version},
84-
)
85-
86-
# An alias pointing to an underscore-prefixed config_setting_group
87-
# is used because config_setting_group creates
88-
# `is_{minor}_N` targets, which are easily confused with the
89-
# `is_{minor}.{micro}` (dot) targets.
90-
selects.config_setting_group(
91-
name = "_" + is_micro_version_name,
92-
match_any = [
93-
equals_micro_name,
94-
equals_minor_version_name,
95-
],
96-
)
97-
native.alias(
98-
name = is_micro_version_name,
99-
actual = "_" + is_micro_version_name,
100-
visibility = ["//visibility:public"],
101-
)
102-
matches_minor_version_names.append(equals_micro_name)
103-
104-
# This is prefixed with an underscore to prevent confusion due to how
105-
# config_setting_group is implemented and how our micro-version targets
106-
# are named. config_setting_group will generate targets like
107-
# "is_python_3.10_1" (where the `_N` suffix is len(match_any).
108-
# Meanwhile, the micro-version tarets are named "is_python_3.10.1" --
109-
# just a single dot vs underscore character difference.
110-
selects.config_setting_group(
111-
name = "_is_python_" + minor_version,
112-
match_any = matches_minor_version_names,
113-
)
114-
115-
native.alias(
116-
name = "is_python_" + minor_version,
117-
actual = "_is_python_" + minor_version,
118-
visibility = ["//visibility:public"],
119-
)
18+
load(
19+
"//python/private:config_settings.bzl",
20+
_construct_config_settings = "construct_config_settings",
21+
_is_python_config_setting = "is_python_config_setting",
22+
)
23+
24+
# This is exposed only for cases where the pip hub repo needs to use this rule
25+
# to define hub-repo scoped config_settings for platform specific wheel
26+
# support.
27+
is_python_config_setting = _is_python_config_setting
28+
29+
# This is exposed for usage in rules_python only.
30+
construct_config_settings = _construct_config_settings

python/private/BUILD.bazel

+9
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ bzl_library(
6868
srcs = ["bzlmod_enabled.bzl"],
6969
)
7070

71+
bzl_library(
72+
name = "config_settings_bzl",
73+
srcs = ["config_settings.bzl"],
74+
deps = [
75+
"//python:versions_bzl",
76+
"@bazel_skylib//lib:selects",
77+
],
78+
)
79+
7180
bzl_library(
7281
name = "coverage_deps_bzl",
7382
srcs = ["coverage_deps.bzl"],

python/private/config_settings.bzl

+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Copyright 2024 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+
"""This module is used to construct the config settings in the BUILD file in this same package.
16+
"""
17+
18+
load("@bazel_skylib//lib:selects.bzl", "selects")
19+
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
20+
load("//python:versions.bzl", "MINOR_MAPPING", "TOOL_VERSIONS")
21+
22+
_PYTHON_VERSION_FLAG = str(Label("//python/config_settings:python_version"))
23+
24+
def _ver_key(s):
25+
major, _, s = s.partition(".")
26+
minor, _, s = s.partition(".")
27+
micro, _, s = s.partition(".")
28+
return (int(major), int(minor), int(micro))
29+
30+
def _flag_values(python_versions):
31+
"""Construct a map of python_version to a list of toolchain values.
32+
33+
This mapping maps the concept of a config setting to a list of compatible toolchain versions.
34+
For using this in the code, the VERSION_FLAG_VALUES should be used instead.
35+
36+
Args:
37+
python_versions: list of strings; all X.Y.Z python versions
38+
39+
Returns:
40+
A `map[str, list[str]]`. Each key is a python_version flag value. Each value
41+
is a list of the python_version flag values that should match when for the
42+
`key`. For example:
43+
```
44+
"3.8" -> ["3.8", "3.8.1", "3.8.2", ..., "3.8.19"] # All 3.8 versions
45+
"3.8.2" -> ["3.8.2"] # Only 3.8.2
46+
"3.8.19" -> ["3.8.19", "3.8"] # The latest version should also match 3.8 so
47+
as when the `3.8` toolchain is used we just use the latest `3.8` toolchain.
48+
this makes the `select("is_python_3.8.19")` work no matter how the user
49+
specifies the latest python version to use.
50+
```
51+
"""
52+
ret = {}
53+
54+
for micro_version in sorted(python_versions, key = _ver_key):
55+
minor_version, _, _ = micro_version.rpartition(".")
56+
57+
# This matches the raw flag value, e.g. --//python/config_settings:python_version=3.8
58+
# It's private because matching the concept of e.g. "3.8" value is done
59+
# using the `is_python_X.Y` config setting group, which is aware of the
60+
# minor versions that could match instead.
61+
ret.setdefault(minor_version, [minor_version]).append(micro_version)
62+
63+
# Ensure that is_python_3.9.8 is matched if python_version is set
64+
# to 3.9 if MINOR_MAPPING points to 3.9.8
65+
default_micro_version = MINOR_MAPPING[minor_version]
66+
ret[micro_version] = [micro_version, minor_version] if default_micro_version == micro_version else [micro_version]
67+
68+
return ret
69+
70+
VERSION_FLAG_VALUES = _flag_values(TOOL_VERSIONS.keys())
71+
72+
def is_python_config_setting(name, *, python_version, reuse_conditions = None, **kwargs):
73+
"""Create a config setting for matching 'python_version' configuration flag.
74+
75+
This function is mainly intended for internal use within the `whl_library` and `pip_parse`
76+
machinery.
77+
78+
The matching of the 'python_version' flag depends on the value passed in
79+
`python_version` and here is the example for `3.8` (but the same applies
80+
to other python versions present in @//python:versions.bzl#TOOL_VERSIONS):
81+
* "3.8" -> ["3.8", "3.8.1", "3.8.2", ..., "3.8.19"] # All 3.8 versions
82+
* "3.8.2" -> ["3.8.2"] # Only 3.8.2
83+
* "3.8.19" -> ["3.8.19", "3.8"] # The latest version should also match 3.8 so
84+
as when the `3.8` toolchain is used we just use the latest `3.8` toolchain.
85+
this makes the `select("is_python_3.8.19")` work no matter how the user
86+
specifies the latest python version to use.
87+
88+
Args:
89+
name: name for the target that will be created to be used in select statements.
90+
python_version: The python_version to be passed in the `flag_values` in the
91+
`config_setting`. Depending on the version, the matching python version list
92+
can be as described above.
93+
reuse_conditions: A dict of version to version label for which we should
94+
reuse config_setting targets instead of creating them from scratch. This
95+
is useful when using is_python_config_setting multiple times in the
96+
same package with the same `major.minor` python versions.
97+
**kwargs: extra kwargs passed to the `config_setting`.
98+
"""
99+
if python_version not in name:
100+
fail("The name '{}' must have the python version '{}' in it".format(name, python_version))
101+
102+
if python_version not in VERSION_FLAG_VALUES:
103+
fail("The 'python_version' must be known to 'rules_python', choose from the values: {}".format(VERSION_FLAG_VALUES.keys()))
104+
105+
python_versions = VERSION_FLAG_VALUES[python_version]
106+
if len(python_versions) == 1:
107+
native.config_setting(
108+
name = name,
109+
flag_values = {
110+
_PYTHON_VERSION_FLAG: python_version,
111+
},
112+
**kwargs
113+
)
114+
return
115+
116+
reuse_conditions = reuse_conditions or {}
117+
create_config_settings = {
118+
"_{}".format(name).replace(python_version, version): {_PYTHON_VERSION_FLAG: version}
119+
for version in python_versions
120+
if not reuse_conditions or version not in reuse_conditions
121+
}
122+
match_any = list(create_config_settings.keys())
123+
for version, condition in reuse_conditions.items():
124+
if len(VERSION_FLAG_VALUES[version]) == 1:
125+
match_any.append(condition)
126+
continue
127+
128+
# Convert the name to an internal label that this function would create,
129+
# so that we are hitting the config_setting and not the config_setting_group.
130+
condition = Label(condition)
131+
if hasattr(condition, "same_package_label"):
132+
condition = condition.same_package_label("_" + condition.name)
133+
else:
134+
condition = condition.relative("_" + condition.name)
135+
136+
match_any.append(condition)
137+
138+
for name_, flag_values_ in create_config_settings.items():
139+
native.config_setting(
140+
name = name_,
141+
flag_values = flag_values_,
142+
**kwargs
143+
)
144+
145+
# An alias pointing to an underscore-prefixed config_setting_group
146+
# is used because config_setting_group creates
147+
# `is_{version}_N` targets, which are easily confused with the
148+
# `is_{minor}.{micro}` (dot) targets.
149+
selects.config_setting_group(
150+
name = "_{}_group".format(name),
151+
match_any = match_any,
152+
visibility = ["//visibility:private"],
153+
)
154+
native.alias(
155+
name = name,
156+
actual = "_{}_group".format(name),
157+
visibility = kwargs.get("visibility", []),
158+
)
159+
160+
def construct_config_settings(name = None): # buildifier: disable=function-docstring
161+
"""Create a 'python_version' config flag and construct all config settings used in rules_python.
162+
163+
This mainly includes the targets that are used in the toolchain and pip hub
164+
repositories that only match on the 'python_version' flag values.
165+
166+
Args:
167+
name(str): A dummy name value that is no-op for now.
168+
"""
169+
string_flag(
170+
name = "python_version",
171+
# TODO: The default here should somehow match the MODULE config. Until
172+
# then, use the empty string to indicate an unknown version. This
173+
# also prevents version-unaware targets from inadvertently matching
174+
# a select condition when they shouldn't.
175+
build_setting_default = "",
176+
values = [""] + VERSION_FLAG_VALUES.keys(),
177+
visibility = ["//visibility:public"],
178+
)
179+
180+
for version, matching_versions in VERSION_FLAG_VALUES.items():
181+
is_python_config_setting(
182+
name = "is_python_{}".format(version),
183+
python_version = version,
184+
reuse_conditions = {
185+
v: native.package_relative_label("is_python_{}".format(v))
186+
for v in matching_versions
187+
if v != version
188+
},
189+
visibility = ["//visibility:public"],
190+
)

0 commit comments

Comments
 (0)