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