• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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"""pkg_aliases is a macro to generate aliases for selecting the right wheel for the right target platform.
16
17This is used in bzlmod and non-bzlmod setups."""
18
19load("@bazel_skylib//lib:selects.bzl", "selects")
20load("//python/private:text_util.bzl", "render")
21load(
22    ":labels.bzl",
23    "DATA_LABEL",
24    "DIST_INFO_LABEL",
25    "PY_LIBRARY_IMPL_LABEL",
26    "PY_LIBRARY_PUBLIC_LABEL",
27    "WHEEL_FILE_IMPL_LABEL",
28    "WHEEL_FILE_PUBLIC_LABEL",
29)
30load(":parse_whl_name.bzl", "parse_whl_name")
31load(":whl_target_platforms.bzl", "whl_target_platforms")
32
33# This value is used as sentinel value in the alias/config setting machinery
34# for libc and osx versions. If we encounter this version in this part of the
35# code, then it means that we have a bug in rules_python and that we should fix
36# it. It is more of an internal consistency check.
37_VERSION_NONE = (0, 0)
38
39_CONFIG_SETTINGS_PKG = str(Label("//python/config_settings:BUILD.bazel")).partition(":")[0]
40
41_NO_MATCH_ERROR_TEMPLATE = """\
42No matching wheel for current configuration's Python version.
43
44The current build configuration's Python version doesn't match any of the Python
45wheels available for this distribution. This distribution supports the following Python
46configuration settings:
47    {config_settings}
48
49To determine the current configuration's Python version, run:
50    `bazel config <config id>` (shown further below)
51
52and look for one of:
53    {settings_pkg}:python_version
54    {settings_pkg}:pip_whl
55    {settings_pkg}:pip_whl_glibc_version
56    {settings_pkg}:pip_whl_muslc_version
57    {settings_pkg}:pip_whl_osx_arch
58    {settings_pkg}:pip_whl_osx_version
59    {settings_pkg}:py_freethreaded
60    {settings_pkg}:py_linux_libc
61
62If the value is missing, then the default value is being used, see documentation:
63{docs_url}/python/config_settings"""
64
65def _no_match_error(actual):
66    if type(actual) != type({}):
67        return None
68
69    if "//conditions:default" in actual:
70        return None
71
72    return _NO_MATCH_ERROR_TEMPLATE.format(
73        config_settings = render.indent(
74            "\n".join(sorted([
75                value
76                for key in actual
77                for value in (key if type(key) == "tuple" else [key])
78            ])),
79        ).lstrip(),
80        settings_pkg = _CONFIG_SETTINGS_PKG,
81        docs_url = "https://rules-python.readthedocs.io/en/latest/api/rules_python",
82    )
83
84def pkg_aliases(
85        *,
86        name,
87        actual,
88        group_name = None,
89        extra_aliases = None,
90        native = native,
91        select = selects.with_or,
92        **kwargs):
93    """Create aliases for an actual package.
94
95    Args:
96        name: {type}`str` The name of the package.
97        actual: {type}`dict[Label | tuple, str] | str` The name of the repo the
98            aliases point to, or a dict of select conditions to repo names for
99            the aliases to point to mapping to repositories. The keys are passed
100            to bazel skylib's `selects.with_or`, so they can be tuples as well.
101        group_name: {type}`str` The group name that the pkg belongs to.
102        extra_aliases: {type}`list[str]` The extra aliases to be created.
103        native: {type}`struct` used in unit tests.
104        select: {type}`select` used in unit tests.
105        **kwargs: extra kwargs to pass to {bzl:obj}`get_filename_config_settings`.
106    """
107    native.alias(
108        name = name,
109        actual = ":" + PY_LIBRARY_PUBLIC_LABEL,
110    )
111
112    target_names = {
113        PY_LIBRARY_PUBLIC_LABEL: PY_LIBRARY_IMPL_LABEL if group_name else PY_LIBRARY_PUBLIC_LABEL,
114        WHEEL_FILE_PUBLIC_LABEL: WHEEL_FILE_IMPL_LABEL if group_name else WHEEL_FILE_PUBLIC_LABEL,
115        DATA_LABEL: DATA_LABEL,
116        DIST_INFO_LABEL: DIST_INFO_LABEL,
117    } | {
118        x: x
119        for x in extra_aliases or []
120    }
121
122    actual = multiplatform_whl_aliases(aliases = actual, **kwargs)
123    no_match_error = _no_match_error(actual)
124
125    for name, target_name in target_names.items():
126        if type(actual) == type(""):
127            _actual = "@{repo}//:{target_name}".format(
128                repo = actual,
129                target_name = name,
130            )
131        elif type(actual) == type({}):
132            _actual = select(
133                {
134                    v: "@{repo}//:{target_name}".format(
135                        repo = repo,
136                        target_name = name,
137                    )
138                    for v, repo in actual.items()
139                },
140                no_match_error = no_match_error,
141            )
142        else:
143            fail("The `actual` arg must be a dictionary or a string")
144
145        kwargs = {}
146        if target_name.startswith("_"):
147            kwargs["visibility"] = ["//_groups:__subpackages__"]
148
149        native.alias(
150            name = target_name,
151            actual = _actual,
152            **kwargs
153        )
154
155    if group_name:
156        native.alias(
157            name = PY_LIBRARY_PUBLIC_LABEL,
158            actual = "//_groups:{}_pkg".format(group_name),
159        )
160        native.alias(
161            name = WHEEL_FILE_PUBLIC_LABEL,
162            actual = "//_groups:{}_whl".format(group_name),
163        )
164
165def _normalize_versions(name, versions):
166    if not versions:
167        return []
168
169    if _VERSION_NONE in versions:
170        fail("a sentinel version found in '{}', check render_pkg_aliases for bugs".format(name))
171
172    return sorted(versions)
173
174def multiplatform_whl_aliases(
175        *,
176        aliases = [],
177        glibc_versions = [],
178        muslc_versions = [],
179        osx_versions = []):
180    """convert a list of aliases from filename to config_setting ones.
181
182    Args:
183        aliases: {type}`str | dict[whl_config_setting | str, str]`: The aliases
184            to process. Any aliases that have the filename set will be
185            converted to a dict of config settings to repo names.
186        glibc_versions: {type}`list[tuple[int, int]]` list of versions that can be
187            used in this hub repo.
188        muslc_versions: {type}`list[tuple[int, int]]` list of versions that can be
189            used in this hub repo.
190        osx_versions: {type}`list[tuple[int, int]]` list of versions that can be
191            used in this hub repo.
192
193    Returns:
194        A dict with of config setting labels to repo names or the repo name itself.
195    """
196
197    if type(aliases) == type(""):
198        # We don't have any aliases, this is a repo name
199        return aliases
200
201    # TODO @aignas 2024-11-17: we might be able to use FeatureFlagInfo and some
202    # code gen to create a version_lt_x target, which would allow us to check
203    # if the libc version is in a particular range.
204    glibc_versions = _normalize_versions("glibc_versions", glibc_versions)
205    muslc_versions = _normalize_versions("muslc_versions", muslc_versions)
206    osx_versions = _normalize_versions("osx_versions", osx_versions)
207
208    ret = {}
209    versioned_additions = {}
210    for alias, repo in aliases.items():
211        if type(alias) != "struct":
212            ret[alias] = repo
213            continue
214        elif not (alias.filename or alias.target_platforms):
215            # This is an internal consistency check
216            fail("Expected to have either 'filename' or 'target_platforms' set, got: {}".format(alias))
217
218        config_settings, all_versioned_settings = get_filename_config_settings(
219            filename = alias.filename or "",
220            target_platforms = alias.target_platforms,
221            python_version = alias.version,
222            # If we have multiple platforms but no wheel filename, lets use different
223            # config settings.
224            non_whl_prefix = "sdist" if alias.filename else "",
225            glibc_versions = glibc_versions,
226            muslc_versions = muslc_versions,
227            osx_versions = osx_versions,
228        )
229
230        for setting in config_settings:
231            ret["//_config" + setting] = repo
232
233        # Now for the versioned platform config settings, we need to select one
234        # that best fits the bill and if there are multiple wheels, e.g.
235        # manylinux_2_17_x86_64 and manylinux_2_28_x86_64, then we need to select
236        # the former when the glibc is in the range of [2.17, 2.28) and then chose
237        # the later if it is [2.28, ...). If the 2.28 wheel was not present in
238        # the hub, then we would need to use 2.17 for all the glibc version
239        # configurations.
240        #
241        # Here we add the version settings to a dict where we key the range of
242        # versions that the whl spans. If the wheel supports musl and glibc at
243        # the same time, we do this for each supported platform, hence the
244        # double dict.
245        for default_setting, versioned in all_versioned_settings.items():
246            versions = sorted(versioned)
247            min_version = versions[0]
248            max_version = versions[-1]
249
250            versioned_additions.setdefault(default_setting, {})[(min_version, max_version)] = struct(
251                repo = repo,
252                settings = versioned,
253            )
254
255    versioned = {}
256    for default_setting, candidates in versioned_additions.items():
257        # Sort the candidates by the range of versions the span, so that we
258        # start with the lowest version.
259        for _, candidate in sorted(candidates.items()):
260            # Set the default with the first candidate, which gives us the highest
261            # compatibility. If the users want to use a higher-version than the default
262            # they can configure the glibc_version flag.
263            versioned.setdefault("//_config" + default_setting, candidate.repo)
264
265            # We will be overwriting previously added entries, but that is intended.
266            for _, setting in candidate.settings.items():
267                versioned["//_config" + setting] = candidate.repo
268
269    ret.update(versioned)
270    return ret
271
272def get_filename_config_settings(
273        *,
274        filename,
275        target_platforms,
276        python_version,
277        glibc_versions = None,
278        muslc_versions = None,
279        osx_versions = None,
280        non_whl_prefix = "sdist"):
281    """Get the filename config settings.
282
283    Args:
284        filename: the distribution filename (can be a whl or an sdist).
285        target_platforms: list[str], target platforms in "{abi}_{os}_{cpu}" format.
286        glibc_versions: list[tuple[int, int]], list of versions.
287        muslc_versions: list[tuple[int, int]], list of versions.
288        osx_versions: list[tuple[int, int]], list of versions.
289        python_version: the python version to generate the config_settings for.
290        non_whl_prefix: the prefix of the config setting when the whl we don't have
291            a filename ending with ".whl".
292
293    Returns:
294        A tuple:
295         * A list of config settings that are generated by ./pip_config_settings.bzl
296         * The list of default version settings.
297    """
298    prefixes = []
299    suffixes = []
300    setting_supported_versions = {}
301
302    if filename.endswith(".whl"):
303        parsed = parse_whl_name(filename)
304        if parsed.python_tag == "py2.py3":
305            py = "py"
306        elif parsed.python_tag.startswith("cp"):
307            py = "cp3x"
308        else:
309            py = "py3"
310
311        if parsed.abi_tag.startswith("cp"):
312            abi = "cp"
313        else:
314            abi = parsed.abi_tag
315
316        if parsed.platform_tag == "any":
317            prefixes = ["_{}_{}_any".format(py, abi)]
318        else:
319            prefixes = ["_{}_{}".format(py, abi)]
320            suffixes = _whl_config_setting_suffixes(
321                platform_tag = parsed.platform_tag,
322                glibc_versions = glibc_versions,
323                muslc_versions = muslc_versions,
324                osx_versions = osx_versions,
325                setting_supported_versions = setting_supported_versions,
326            )
327    else:
328        prefixes = [""] if not non_whl_prefix else ["_" + non_whl_prefix]
329
330    versioned = {
331        ":is_cp{}{}_{}".format(python_version, p, suffix): {
332            version: ":is_cp{}{}_{}".format(python_version, p, setting)
333            for version, setting in versions.items()
334        }
335        for p in prefixes
336        for suffix, versions in setting_supported_versions.items()
337    }
338
339    if suffixes or target_platforms or versioned:
340        target_platforms = target_platforms or []
341        suffixes = suffixes or [_non_versioned_platform(p) for p in target_platforms]
342        return [
343            ":is_cp{}{}_{}".format(python_version, p, s)
344            for p in prefixes
345            for s in suffixes
346        ], versioned
347    else:
348        return [":is_cp{}{}".format(python_version, p) for p in prefixes], setting_supported_versions
349
350def _whl_config_setting_suffixes(
351        platform_tag,
352        glibc_versions,
353        muslc_versions,
354        osx_versions,
355        setting_supported_versions):
356    suffixes = []
357    for platform_tag in platform_tag.split("."):
358        for p in whl_target_platforms(platform_tag):
359            prefix = p.os
360            suffix = p.cpu
361            if "manylinux" in platform_tag:
362                prefix = "manylinux"
363                versions = glibc_versions
364            elif "musllinux" in platform_tag:
365                prefix = "musllinux"
366                versions = muslc_versions
367            elif p.os in ["linux", "windows"]:
368                versions = [(0, 0)]
369            elif p.os == "osx":
370                versions = osx_versions
371                if "universal2" in platform_tag:
372                    suffix += "_universal2"
373            else:
374                fail("Unsupported whl os: {}".format(p.os))
375
376            default_version_setting = "{}_{}".format(prefix, suffix)
377            supported_versions = {}
378            for v in versions:
379                if v == (0, 0):
380                    suffixes.append(default_version_setting)
381                elif v >= p.version:
382                    supported_versions[v] = "{}_{}_{}_{}".format(
383                        prefix,
384                        v[0],
385                        v[1],
386                        suffix,
387                    )
388            if supported_versions:
389                setting_supported_versions[default_version_setting] = supported_versions
390
391    return suffixes
392
393def _non_versioned_platform(p, *, strict = False):
394    """A small utility function that converts 'cp311_linux_x86_64' to 'linux_x86_64'.
395
396    This is so that we can tighten the code structure later by using strict = True.
397    """
398    has_abi = p.startswith("cp")
399    if has_abi:
400        return p.partition("_")[-1]
401    elif not strict:
402        return p
403    else:
404        fail("Expected to always have a platform in the form '{{abi}}_{{os}}_{{arch}}', got: {}".format(p))
405