blob: fe0911e3ea2b4f9bdf1d52679918c6798e54dc27 [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.
"""
EXPERIMENTAL: This is experimental and may be removed without notice
A module extension for working with uv.
"""
load("//python/private:auth.bzl", "AUTH_ATTRS", "get_auth")
load("//python/private:common_labels.bzl", "labels")
load(":toolchain_types.bzl", "UV_TOOLCHAIN_TYPE")
load(":uv_repository.bzl", "uv_repository")
load(":uv_toolchains_repo.bzl", "uv_toolchains_repo")
_DOC = """\
A module extension for working with uv.
Basic usage:
```starlark
uv = use_extension(
"@rules_python//python/uv:uv.bzl",
"uv",
# Use `dev_dependency` so that the toolchains are not defined pulled when
# your module is used elsewhere.
dev_dependency = True,
)
uv.configure(version = "0.5.24")
```
Since this is only for locking the requirements files, it should be always
marked as a `dev_dependency`.
"""
_DEFAULT_ATTRS = {
"base_url": attr.string(
doc = """\
Base URL to download metadata about the binaries and the binaries themselves.
""",
),
"compatible_with": attr.label_list(
doc = """\
The compatible with constraint values for toolchain resolution.
""",
),
"manifest_filename": attr.string(
doc = """\
The distribution manifest filename to use for the metadata fetching from GH. The
defaults for this are set in `rules_python` MODULE.bazel file that one can override
for a specific version.
""",
default = "dist-manifest.json",
),
"platform": attr.string(
doc = """\
The platform string used in the UV repository to denote the platform triple.
""",
),
"target_settings": attr.label_list(
doc = """\
The `target_settings` to add to platform definitions that then get used in `toolchain`
definitions.
""",
),
"version": attr.string(
doc = """\
The version of uv to configure the sources for. If this is not specified it will be the
last version used in the module or the default version set by `rules_python`.
""",
),
} | AUTH_ATTRS
default = tag_class(
doc = """\
Set the uv configuration defaults.
""",
attrs = _DEFAULT_ATTRS,
)
configure = tag_class(
doc = """\
Build the `uv` toolchain configuration by appending the provided configuration.
The information is appended to the version configuration that is specified by
{attr}`version` attribute, or if the version is unspecified, the version of the
last {obj}`uv.configure` call in the current module, or the version from the
defaults is used.
Complex configuration example:
```starlark
# Configure the base_url for the default version.
uv.configure(base_url = "my_mirror")
# Add an extra platform that can be used with your version.
uv.configure(
platform = "extra-platform",
target_settings = ["//my_config_setting_label"],
compatible_with = ["@platforms//os:exotic"],
)
# Add an extra platform that can be used with your version.
uv.configure(
platform = "patched-binary",
target_settings = ["//my_super_config_setting"],
urls = ["https://example.zip"],
sha256 = "deadbeef",
)
```
""",
attrs = _DEFAULT_ATTRS | {
"sha256": attr.string(
doc = "The sha256 of the downloaded artifact if the {attr}`urls` is specified.",
),
"urls": attr.string_list(
doc = """\
The urls to download the binary from. If this is used, {attr}`base_url` and
{attr}`manifest_filename` are ignored for the given version.
::::note
If the `urls` are specified, they need to be specified for all of the platforms
for a particular version.
::::
""",
),
},
)
def _configure(config, *, platform, compatible_with, target_settings, auth_patterns, urls = [], sha256 = "", override = False, **values):
"""Set the value in the config if the value is provided"""
for key, value in values.items():
if not value:
continue
if not override and config.get(key):
continue
config[key] = value
config.setdefault("auth_patterns", {}).update(auth_patterns)
config.setdefault("platforms", {})
if not platform:
if compatible_with or target_settings or urls:
fail("`platform` name must be specified when specifying `compatible_with`, `target_settings` or `urls`")
elif compatible_with or target_settings:
if not override and config.get("platforms", {}).get(platform):
return
config["platforms"][platform] = struct(
name = platform.replace("-", "_").lower(),
compatible_with = compatible_with,
target_settings = target_settings,
)
elif urls:
if not override and config.get("urls", {}).get(platform):
return
config.setdefault("urls", {})[platform] = struct(
sha256 = sha256,
urls = urls,
)
else:
config["platforms"].pop(platform)
def process_modules(
module_ctx,
hub_name = "uv",
uv_repository = uv_repository,
toolchain_type = str(UV_TOOLCHAIN_TYPE),
hub_repo = uv_toolchains_repo,
get_auth = get_auth):
"""Parse the modules to get the config for 'uv' toolchains.
Args:
module_ctx: the context.
hub_name: the name of the hub repository.
uv_repository: the rule to create a uv_repository override.
toolchain_type: the toolchain type to use here.
hub_repo: the hub repo factory function to use.
get_auth: the auth function to use.
Returns:
the result of the hub_repo. Mainly used for tests.
"""
# default values to apply for version specific config
defaults = {
"base_url": "",
"manifest_filename": "",
"platforms": {
# The structure is as follows:
# "platform_name": struct(
# compatible_with = [],
# target_settings = [],
# ),
#
# NOTE: urls and sha256 cannot be set in defaults
},
"version": "",
}
for mod in module_ctx.modules:
if not (mod.is_root or mod.name == "rules_python"):
continue
for tag in mod.tags.default:
_configure(
defaults,
version = tag.version,
base_url = tag.base_url,
manifest_filename = tag.manifest_filename,
platform = tag.platform,
compatible_with = tag.compatible_with,
target_settings = tag.target_settings,
override = mod.is_root,
netrc = tag.netrc,
auth_patterns = tag.auth_patterns,
)
for key in [
"version",
"manifest_filename",
"platforms",
]:
if not defaults.get(key, None):
fail("defaults need to be set for '{}'".format(key))
# resolved per-version configuration. The shape is something like:
# versions = {
# "1.0.0": {
# "base_url": "",
# "manifest_filename": "",
# "platforms": {
# "platform_name": struct(
# compatible_with = [],
# target_settings = [],
# urls = [], # can be unset
# sha256 = "", # can be unset
# ),
# },
# },
# }
versions = {}
for mod in module_ctx.modules:
if not (mod.is_root or mod.name == "rules_python"):
continue
# last_version is the last version used in the MODULE.bazel or the default
last_version = None
for tag in mod.tags.configure:
last_version = tag.version or last_version or defaults["version"]
specific_config = versions.setdefault(
last_version,
{
"base_url": defaults["base_url"],
"manifest_filename": defaults["manifest_filename"],
# shallow copy is enough as the values are structs and will
# be replaced on modification
"platforms": dict(defaults["platforms"]),
},
)
_configure(
specific_config,
base_url = tag.base_url,
manifest_filename = tag.manifest_filename,
platform = tag.platform,
compatible_with = tag.compatible_with,
target_settings = tag.target_settings,
sha256 = tag.sha256,
urls = tag.urls,
override = mod.is_root,
netrc = tag.netrc,
auth_patterns = tag.auth_patterns,
)
if not versions:
return hub_repo(
name = hub_name,
toolchain_type = toolchain_type,
toolchain_names = ["none"],
toolchain_implementations = {
# NOTE @aignas 2025-02-24: the label to the toolchain can be anything
"none": labels.NONE,
},
toolchain_compatible_with = {
"none": ["@platforms//:incompatible"],
},
toolchain_target_settings = {},
)
toolchain_names = []
toolchain_implementations = {}
toolchain_compatible_with_by_toolchain = {}
toolchain_target_settings = {}
for version, config in versions.items():
platforms = config["platforms"]
# Use the manually specified urls
urls = {
platform: src
for platform, src in config.get("urls", {}).items()
if src.urls
}
auth = {
"auth_patterns": config.get("auth_patterns"),
"netrc": config.get("netrc"),
}
auth = {k: v for k, v in auth.items() if v}
# Or fallback to fetching them from GH manifest file
# Example file: https://github.com/astral-sh/uv/releases/download/0.6.3/dist-manifest.json
if not urls:
urls = _get_tool_urls_from_dist_manifest(
module_ctx,
base_url = "{base_url}/{version}".format(
version = version,
base_url = config["base_url"],
),
manifest_filename = config["manifest_filename"],
platforms = sorted(platforms),
get_auth = get_auth,
**auth
)
for platform_name, platform in platforms.items():
if platform_name not in urls:
continue
toolchain_name = "{}_{}".format(version.replace(".", "_"), platform_name.lower().replace("-", "_"))
uv_repository_name = "{}_{}".format(hub_name, toolchain_name)
uv_repository(
name = uv_repository_name,
version = version,
platform = platform_name,
urls = urls[platform_name].urls,
sha256 = urls[platform_name].sha256,
**auth
)
toolchain_names.append(toolchain_name)
toolchain_implementations[toolchain_name] = "@{}//:uv_toolchain".format(uv_repository_name)
toolchain_compatible_with_by_toolchain[toolchain_name] = [
str(label)
for label in platform.compatible_with
]
if platform.target_settings:
toolchain_target_settings[toolchain_name] = [
str(label)
for label in platform.target_settings
]
return hub_repo(
name = hub_name,
toolchain_type = toolchain_type,
toolchain_names = toolchain_names,
toolchain_implementations = toolchain_implementations,
toolchain_compatible_with = toolchain_compatible_with_by_toolchain,
toolchain_target_settings = toolchain_target_settings,
)
def _uv_toolchain_extension(module_ctx):
process_modules(
module_ctx,
hub_name = "uv",
)
def _overlap(first_collection, second_collection):
for x in first_collection:
if x in second_collection:
return True
return False
def _get_tool_urls_from_dist_manifest(module_ctx, *, base_url, manifest_filename, platforms, get_auth = get_auth, **auth_attrs):
"""Download the results about remote tool sources.
This relies on the tools using the cargo packaging to infer the actual
sha256 values for each binary.
Example manifest url: https://github.com/astral-sh/uv/releases/download/0.6.5/dist-manifest.json
The example format is as below
dist_version "0.28.0"
announcement_tag "0.6.5"
announcement_tag_is_implicit false
announcement_is_prerelease false
announcement_title "0.6.5"
announcement_changelog "text"
announcement_github_body "MD text"
releases [
{
app_name "uv"
app_version "0.6.5"
env
install_dir_env_var "UV_INSTALL_DIR"
unmanaged_dir_env_var "UV_UNMANAGED_INSTALL"
disable_update_env_var "UV_DISABLE_UPDATE"
no_modify_path_env_var "UV_NO_MODIFY_PATH"
github_base_url_env_var "UV_INSTALLER_GITHUB_BASE_URL"
ghe_base_url_env_var "UV_INSTALLER_GHE_BASE_URL"
display_name "uv"
display true
artifacts [
"source.tar.gz"
"source.tar.gz.sha256"
"uv-installer.sh"
"uv-installer.ps1"
"sha256.sum"
"uv-aarch64-apple-darwin.tar.gz"
"uv-aarch64-apple-darwin.tar.gz.sha256"
"...
]
artifacts
uv-aarch64-apple-darwin.tar.gz
name "uv-aarch64-apple-darwin.tar.gz"
kind "executable-zip"
target_triples [
"aarch64-apple-darwin"
assets [
{
id "uv-aarch64-apple-darwin-exe-uv"
name "uv"
path "uv"
kind "executable"
},
{
id "uv-aarch64-apple-darwin-exe-uvx"
name "uvx"
path "uvx"
kind "executable"
}
]
checksum "uv-aarch64-apple-darwin.tar.gz.sha256"
uv-aarch64-apple-darwin.tar.gz.sha256
name "uv-aarch64-apple-darwin.tar.gz.sha256"
kind "checksum"
target_triples [
"aarch64-apple-darwin"
]
"""
auth_attr = struct(**auth_attrs)
dist_manifest = module_ctx.path(manifest_filename)
urls = [base_url + "/" + manifest_filename]
result = module_ctx.download(
url = urls,
output = dist_manifest,
auth = get_auth(module_ctx, urls, ctx_attr = auth_attr),
)
if not result.success:
fail(result)
dist_manifest = json.decode(module_ctx.read(dist_manifest))
artifacts = dist_manifest["artifacts"]
tool_sources = {}
downloads = {}
for fname, artifact in artifacts.items():
if artifact.get("kind") != "executable-zip":
continue
checksum = artifacts[artifact["checksum"]]
if not _overlap(checksum["target_triples"], platforms):
# we are not interested in this platform, so skip
continue
checksum_fname = checksum["name"]
checksum_path = module_ctx.path(checksum_fname)
urls = ["{}/{}".format(base_url, checksum_fname)]
downloads[checksum_path] = struct(
download = module_ctx.download(
url = urls,
output = checksum_path,
block = False,
auth = get_auth(module_ctx, urls, ctx_attr = auth_attr),
),
archive_fname = fname,
platforms = checksum["target_triples"],
)
for checksum_path, download in downloads.items():
result = download.download.wait()
if not result.success:
fail(result)
archive_fname = download.archive_fname
sha256, _, checksummed_fname = module_ctx.read(checksum_path).partition(" ")
checksummed_fname = checksummed_fname.strip(" *\n")
if checksummed_fname and archive_fname != checksummed_fname:
fail("The checksum is for a different file, expected '{}' but got '{}'".format(
archive_fname,
checksummed_fname,
))
for platform in download.platforms:
tool_sources[platform] = struct(
urls = ["{}/{}".format(base_url, archive_fname)],
sha256 = sha256,
)
return tool_sources
uv = module_extension(
doc = _DOC,
implementation = _uv_toolchain_extension,
tag_classes = {
"configure": configure,
"default": default,
},
)