blob: 58d35f268163367ce0069cd46e8a86160a27e3b9 [file] [log] [blame]
"""A hub repository builder for incrementally building the hub configuration."""
load("//python/private:full_version.bzl", "full_version")
load("//python/private:normalize_name.bzl", "normalize_name")
load("//python/private:version.bzl", "version")
load("//python/private:version_label.bzl", "version_label")
load(":attrs.bzl", "use_isolated")
load(":evaluate_markers.bzl", "evaluate_markers_py", evaluate_markers_star = "evaluate_markers")
load(":parse_requirements.bzl", "parse_requirements")
load(":pep508_env.bzl", "env")
load(":pep508_evaluate.bzl", "evaluate")
load(":python_tag.bzl", "python_tag")
load(":requirements_files_by_platform.bzl", "requirements_files_by_platform")
load(":whl_config_setting.bzl", "whl_config_setting")
load(":whl_repo_name.bzl", "pypi_repo_name", "whl_repo_name")
def _major_minor_version(version_str):
ver = version.parse(version_str)
return "{}.{}".format(ver.release[0], ver.release[1])
def hub_builder(
*,
name,
module_name,
config,
whl_overrides,
minor_mapping,
available_interpreters,
simpleapi_download_fn,
evaluate_markers_fn,
logger,
simpleapi_cache = {}):
"""Return a hub builder instance
Args:
name: {type}`str`, the name of the hub.
module_name: {type}`str`, the module name that has created the hub.
config: The platform configuration.
whl_overrides: {type}`dict[str, struct]` - per-wheel overrides.
minor_mapping: {type}`dict[str, str]` the mapping between minor and full versions.
evaluate_markers_fn: the override function used to evaluate the markers.
available_interpreters: {type}`dict[str, Label]` The dictionary of available
interpreters that have been registered using the `python` bzlmod extension.
The keys are in the form `python_{snake_case_version}_host`. This is to be
used during the `repository_rule` and must be always compatible with the host.
simpleapi_download_fn: the function used to download from SimpleAPI.
simpleapi_cache: the cache for the download results.
logger: the logger for this builder.
"""
# buildifier: disable=uninitialized
self = struct(
name = name,
module_name = module_name,
# public methods, keep sorted and to minimum
build = lambda: _build(self),
pip_parse = lambda *a, **k: _pip_parse(self, *a, **k),
# build output
_exposed_packages = {}, # modified by _add_exposed_packages
_extra_aliases = {}, # modified by _add_extra_aliases
_group_map = {}, # modified by _add_group_map
_whl_libraries = {}, # modified by _add_whl_library
_whl_map = {}, # modified by _add_whl_library
# internal
_platforms = {},
_group_name_by_whl = {},
_get_index_urls = {},
_use_downloader = {},
_simpleapi_cache = simpleapi_cache,
# instance constants
_config = config,
_whl_overrides = whl_overrides,
_evaluate_markers_fn = evaluate_markers_fn,
_logger = logger,
_minor_mapping = minor_mapping,
_available_interpreters = available_interpreters,
_simpleapi_download_fn = simpleapi_download_fn,
)
# buildifier: enable=uninitialized
return self
### PUBLIC methods
def _build(self):
whl_map = {}
for key, settings in self._whl_map.items():
for setting, repo in settings.items():
whl_map.setdefault(key, {}).setdefault(repo, []).append(setting)
return struct(
whl_map = whl_map,
group_map = self._group_map,
extra_aliases = {
whl: sorted(aliases)
for whl, aliases in self._extra_aliases.items()
},
exposed_packages = sorted(self._exposed_packages),
whl_libraries = self._whl_libraries,
)
def _pip_parse(self, module_ctx, pip_attr):
python_version = pip_attr.python_version
if python_version in self._platforms:
fail((
"Duplicate pip python version '{version}' for hub " +
"'{hub}' in module '{module}': the Python versions " +
"used for a hub must be unique"
).format(
hub = self.name,
module = self.module_name,
version = python_version,
))
self._platforms[python_version] = _platforms(
python_version = python_version,
minor_mapping = self._minor_mapping,
config = self._config,
)
_set_get_index_urls(self, pip_attr)
_add_group_map(self, pip_attr.experimental_requirement_cycles)
_add_extra_aliases(self, pip_attr.extra_hub_aliases)
_create_whl_repos(
self,
module_ctx,
pip_attr = pip_attr,
)
### end of PUBLIC methods
### setters for build outputs
def _add_exposed_packages(self, exposed_packages):
if self._exposed_packages:
intersection = {}
for pkg in exposed_packages:
if pkg not in self._exposed_packages:
continue
intersection[pkg] = None
self._exposed_packages.clear()
exposed_packages = intersection
self._exposed_packages.update(exposed_packages)
def _add_group_map(self, group_map):
# TODO @aignas 2024-04-05: how do we support different requirement
# cycles for different abis/oses? For now we will need the users to
# assume the same groups across all versions/platforms until we start
# using an alternative cycle resolution strategy.
group_map = {
name: [normalize_name(whl_name) for whl_name in whls]
for name, whls in group_map.items()
}
self._group_map.clear()
self._group_name_by_whl.clear()
self._group_map.update(group_map)
self._group_name_by_whl.update({
whl_name: group_name
for group_name, group_whls in self._group_map.items()
for whl_name in group_whls
})
def _add_extra_aliases(self, extra_hub_aliases):
for whl_name, aliases in extra_hub_aliases.items():
self._extra_aliases.setdefault(whl_name, {}).update(
{alias: True for alias in aliases},
)
def _add_whl_library(self, *, python_version, whl, repo):
if repo == None:
# NOTE @aignas 2025-07-07: we guard against an edge-case where there
# are more platforms defined than there are wheels for and users
# disallow building from sdist.
return
platforms = self._platforms[python_version]
# TODO @aignas 2025-06-29: we should not need the version in the repo_name if
# we are using pipstar and we are downloading the wheel using the downloader
repo_name = "{}_{}_{}".format(self.name, version_label(python_version), repo.repo_name)
if repo_name in self._whl_libraries:
fail("attempting to create a duplicate library {} for {}".format(
repo_name,
whl.name,
))
self._whl_libraries[repo_name] = repo.args
if not self._config.enable_pipstar and "experimental_target_platforms" in repo.args:
self._whl_libraries[repo_name] |= {
"experimental_target_platforms": sorted({
# TODO @aignas 2025-07-07: this should be solved in a better way
platforms[candidate].triple.partition("_")[-1]: None
for p in repo.args["experimental_target_platforms"]
for candidate in platforms
if candidate.endswith(p)
}),
}
mapping = self._whl_map.setdefault(whl.name, {})
if repo.config_setting in mapping and mapping[repo.config_setting] != repo_name:
fail(
"attempting to override an existing repo '{}' for config setting '{}' with a new repo '{}'".format(
mapping[repo.config_setting],
repo.config_setting,
repo_name,
),
)
else:
mapping[repo.config_setting] = repo_name
### end of setters, below we have various functions to implement the public methods
def _set_get_index_urls(self, pip_attr):
if not pip_attr.experimental_index_url:
if pip_attr.experimental_extra_index_urls:
fail("'experimental_extra_index_urls' is a no-op unless 'experimental_index_url' is set")
elif pip_attr.experimental_index_url_overrides:
fail("'experimental_index_url_overrides' is a no-op unless 'experimental_index_url' is set")
elif pip_attr.simpleapi_skip:
fail("'simpleapi_skip' is a no-op unless 'experimental_index_url' is set")
elif pip_attr.netrc:
fail("'netrc' is a no-op unless 'experimental_index_url' is set")
elif pip_attr.auth_patterns:
fail("'auth_patterns' is a no-op unless 'experimental_index_url' is set")
# parallel_download is set to True by default, so we are not checking/validating it
# here
return
python_version = pip_attr.python_version
self._use_downloader.setdefault(python_version, {}).update({
normalize_name(s): False
for s in pip_attr.simpleapi_skip
})
self._get_index_urls[python_version] = lambda ctx, distributions: self._simpleapi_download_fn(
ctx,
attr = struct(
index_url = pip_attr.experimental_index_url,
extra_index_urls = pip_attr.experimental_extra_index_urls or [],
index_url_overrides = pip_attr.experimental_index_url_overrides or {},
sources = [
d
for d in distributions
if _use_downloader(self, python_version, d)
],
envsubst = pip_attr.envsubst,
# Auth related info
netrc = pip_attr.netrc,
auth_patterns = pip_attr.auth_patterns,
),
cache = self._simpleapi_cache,
parallel_download = pip_attr.parallel_download,
)
def _detect_interpreter(self, pip_attr):
python_interpreter_target = pip_attr.python_interpreter_target
if python_interpreter_target == None and not pip_attr.python_interpreter:
python_name = "python_{}_host".format(
pip_attr.python_version.replace(".", "_"),
)
if python_name not in self._available_interpreters:
fail((
"Unable to find interpreter for pip hub '{hub_name}' for " +
"python_version={version}: Make sure a corresponding " +
'`python.toolchain(python_version="{version}")` call exists.' +
"Expected to find {python_name} among registered versions:\n {labels}"
).format(
hub_name = self.name,
version = pip_attr.python_version,
python_name = python_name,
labels = " \n".join(self._available_interpreters),
))
python_interpreter_target = self._available_interpreters[python_name]
return struct(
target = python_interpreter_target,
path = pip_attr.python_interpreter,
)
def _platforms(*, python_version, minor_mapping, config):
platforms = {}
python_version = version.parse(
full_version(
version = python_version,
minor_mapping = minor_mapping,
),
strict = True,
)
for platform, values in config.platforms.items():
# TODO @aignas 2025-07-07: this is probably doing the parsing of the version too
# many times.
abi = "{}{}{}.{}".format(
python_tag(values.env["implementation_name"]),
python_version.release[0],
python_version.release[1],
python_version.release[2],
)
key = "{}_{}".format(abi, platform)
env_ = env(
env = values.env,
os = values.os_name,
arch = values.arch_name,
python_version = python_version.string,
)
if values.marker and not evaluate(values.marker, env = env_):
continue
platforms[key] = struct(
env = env_,
triple = "{}_{}_{}".format(abi, values.os_name, values.arch_name),
whl_abi_tags = [
v.format(
major = python_version.release[0],
minor = python_version.release[1],
)
for v in values.whl_abi_tags
],
whl_platform_tags = values.whl_platform_tags,
)
return platforms
def _evaluate_markers(self, pip_attr):
if self._evaluate_markers_fn:
return self._evaluate_markers_fn
if self._config.enable_pipstar:
return lambda _, requirements: evaluate_markers_star(
requirements = requirements,
platforms = self._platforms[pip_attr.python_version],
)
interpreter = _detect_interpreter(self, pip_attr)
# NOTE @aignas 2024-08-02: , we will execute any interpreter that we find either
# in the PATH or if specified as a label. We will configure the env
# markers when evaluating the requirement lines based on the output
# from the `requirements_files_by_platform` which should have something
# similar to:
# {
# "//:requirements.txt": ["cp311_linux_x86_64", ...]
# }
#
# We know the target python versions that we need to evaluate the
# markers for and thus we don't need to use multiple python interpreter
# instances to perform this manipulation. This function should be executed
# only once by the underlying code to minimize the overhead needed to
# spin up a Python interpreter.
return lambda module_ctx, requirements: evaluate_markers_py(
module_ctx,
requirements = {
k: {
p: self._platforms[pip_attr.python_version][p].triple
for p in plats
}
for k, plats in requirements.items()
},
python_interpreter = interpreter.path,
python_interpreter_target = interpreter.target,
srcs = pip_attr._evaluate_markers_srcs,
logger = self._logger,
)
def _create_whl_repos(
self,
module_ctx,
*,
pip_attr):
"""create all of the whl repositories
Args:
self: the builder.
module_ctx: {type}`module_ctx`.
pip_attr: {type}`struct` - the struct that comes from the tag class iteration.
"""
logger = self._logger
platforms = self._platforms[pip_attr.python_version]
requirements_by_platform = parse_requirements(
module_ctx,
requirements_by_platform = requirements_files_by_platform(
requirements_by_platform = pip_attr.requirements_by_platform,
requirements_linux = pip_attr.requirements_linux,
requirements_lock = pip_attr.requirements_lock,
requirements_osx = pip_attr.requirements_darwin,
requirements_windows = pip_attr.requirements_windows,
extra_pip_args = pip_attr.extra_pip_args,
platforms = sorted(platforms), # here we only need keys
python_version = full_version(
version = pip_attr.python_version,
minor_mapping = self._minor_mapping,
),
logger = logger,
),
platforms = platforms,
extra_pip_args = pip_attr.extra_pip_args,
get_index_urls = self._get_index_urls.get(pip_attr.python_version),
evaluate_markers = _evaluate_markers(self, pip_attr),
logger = logger,
)
_add_exposed_packages(self, {
whl.name: None
for whl in requirements_by_platform
if whl.is_exposed
})
whl_modifications = {}
if pip_attr.whl_modifications != None:
for mod, whl_name in pip_attr.whl_modifications.items():
whl_modifications[normalize_name(whl_name)] = mod
common_args = _common_args(
self,
module_ctx,
pip_attr = pip_attr,
)
for whl in requirements_by_platform:
whl_library_args = common_args | _whl_library_args(
self,
whl = whl,
whl_modifications = whl_modifications,
)
for src in whl.srcs:
repo = _whl_repo(
src = src,
whl_library_args = whl_library_args,
download_only = pip_attr.download_only,
netrc = self._config.netrc or pip_attr.netrc,
use_downloader = _use_downloader(self, pip_attr.python_version, whl.name),
auth_patterns = self._config.auth_patterns or pip_attr.auth_patterns,
python_version = _major_minor_version(pip_attr.python_version),
is_multiple_versions = whl.is_multiple_versions,
enable_pipstar = self._config.enable_pipstar,
)
_add_whl_library(
self,
python_version = pip_attr.python_version,
whl = whl,
repo = repo,
)
def _common_args(self, module_ctx, *, pip_attr):
interpreter = _detect_interpreter(self, pip_attr)
# Construct args separately so that the lock file can be smaller and does not include unused
# attrs.
whl_library_args = dict(
dep_template = "@{}//{{name}}:{{target}}".format(self.name),
config_load = "@{}//:config.bzl".format(self.name),
)
maybe_args = dict(
# The following values are safe to omit if they have false like values
add_libdir_to_library_search_path = pip_attr.add_libdir_to_library_search_path,
download_only = pip_attr.download_only,
enable_implicit_namespace_pkgs = pip_attr.enable_implicit_namespace_pkgs,
environment = pip_attr.environment,
envsubst = pip_attr.envsubst,
pip_data_exclude = pip_attr.pip_data_exclude,
python_interpreter = interpreter.path,
python_interpreter_target = interpreter.target,
)
if not self._config.enable_pipstar:
maybe_args["experimental_target_platforms"] = pip_attr.experimental_target_platforms
whl_library_args.update({k: v for k, v in maybe_args.items() if v})
maybe_args_with_default = dict(
# The following values have defaults next to them
isolated = (use_isolated(module_ctx, pip_attr), True),
quiet = (pip_attr.quiet, True),
timeout = (pip_attr.timeout, 600),
)
whl_library_args.update({
k: v
for k, (v, default) in maybe_args_with_default.items()
if v != default
})
return whl_library_args
def _whl_library_args(self, *, whl, whl_modifications):
group_name = self._group_name_by_whl.get(whl.name)
group_deps = self._group_map.get(group_name, [])
# Construct args separately so that the lock file can be smaller and does not include unused
# attrs.
whl_library_args = dict(
dep_template = "@{}//{{name}}:{{target}}".format(self.name),
)
maybe_args = dict(
# The following values are safe to omit if they have false like values
annotation = whl_modifications.get(whl.name),
group_deps = group_deps,
group_name = group_name,
whl_patches = {
p: json.encode(args)
for p, args in self._whl_overrides.get(whl.name, {}).items()
},
)
whl_library_args.update({k: v for k, v in maybe_args.items() if v})
return whl_library_args
def _whl_repo(
*,
src,
whl_library_args,
is_multiple_versions,
download_only,
netrc,
auth_patterns,
python_version,
use_downloader,
enable_pipstar = False):
args = dict(whl_library_args)
args["requirement"] = src.requirement_line
is_whl = src.filename.endswith(".whl")
if src.extra_pip_args and not is_whl:
# pip is not used to download wheels and the python
# `whl_library` helpers are only extracting things, however
# for sdists, they will be built by `pip`, so we still
# need to pass the extra args there, so only pop this for whls
args["extra_pip_args"] = src.extra_pip_args
if not src.url or (not is_whl and download_only):
if download_only and use_downloader:
# If the user did not allow using sdists and we are using the downloader
# and we are not using simpleapi_skip for this
return None
else:
# Fallback to a pip-installed wheel
target_platforms = src.target_platforms if is_multiple_versions else []
return struct(
repo_name = pypi_repo_name(
normalize_name(src.distribution),
*target_platforms
),
args = args,
config_setting = whl_config_setting(
version = python_version,
target_platforms = target_platforms or None,
),
)
# This is no-op because pip is not used to download the wheel.
args.pop("download_only", None)
if netrc:
args["netrc"] = netrc
if auth_patterns:
args["auth_patterns"] = auth_patterns
args["urls"] = [src.url]
args["sha256"] = src.sha256
args["filename"] = src.filename
if not enable_pipstar:
args["experimental_target_platforms"] = [
# Get rid of the version for the target platforms because we are
# passing the interpreter any way. Ideally we should search of ways
# how to pass the target platforms through the hub repo.
p.partition("_")[2]
for p in src.target_platforms
]
return struct(
repo_name = whl_repo_name(src.filename, src.sha256),
args = args,
config_setting = whl_config_setting(
version = python_version,
target_platforms = src.target_platforms,
),
)
def _use_downloader(self, python_version, whl_name):
return self._use_downloader.get(python_version, {}).get(
normalize_name(whl_name),
self._get_index_urls.get(python_version) != None,
)