blob: a9eee7be88139661935aba7700f67e13324b5d2f [file] [log] [blame]
# Copyright 2024 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""{obj}`pkg_aliases` is a macro to generate aliases for selecting the right wheel for the right target platform.
If you see an error where the distribution selection error indicates the config setting names this
page may help to describe the naming convention and relationship between various flags and options
in `rules_python` and the error message contents.
Definitions:
:minor_version: Python interpreter minor version that the distributions are compatible with.
:suffix: Can be either empty or `_<os>_<suffix>`, which is usually used to distinguish multiple versions used for different target platforms.
:os: OS identifier that exists in `@platforms//os:<os>`.
:cpu: CPU architecture identifier that exists in `@platforms//cpu:<cpu>`.
:python_tag: The Python tag as defined by the [Python Packaging Authority][packaging_spec]. E.g. `py2.py3`, `py3`, `py311`, `cp311`.
:abi_tag: The ABI tag as defined by the [Python Packaging Authority][packaging_spec]. E.g. `none`, `abi3`, `cp311`, `cp311t`.
:platform_tag: The Platform tag as defined by the [Python Packaging Authority][packaging_spec]. E.g. `manylinux_2_17_x86_64`.
:platform_suffix: is a derivative of the `platform_tag` and is used to implement selection based on `libc` or `osx` version.
All of the config settings used by this macro are generated by
{obj}`config_settings`, for more detailed documentation on what each config
setting maps to and their precedence, refer to documentation on that page.
The first group of config settings that are as follows:
* `//_config:is_cp3<minor_version><suffix>` is used to select legacy `pip`
based `whl` and `sdist` {obj}`whl_library` instances. Whereas other config
settings are created when {obj}`pip.parse.experimental_index_url` is used.
* `//_config:is_cp3<minor_version>_sdist<suffix>` is for wheels built from
`sdist` in {obj}`whl_library`.
* `//_config:is_cp3<minor_version>_py_<abi_tag>_any<suffix>` for wheels with
`py2.py3` `python_tag` value.
* `//_config:is_cp3<minor_version>_py3_<abi_tag>_any<suffix>` for wheels with
`py3` `python_tag` value.
* `//_config:is_cp3<minor_version>_<abi_tag>_any<suffix>` for any other wheels.
* `//_config:is_cp3<minor_version>_py_<abi_tag>_<platform_suffix>` for
platform-specific wheels with `py2.py3` `python_tag` value.
* `//_config:is_cp3<minor_version>_py3_<abi_tag>_<platform_suffix>` for
platform-specific wheels with `py3` `python_tag` value.
* `//_config:is_cp3<minor_version>_<abi_tag>_<platform_suffix>` for any other
platform-specific wheels.
Note that wheels with `abi3` or `none` `abi_tag` values and `python_tag` values
other than `py2.py3` or `py3` are compatible with the python version that is
equal or higher than the one denoted in the `python_tag`. For example: `py37`
and `cp37` wheels are compatible with Python 3.7 and above and in the case of
the target python version being `3.11`, `rules_python` will use
`//_config:is_cp311_<abi_tag>_any<suffix>` config settings.
For platform-specific wheels, i.e. the ones that have their `platform_tag` as
something else than `any`, we treat them as below:
* `linux_<cpu>` tags assume that the target `libc` flavour is `glibc`, so this
is in many ways equivalent to it being `manylinux`, but with an unspecified
`libc` version.
* For `osx` and `linux` OSes wheel filename will be mapped to multiple config settings:
* `osx_<cpu>` and `osx_<major_version>_<minor_version>_<cpu>` where
`major_version` and `minor_version` are the compatible OSX versions.
* `<many|musl>linux_<cpu>` and
`<many|musl>linux_<major_version>_<minor_version>_<cpu>` where the version
identifiers are the compatible libc versions.
[packaging_spec]: https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/
"""
load("@bazel_skylib//lib:selects.bzl", "selects")
load("//python/private:text_util.bzl", "render")
load(
":labels.bzl",
"DATA_LABEL",
"DIST_INFO_LABEL",
"PY_LIBRARY_IMPL_LABEL",
"PY_LIBRARY_PUBLIC_LABEL",
"WHEEL_FILE_IMPL_LABEL",
"WHEEL_FILE_PUBLIC_LABEL",
)
load(":parse_whl_name.bzl", "parse_whl_name")
load(":whl_target_platforms.bzl", "whl_target_platforms")
# This value is used as sentinel value in the alias/config setting machinery
# for libc and osx versions. If we encounter this version in this part of the
# code, then it means that we have a bug in rules_python and that we should fix
# it. It is more of an internal consistency check.
_VERSION_NONE = (0, 0)
_NO_MATCH_ERROR_TEMPLATE = """\
No matching wheel for current configuration's Python version.
The current build configuration's Python version doesn't match any of the Python
wheels available for this distribution. This distribution supports the following Python
configuration settings:
{config_settings}
To determine the current configuration's Python version, run:
`bazel config <config id>` (shown further below)
For the current configuration value see the debug message above that is
printing the current flag values. If you can't see the message, then re-run the
build to make it a failure instead by running the build with:
--{current_flags}=fail
However, the command above will hide the `bazel config <config id>` message.
"""
_LABEL_NONE = Label("//python:none")
_LABEL_CURRENT_CONFIG = Label("//python/config_settings:current_config")
_LABEL_CURRENT_CONFIG_NO_MATCH = Label("//python/config_settings:is_not_matching_current_config")
_INCOMPATIBLE = "_no_matching_repository"
def pkg_aliases(
*,
name,
actual,
group_name = None,
extra_aliases = None,
**kwargs):
"""Create aliases for an actual package.
Exposed only to be used from the hub repositories created by `rules_python`.
Args:
name: {type}`str` The name of the package.
actual: {type}`dict[Label | tuple, str] | str` The name of the repo the
aliases point to, or a dict of select conditions to repo names for
the aliases to point to mapping to repositories. The keys are passed
to bazel skylib's `selects.with_or`, so they can be tuples as well.
group_name: {type}`str` The group name that the pkg belongs to.
extra_aliases: {type}`list[str]` The extra aliases to be created.
**kwargs: extra kwargs to pass to {bzl:obj}`get_filename_config_settings`.
"""
alias = kwargs.pop("native", native).alias
select = kwargs.pop("select", selects.with_or)
alias(
name = name,
actual = ":" + PY_LIBRARY_PUBLIC_LABEL,
)
target_names = {
PY_LIBRARY_PUBLIC_LABEL: PY_LIBRARY_IMPL_LABEL if group_name else PY_LIBRARY_PUBLIC_LABEL,
WHEEL_FILE_PUBLIC_LABEL: WHEEL_FILE_IMPL_LABEL if group_name else WHEEL_FILE_PUBLIC_LABEL,
DATA_LABEL: DATA_LABEL,
DIST_INFO_LABEL: DIST_INFO_LABEL,
} | {
x: x
for x in extra_aliases or []
}
actual = multiplatform_whl_aliases(aliases = actual, **kwargs)
if type(actual) == type({}) and "//conditions:default" not in actual:
alias(
name = _INCOMPATIBLE,
actual = select(
{_LABEL_CURRENT_CONFIG_NO_MATCH: _LABEL_NONE},
no_match_error = _NO_MATCH_ERROR_TEMPLATE.format(
config_settings = render.indent(
"\n".join(sorted([
value
for key in actual
for value in (key if type(key) == "tuple" else [key])
])),
).lstrip(),
current_flags = str(_LABEL_CURRENT_CONFIG),
),
),
visibility = ["//visibility:private"],
tags = ["manual"],
)
actual["//conditions:default"] = _INCOMPATIBLE
for name, target_name in target_names.items():
if type(actual) == type(""):
_actual = "@{repo}//:{target_name}".format(
repo = actual,
target_name = name,
)
elif type(actual) == type({}):
_actual = select(
{
v: "@{repo}//:{target_name}".format(
repo = repo,
target_name = name,
) if repo != _INCOMPATIBLE else repo
for v, repo in actual.items()
},
)
else:
fail("The `actual` arg must be a dictionary or a string")
kwargs = {}
if target_name.startswith("_"):
kwargs["visibility"] = ["//_groups:__subpackages__"]
alias(
name = target_name,
actual = _actual,
**kwargs
)
if group_name:
alias(
name = PY_LIBRARY_PUBLIC_LABEL,
actual = "//_groups:{}_pkg".format(group_name),
)
alias(
name = WHEEL_FILE_PUBLIC_LABEL,
actual = "//_groups:{}_whl".format(group_name),
)
def _normalize_versions(name, versions):
if not versions:
return []
if _VERSION_NONE in versions:
fail("a sentinel version found in '{}', check render_pkg_aliases for bugs".format(name))
return sorted(versions)
def multiplatform_whl_aliases(
*,
aliases = [],
glibc_versions = [],
muslc_versions = [],
osx_versions = []):
"""convert a list of aliases from filename to config_setting ones.
Exposed only for unit tests.
Args:
aliases: {type}`str | dict[whl_config_setting | str, str]`: The aliases
to process. Any aliases that have the filename set will be
converted to a dict of config settings to repo names.
glibc_versions: {type}`list[tuple[int, int]]` list of versions that can be
used in this hub repo.
muslc_versions: {type}`list[tuple[int, int]]` list of versions that can be
used in this hub repo.
osx_versions: {type}`list[tuple[int, int]]` list of versions that can be
used in this hub repo.
Returns:
A dict with of config setting labels to repo names or the repo name itself.
"""
if type(aliases) == type(""):
# We don't have any aliases, this is a repo name
return aliases
# TODO @aignas 2024-11-17: we might be able to use FeatureFlagInfo and some
# code gen to create a version_lt_x target, which would allow us to check
# if the libc version is in a particular range.
glibc_versions = _normalize_versions("glibc_versions", glibc_versions)
muslc_versions = _normalize_versions("muslc_versions", muslc_versions)
osx_versions = _normalize_versions("osx_versions", osx_versions)
ret = {}
versioned_additions = {}
for alias, repo in aliases.items():
if type(alias) != "struct":
ret[alias] = repo
continue
elif not (alias.filename or alias.target_platforms):
# This is an internal consistency check
fail("Expected to have either 'filename' or 'target_platforms' set, got: {}".format(alias))
config_settings, all_versioned_settings = get_filename_config_settings(
filename = alias.filename or "",
target_platforms = alias.target_platforms,
python_version = alias.version,
# If we have multiple platforms but no wheel filename, lets use different
# config settings.
non_whl_prefix = "sdist" if alias.filename else "",
glibc_versions = glibc_versions,
muslc_versions = muslc_versions,
osx_versions = osx_versions,
)
for setting in config_settings:
ret["//_config" + setting] = repo
# Now for the versioned platform config settings, we need to select one
# that best fits the bill and if there are multiple wheels, e.g.
# manylinux_2_17_x86_64 and manylinux_2_28_x86_64, then we need to select
# the former when the glibc is in the range of [2.17, 2.28) and then chose
# the later if it is [2.28, ...). If the 2.28 wheel was not present in
# the hub, then we would need to use 2.17 for all the glibc version
# configurations.
#
# Here we add the version settings to a dict where we key the range of
# versions that the whl spans. If the wheel supports musl and glibc at
# the same time, we do this for each supported platform, hence the
# double dict.
for default_setting, versioned in all_versioned_settings.items():
versions = sorted(versioned)
min_version = versions[0]
max_version = versions[-1]
versioned_additions.setdefault(default_setting, {})[(min_version, max_version)] = struct(
repo = repo,
settings = versioned,
)
versioned = {}
for default_setting, candidates in versioned_additions.items():
# Sort the candidates by the range of versions the span, so that we
# start with the lowest version.
for _, candidate in sorted(candidates.items()):
# Set the default with the first candidate, which gives us the highest
# compatibility. If the users want to use a higher-version than the default
# they can configure the glibc_version flag.
versioned.setdefault("//_config" + default_setting, candidate.repo)
# We will be overwriting previously added entries, but that is intended.
for _, setting in candidate.settings.items():
versioned["//_config" + setting] = candidate.repo
ret.update(versioned)
return ret
def get_filename_config_settings(
*,
filename,
target_platforms,
python_version,
glibc_versions = None,
muslc_versions = None,
osx_versions = None,
non_whl_prefix = "sdist"):
"""Get the filename config settings.
Exposed only for unit tests.
Args:
filename: the distribution filename (can be a whl or an sdist).
target_platforms: list[str], target platforms in "{abi}_{os}_{cpu}" format.
glibc_versions: list[tuple[int, int]], list of versions.
muslc_versions: list[tuple[int, int]], list of versions.
osx_versions: list[tuple[int, int]], list of versions.
python_version: the python version to generate the config_settings for.
non_whl_prefix: the prefix of the config setting when the whl we don't have
a filename ending with ".whl".
Returns:
A tuple:
* A list of config settings that are generated by ./pip_config_settings.bzl
* The list of default version settings.
"""
prefixes = []
suffixes = []
setting_supported_versions = {}
if filename.endswith(".whl"):
parsed = parse_whl_name(filename)
if parsed.python_tag == "py2.py3":
py = "py_"
elif parsed.python_tag == "py3":
py = "py3_"
elif parsed.python_tag.startswith("cp"):
py = ""
else:
py = "py3_"
abi = parsed.abi_tag
if parsed.platform_tag == "any":
prefixes = ["{}{}_any".format(py, abi)]
else:
prefixes = ["{}{}".format(py, abi)]
suffixes = _whl_config_setting_suffixes(
platform_tag = parsed.platform_tag,
glibc_versions = glibc_versions,
muslc_versions = muslc_versions,
osx_versions = osx_versions,
setting_supported_versions = setting_supported_versions,
)
else:
prefixes = [non_whl_prefix or ""]
py = "cp{}".format(python_version).replace(".", "")
prefixes = [
"{}_{}".format(py, prefix) if prefix else py
for prefix in prefixes
]
versioned = {
":is_{}_{}".format(prefix, suffix): {
version: ":is_{}_{}".format(prefix, setting)
for version, setting in versions.items()
}
for prefix in prefixes
for suffix, versions in setting_supported_versions.items()
}
if suffixes or target_platforms or versioned:
target_platforms = target_platforms or []
suffixes = suffixes or [_non_versioned_platform(p) for p in target_platforms]
return [
":is_{}_{}".format(prefix, suffix)
for prefix in prefixes
for suffix in suffixes
], versioned
else:
return [":is_{}".format(p) for p in prefixes], setting_supported_versions
def _whl_config_setting_suffixes(
platform_tag,
glibc_versions,
muslc_versions,
osx_versions,
setting_supported_versions):
suffixes = []
for platform_tag in platform_tag.split("."):
for p in whl_target_platforms(platform_tag):
prefix = p.os
suffix = p.cpu
if "manylinux" in platform_tag:
prefix = "manylinux"
versions = glibc_versions
elif "musllinux" in platform_tag:
prefix = "musllinux"
versions = muslc_versions
elif p.os in ["linux", "windows"]:
versions = [(0, 0)]
elif p.os == "osx":
versions = osx_versions
if "universal2" in platform_tag:
suffix = "universal2"
else:
fail("Unsupported whl os: {}".format(p.os))
default_version_setting = "{}_{}".format(prefix, suffix)
supported_versions = {}
for v in versions:
if v == (0, 0):
suffixes.append(default_version_setting)
elif v >= p.version:
supported_versions[v] = "{}_{}_{}_{}".format(
prefix,
v[0],
v[1],
suffix,
)
if supported_versions:
setting_supported_versions[default_version_setting] = supported_versions
return suffixes
def _non_versioned_platform(p, *, strict = False):
"""A small utility function that converts 'cp311_linux_x86_64' to 'linux_x86_64'.
This is so that we can tighten the code structure later by using strict = True.
"""
has_abi = p.startswith("cp")
if has_abi:
return p.partition("_")[-1]
elif not strict:
return p
else:
fail("Expected to always have a platform in the form '{{abi}}_{{os}}_{{arch}}', got: {}".format(p))