From 25ad57c85f0094889520486cd9a5738278fd0541 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:47:40 +0900 Subject: [PATCH 001/179] fix(uv): allow environment setting for the update action (#3800) With this PR we allow users to set extra environment variables using `.bazelrc` and to ensure that we can pass UV extra parameters. This should enable users use this with private indexes in a `diff_test` usage scenario. Whilst at it, add integration tests to actually verify that passing works via env vars. Whilst at it also fix the Windows support for the `uv` lock rule. Fixes #3405 --- .../scripts/get_buildkite_results.py | 38 ++- .bazelignore | 1 + .bazelrc.deleted_packages | 1 + .gitattributes | 1 + .gitignore | 4 + CHANGELOG.md | 6 + python/uv/private/lock.bat | 14 +- python/uv/private/lock.bzl | 95 +++++- python/uv/private/lock_copier.py | 2 +- tests/integration/BUILD.bazel | 36 +++ tests/integration/integration_test.bzl | 7 +- tests/integration/uv_lock/.bazelrc | 5 + tests/integration/uv_lock/.bazelversion | 1 + tests/integration/uv_lock/BUILD.bazel | 26 ++ tests/integration/uv_lock/MODULE.bazel | 18 ++ tests/integration/uv_lock/README.md | 64 ++++ tests/integration/uv_lock/WORKSPACE | 0 tests/integration/uv_lock/requirements.in | 1 + tests/integration/uv_lock/requirements.txt | 5 + tests/integration/uv_lock/uv_runner.bzl | 30 ++ tests/integration/uv_lock_pypi_server.py | 148 +++++++++ tests/integration/uv_lock_test.py | 288 ++++++++++++++++++ tests/uv/lock/lock_run_test.py | 87 ++++-- tests/uv/lock/lock_tests.bzl | 21 +- 24 files changed, 825 insertions(+), 74 deletions(-) create mode 100644 tests/integration/uv_lock/.bazelrc create mode 100644 tests/integration/uv_lock/.bazelversion create mode 100644 tests/integration/uv_lock/BUILD.bazel create mode 100644 tests/integration/uv_lock/MODULE.bazel create mode 100644 tests/integration/uv_lock/README.md create mode 100644 tests/integration/uv_lock/WORKSPACE create mode 100644 tests/integration/uv_lock/requirements.in create mode 100644 tests/integration/uv_lock/requirements.txt create mode 100644 tests/integration/uv_lock/uv_runner.bzl create mode 100644 tests/integration/uv_lock_pypi_server.py create mode 100644 tests/integration/uv_lock_test.py diff --git a/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py index e1fc635cf6..06117fbd7c 100755 --- a/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py +++ b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py @@ -94,24 +94,32 @@ def fetch_buildkite_data(build_url): def download_log(job_url, output_path): - # Construct raw log URL: job_url + "/raw" (Buildkite convention) - # job_url e.g. https://buildkite.com/org/pipeline/builds/14394#job-id - # Wait, the job['path'] gives /org/pipeline/builds/14394#job-id - # We want /org/pipeline/builds/14394/jobs/job-id/raw? No - # The clean URL for a job is https://buildkite.com/org/pipeline/builds/14394/jobs/job-id - # And raw log is https://buildkite.com/org/pipeline/builds/14394/jobs/job-id/raw - - # We have full_url e.g. https://buildkite.com/bazel/rules-python-python/builds/14394#019c5cf9-e3cf-468f-a7b1-8f9f5ad4b08c - # We need to transform it. + # job_url looks like: + # https://buildkite.com/bazel/rules-python-python/builds/15594#019e879b-... + # We need to transform it to: + # https://buildkite.com/organizations/bazel/pipelines/rules-python-python/builds/15594/jobs/{job_id}/download.txt if "#" in job_url: base, job_id = job_url.split("#") - # Ensure base doesn't end with / - if base.endswith("/"): - base = base[:-1] - - # Build raw URL - raw_url = f"{base}/jobs/{job_id}/raw" + base = base.rstrip("/") + + # Parse the path segments: https://buildkite.com/org/pipeline/builds/N + # Rebuild with the /organizations/org/pipelines/pipeline/ format which + # supports the /jobs/{id}/download.txt log URL without auth. + parts = base.split("/") + # parts = ["https:", "", "buildkite.com", "org", "pipeline", "builds", "N"] + if len(parts) >= 7 and parts[2] == "buildkite.com": + org = parts[3] + pipeline = parts[4] + build_num = parts[6] if len(parts) >= 7 else "" + raw_url = ( + f"https://buildkite.com/organizations/{org}" + f"/pipelines/{pipeline}" + f"/builds/{build_num}" + f"/jobs/{job_id}/download.txt" + ) + else: + raw_url = f"{base}/jobs/{job_id}/download.txt" else: print(f"Could not parse job URL for download: {job_url}", file=sys.stderr) return False diff --git a/.bazelignore b/.bazelignore index afd162998a..2cf1523aef 100644 --- a/.bazelignore +++ b/.bazelignore @@ -35,3 +35,4 @@ tests/integration/compile_pip_requirements/bazel-compile_pip_requirements tests/integration/local_toolchains/bazel-local_toolchains tests/integration/py_cc_toolchain_registered/bazel-py_cc_toolchain_registered tests/integration/toolchain_target_settings/bazel-module_under_test +tests/integration/uv_lock/bazel-uv_lock diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index f4ea8527f3..7256937a87 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -39,6 +39,7 @@ common --deleted_packages=tests/integration/pip_parse/empty common --deleted_packages=tests/integration/pip_parse_isolated common --deleted_packages=tests/integration/py_cc_toolchain_registered common --deleted_packages=tests/integration/toolchain_target_settings +common --deleted_packages=tests/integration/uv_lock common --deleted_packages=tests/modules/another_module common --deleted_packages=tests/modules/other common --deleted_packages=tests/modules/other/nspkg_delta diff --git a/.gitattributes b/.gitattributes index eae260e931..9905cbfacb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ python/features.bzl export-subst tools/publish/*.txt linguist-generated=true +tests/uv/lock/testdata/requirements.txt text eol=lf diff --git a/.gitignore b/.gitignore index fb1b17e466..efce592aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ user.bazelrc # MODULE.bazel.lock is ignored for now as per recommendation from upstream. # See https://github.com/bazelbuild/bazel/issues/20369 MODULE.bazel.lock + +# Buildkite logs +*Windows*.log + diff --git a/CHANGELOG.md b/CHANGELOG.md index e57e0969d3..33adfa7a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,12 @@ END_UNRELEASED_TEMPLATE PyPI download and supports PyPI mirror implementations that do not support the root index functionality. Fixes ([#3769](https://github.com/bazel-contrib/rules_python/pull/3769)). +* (uv) allow user overwrite the build environment using `--action_env` to allow + setting authentication for the index URL. + ([#3405](https://github.com/bazel-contrib/rules_python/issues/3405)) +* (uv) fix the execution of the `uv pip compile` in the sandbox. Work + towards better supporting `uv` out of the box on our platforms. + ([#1975](https://github.com/bazel-contrib/rules_python/issues/1975)) {#v0-0-0-added} ### Added diff --git a/python/uv/private/lock.bat b/python/uv/private/lock.bat index 3954c10347..5190ddf9eb 100755 --- a/python/uv/private/lock.bat +++ b/python/uv/private/lock.bat @@ -1,7 +1,7 @@ -if defined BUILD_WORKSPACE_DIRECTORY ( - set "out=%BUILD_WORKSPACE_DIRECTORY%\{{src_out}}" -) else ( - exit /b 1 -) - -"{{args}}" --output-file "%out%" %* +if defined BUILD_WORKSPACE_DIRECTORY ( + set "out=%BUILD_WORKSPACE_DIRECTORY%\{{src_out}}" +) else ( + exit /b 1 +) + +"{{args}}" --output-file "%out%" %* diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index b007baf9c1..6f0b80af89 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -117,31 +117,96 @@ def _lock_impl(ctx): args.run_shell.add("--no-progress") args.run_shell.add("--quiet") + # Generate a wrapper script that copies the existing output (if any) and + # then runs uv. On POSIX, args are forwarded via exec "$@". On Windows, + # the full command line is embedded in the .bat file with backslash paths + # (CMD doesn't recognize forward slashes in executable paths). + if ctx.attr.is_windows: + ext = ".bat" + lines = ["@echo off"] + else: + ext = ".sh" + lines = ["#!/usr/bin/env bash", "set -euo pipefail"] + + python_path = getattr(python, "path", python) + if ctx.files.existing_output: - command = '{python} -c {python_cmd} && "$@"'.format( - python = getattr(python, "path", python), - python_cmd = shell.quote( - "from shutil import copy; copy(\"{src}\", \"{dst}\")".format( + python_cmd = "from shutil import copy; copy(\"{src}\", \"{dst}\")".format( + src = ctx.files.existing_output[0].path, + dst = output.path, + ) + if ctx.attr.is_windows: + # In batch files, use "" to escape internal double quotes. + lines.append( + "\"{py}\" -c \"from shutil import copy; copy(\"\"{src}\"\", \"\"{dst}\"\")\"".format( + py = python_path, src = ctx.files.existing_output[0].path, dst = output.path, ), + ) + else: + lines.append("{py} -c '{cmd}'".format( + py = python_path, + cmd = python_cmd, + )) + + if ctx.attr.is_windows: + # Build the command line with backslash paths for CMD. + # args.run_info has most args; add the output/progress/quiet + # args that were only added directly to args.run_shell. + def _quote(arg): + if hasattr(arg, "path"): + arg = arg.path.replace("/", "\\") + else: + arg = str(arg) + return '"' + arg.replace('"', '""') + '"' + + bat_args = args.run_info + [ + "--output-file", + output, + "--no-progress", + "--quiet", + ] + lines.append(" ".join([_quote(a) for a in bat_args])) + + # Normalize CRLF line endings in the output on Windows. + lines.append( + "\"{py}\" -c \"import pathlib;p=pathlib.Path(r\"\"{dst}\"\");p.write_bytes(p.read_bytes().replace(b'\\r\\n', b'\\n'))\"".format( + py = python_path, + dst = output.path, ), ) else: - command = '"$@"' + lines.append('exec "$@"') + + script = ctx.actions.declare_file(ctx.label.name + "_lock" + ext) + if ctx.attr.is_windows: + content = "\r\n".join(lines) + "\r\n" + else: + content = "\n".join(lines) + "\n" + ctx.actions.write(output = script, content = content, is_executable = True) srcs = srcs + ctx.files.build_constraints + ctx.files.constraints - ctx.actions.run_shell( - command = command, + ctx.actions.run( + executable = script, inputs = srcs + ctx.files.existing_output, mnemonic = "PyRequirementsLockUv", outputs = [output], - arguments = [args.run_shell], + # On Windows, the command line is embedded directly in the .bat + # script (with backslash paths). On POSIX, args are forwarded via + # exec "$@" in the .sh script. + arguments = [args.run_shell] if not ctx.attr.is_windows else [], tools = [ uv, python_files, + script, ], + # User reported being unable to add `--action_env` and get it to work. + # Without this flag. + # + # Ref: https://app.slack.com/client/TA4K1KQ87/CA306CEV6 + use_default_shell_env = True, progress_message = "Creating a requirements.txt with uv: %{label}", env = ctx.attr.env, ) @@ -205,6 +270,7 @@ modifications and the locking is not done from scratch. doc = "Public, see the docs in the macro.", default = True, ), + "is_windows": attr.bool(mandatory = True), "output": attr.string( doc = "Public, see the docs in the macro.", mandatory = True, @@ -241,7 +307,7 @@ The string to input for the 'uv pip compile'. def _lock_run_impl(ctx): if ctx.attr.is_windows: path_sep = "\\" - ext = ".exe" + ext = ".bat" else: path_sep = "/" ext = "" @@ -250,7 +316,12 @@ def _lock_run_impl(ctx): if hasattr(arg, "short_path"): arg = arg.short_path - return shell.quote(arg.replace("/", path_sep)) + arg = arg.replace("/", path_sep) + if ctx.attr.is_windows: + # On Windows, CMD uses double quotes for quoting, and internal + # double quotes are escaped by doubling them. + return '"' + arg.replace('"', '""') + '"' + return shell.quote(arg) info = ctx.attr.lock[_RunLockInfo] executable = ctx.actions.declare_file(ctx.label.name + ext) @@ -438,6 +509,10 @@ def lock( env = env, existing_output = maybe_out, generate_hashes = generate_hashes, + is_windows = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), python_version = python_version, srcs = srcs, strip_extras = strip_extras, diff --git a/python/uv/private/lock_copier.py b/python/uv/private/lock_copier.py index bcc64c1661..8756fc4de6 100644 --- a/python/uv/private/lock_copier.py +++ b/python/uv/private/lock_copier.py @@ -55,7 +55,7 @@ def main(): "This must be either run as `bazel test` via a `native_test` or similar or via `bazel run`" ) - print(f"cp /{src} /{dst}") + print(f"cp /{src.as_posix()} /{dst}") build_workspace = Path(environ["BUILD_WORKSPACE_DIRECTORY"]) dst_real_path = build_workspace / dst diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel index 9295cbb22f..a6027fc3d4 100644 --- a/tests/integration/BUILD.bazel +++ b/tests/integration/BUILD.bazel @@ -13,7 +13,9 @@ # limitations under the License. load("@rules_bazel_integration_test//bazel_integration_test:defs.bzl", "default_test_runner") +load("//python:py_binary.bzl", "py_binary") load("//python:py_library.bzl", "py_library") +load("//tests/support:support.bzl", "NOT_WINDOWS") load(":integration_test.bzl", "rules_python_integration_test") licenses(["notice"]) @@ -48,6 +50,7 @@ test_suite( tests = [ "bzlmod_lockfile_test_bazel_9.1.0", "local_toolchains_test_bazel_self", + "uv_lock_test_bazel_self", ], ) @@ -111,8 +114,41 @@ rules_python_integration_test( py_main = "toolchain_target_settings_test.py", ) +rules_python_integration_test( + name = "uv_lock_test", + py_deps = [ + "@pypiserver//pypiserver", + ":uv_lock_pypi_server_lib", + ], + py_main = "uv_lock_test.py", +) + py_library( name = "runner_lib", srcs = ["runner.py"], imports = ["../../"], ) + +py_library( + name = "uv_lock_pypi_server_lib", + srcs = ["uv_lock_pypi_server.py"], + imports = ["../../"], + # currently windows is not working due to + # https://github.com/pypiserver/pypiserver/blob/main/pypiserver/config.py#L123 + # + # class DEFAULTS: + # .... + # PACKAGE_DIRECTORIES = [pathlib.Path("~/packages").expanduser().resolve()] + # .... + # + # which is loaded through `__init__.py` even though it is not used and breaks because + # in a Windows sandbox one cannot resolve the home directory. + target_compatible_with = NOT_WINDOWS, + deps = ["@pypiserver//pypiserver"], +) + +py_binary( + name = "uv_lock_pypi_server", + srcs = ["uv_lock_pypi_server.py"], + deps = [":uv_lock_pypi_server_lib"], +) diff --git a/tests/integration/integration_test.bzl b/tests/integration/integration_test.bzl index 771976d037..f3d5cb6967 100644 --- a/tests/integration/integration_test.bzl +++ b/tests/integration/integration_test.bzl @@ -21,14 +21,14 @@ load( ) load("//python:py_test.bzl", "py_test") -def _test_runner(*, name, bazel_version, py_main, bzlmod): +def _test_runner(*, name, bazel_version, py_main, bzlmod, py_deps): if py_main: test_runner = "{}_bazel_{}_py_runner".format(name, bazel_version) py_test( name = test_runner, srcs = [py_main], main = py_main, - deps = [":runner_lib"], + deps = [":runner_lib"] + py_deps, # Hide from ... patterns; should only be run as part # of the bazel integration test tags = ["manual"], @@ -46,6 +46,7 @@ def rules_python_integration_test( bzlmod = True, tags = None, py_main = None, + py_deps = None, bazel_versions = None, **kwargs): """Runs a bazel-in-bazel integration test. @@ -60,6 +61,7 @@ def rules_python_integration_test( py_main: Optional `.py` file to run tests using. When specified, a python based test runner is used, and this source file is the main entry point and responsible for executing tests. + py_deps: Optional test runner deps to use for setup. bazel_versions: `list[str] | None`, the bazel versions to test. I not specified, defaults to all configured bazel versions. **kwargs: Passed to the upstream `bazel_integration_tests` rule. @@ -91,6 +93,7 @@ def rules_python_integration_test( name = name, bazel_version = bazel_version, py_main = py_main, + py_deps = py_deps or [], bzlmod = bzlmod, ) bazel_integration_test( diff --git a/tests/integration/uv_lock/.bazelrc b/tests/integration/uv_lock/.bazelrc new file mode 100644 index 0000000000..511f8a2413 --- /dev/null +++ b/tests/integration/uv_lock/.bazelrc @@ -0,0 +1,5 @@ +build --enable_runfiles +common --experimental_isolated_extension_usages +common --action_env=UV_EXTRA_INDEX_URL + +try-import %workspace%/user.bazelrc diff --git a/tests/integration/uv_lock/.bazelversion b/tests/integration/uv_lock/.bazelversion new file mode 100644 index 0000000000..47da986f86 --- /dev/null +++ b/tests/integration/uv_lock/.bazelversion @@ -0,0 +1 @@ +9.1.0 diff --git a/tests/integration/uv_lock/BUILD.bazel b/tests/integration/uv_lock/BUILD.bazel new file mode 100644 index 0000000000..da6482ba08 --- /dev/null +++ b/tests/integration/uv_lock/BUILD.bazel @@ -0,0 +1,26 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@rules_python//python/uv:lock.bzl", "lock") +load(":uv_runner.bzl", "uv_runner") + +lock( + name = "requirements", + srcs = ["requirements.in"], + out = "requirements.txt", + tags = ["no-remote-exec"], +) + +uv_runner( + name = "uv", + is_windows = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), + tags = ["manual"], +) + +diff_test( + name = "requirements_diff_test", + timeout = "short", + file1 = ":requirements", + file2 = ":requirements.txt", +) diff --git a/tests/integration/uv_lock/MODULE.bazel b/tests/integration/uv_lock/MODULE.bazel new file mode 100644 index 0000000000..0e0978ed36 --- /dev/null +++ b/tests/integration/uv_lock/MODULE.bazel @@ -0,0 +1,18 @@ +module(name = "uv_lock") + +bazel_dep(name = "bazel_skylib", version = "1.7.1") +bazel_dep(name = "platforms", version = "0.0.10") +bazel_dep(name = "rules_python") +local_path_override( + module_name = "rules_python", + path = "../../..", +) + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain(python_version = "3.13") + +uv = use_extension("@rules_python//python/uv:uv.bzl", "uv") +uv.configure(version = "0.11.2") +use_repo(uv, "uv") + +register_toolchains("@uv//:all") diff --git a/tests/integration/uv_lock/README.md b/tests/integration/uv_lock/README.md new file mode 100644 index 0000000000..cce142852c --- /dev/null +++ b/tests/integration/uv_lock/README.md @@ -0,0 +1,64 @@ +# uv_lock integration test workspace + +This directory is a self-contained Bazel workspace used by the +`//tests/integration:uv_lock_test` integration test. + +It demonstrates how to use the `lock()` macro from `@rules_python//python/uv` +to pin requirements with `uv pip compile`. + +## Targets + +| Target | Description | +|--------|-------------| +| `//:requirements` | Build action that produces the locked requirements file | +| `//:requirements.update` | Update the in-source `requirements.txt` via `bazel run` | +| `//:requirements.run` | Run `uv pip compile` with extra command-line args | +| `//:requirements_diff_test` | Diff test comparing the lock output to the in-source file | +| `//:uv` | The `uv` binary from the registered toolchain | + +## Workflow for debugging + +If you want to debug and play around, you can start the server and then run the uv lock command +manually. + +### Start the local PyPI server + +In a separate terminal, start the pypiserver that serves the `my-local-pkg` test wheel: + +```shell +bazel run //tests/integration:uv_lock_pypi_server [-- --no-auth] +``` + +The server prints the URL to use (with and without authentication) and the +SHA256 of the wheel. Pass `--no-auth` to allow anonymous access. + +### Lock the requirements + +With the server running, lock the requirements from this directory: + +```shell +cd tests/integration/uv_lock +bazel run //:requirements.update \ + --action_env=UV_EXTRA_INDEX_URL="" \ + --action_env=UV_CREDENTIALS_DIR= +``` + +The `` and `` values are printed by the pypi-server. + +### Verify the lock output matches the in-source file + +```shell +bazel test //:requirements_diff_test +``` + +## bazel-in-bazel Testing + +When iterating on changes to `lock.bzl`, the integration test can be run +directly from rules_python: + +```shell +bazel test //tests/integration:uv_lock_test_bazel_self \ + --config=fast-tests \ + --test_output=streamed \ + --test_filter= +``` diff --git a/tests/integration/uv_lock/WORKSPACE b/tests/integration/uv_lock/WORKSPACE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/uv_lock/requirements.in b/tests/integration/uv_lock/requirements.in new file mode 100644 index 0000000000..fba55a7329 --- /dev/null +++ b/tests/integration/uv_lock/requirements.in @@ -0,0 +1 @@ +my-local-pkg==1.0.0 diff --git a/tests/integration/uv_lock/requirements.txt b/tests/integration/uv_lock/requirements.txt new file mode 100644 index 0000000000..52f5c757f5 --- /dev/null +++ b/tests/integration/uv_lock/requirements.txt @@ -0,0 +1,5 @@ +# This file was autogenerated by uv via the following command: +# bazel run //:requirements.update +my-local-pkg==1.0.0 \ + --hash=sha256:be24d5183a182e8da4465ae1b7e60324864d1a72866ec9aefa6aaf80a4529eb1 + # via -r requirements.in diff --git a/tests/integration/uv_lock/uv_runner.bzl b/tests/integration/uv_lock/uv_runner.bzl new file mode 100644 index 0000000000..3faa3c6fc0 --- /dev/null +++ b/tests/integration/uv_lock/uv_runner.bzl @@ -0,0 +1,30 @@ +"""A rule exposing the uv binary from the registered toolchain as an executable target. + +This allows running ``bazel run //:uv -- `` from the test workspace. +""" + +def _uv_runner_impl(ctx): + toolchain_info = ctx.toolchains["@rules_python//python/uv:uv_toolchain_type"] + original_uv_executable = toolchain_info.uv_toolchain_info.uv[DefaultInfo].files_to_run.executable + + ext = "" + if ctx.attr.is_windows: + ext = ".exe" + + uv_exe = ctx.actions.declare_file("uv" + ext) + ctx.actions.symlink(output = uv_exe, target_file = original_uv_executable) + + return DefaultInfo( + files = depset([uv_exe]), + executable = uv_exe, + runfiles = toolchain_info.default_info.default_runfiles, + ) + +uv_runner = rule( + implementation = _uv_runner_impl, + executable = True, + attrs = { + "is_windows": attr.bool(mandatory = True), + }, + toolchains = ["@rules_python//python/uv:uv_toolchain_type"], +) diff --git a/tests/integration/uv_lock_pypi_server.py b/tests/integration/uv_lock_pypi_server.py new file mode 100644 index 0000000000..0d940e7569 --- /dev/null +++ b/tests/integration/uv_lock_pypi_server.py @@ -0,0 +1,148 @@ +import argparse +import hashlib +import io +import os +import sys +import uuid +import zipfile +from wsgiref.simple_server import make_server + +from pypiserver import app_from_config, setup_routes_from_config +from pypiserver.config import Config + + +def _create_wheel_bytes(name, version): + pkg_name_normalized = name.replace("-", "_") + wheel_name = "{}-{}-py3-none-any.whl".format(pkg_name_normalized, version) + dist_info = "{}-{}.dist-info".format(pkg_name_normalized, version) + + metadata = ( + "Metadata-Version: 2.1\n" + "Name: {name}\n" + "Version: {version}\n" + "Summary: A test package\n" + ).format(name=pkg_name_normalized, version=version) + + wheel_file = ( + "Wheel-Version: 1.0\n" + "Generator: test\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n" + ) + + record_entries = [ + "{}/__init__.py,".format(pkg_name_normalized), + "{}/METADATA,".format(dist_info), + "{}/WHEEL,".format(dist_info), + "{}/RECORD,".format(dist_info), + ] + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("{}/__init__.py".format(pkg_name_normalized), "# empty\n") + zf.writestr("{}/METADATA".format(dist_info), metadata) + zf.writestr("{}/WHEEL".format(dist_info), wheel_file) + zf.writestr("{}/RECORD".format(dist_info), "\n".join(record_entries)) + + wheel_data = buf.getvalue() + sha256 = hashlib.sha256(wheel_data).hexdigest() + return wheel_data, sha256, wheel_name + + +def main(): + parser = argparse.ArgumentParser( + description="Standalone pypiserver for uv_lock integration tests" + ) + parser.add_argument( + "--packages-dir", + type=str, + default=None, + help="Directory for the test wheels (default: $TEST_TMPDIR/pypi-server-packages)", + ) + parser.add_argument( + "--no-auth", + action="store_true", + default=False, + help="Disable authentication (allows anonymous access)", + ) + parser.add_argument( + "--port", + type=int, + default=0, + help="Port to listen on (0 = find free port)", + ) + parser.add_argument( + "--host", + default="localhost", + help="Host to bind to", + ) + args = parser.parse_args() + + if args.packages_dir is None: + sandbox_root = ( + os.environ.get("TEST_TMPDIR") or os.environ.get("TMPDIR") or "/tmp" + ) + args.packages_dir = os.path.join(sandbox_root, "pypi-server-packages") + packages_dir = args.packages_dir + os.makedirs(packages_dir, exist_ok=True) + + wheel_data, sha256, wheel_name = _create_wheel_bytes("my-local-pkg", "1.0.0") + wheel_path = os.path.join(packages_dir, wheel_name) + with open(wheel_path, "wb") as f: + f.write(wheel_data) + + print("Wheel: {}".format(wheel_path), flush=True) + print("SHA256: {}".format(sha256), flush=True) + + password = uuid.uuid4().hex + username = "testuser" + + if args.no_auth: + authenticate = [] + else: + authenticate = ["download", "list", "update"] + + config = Config.default_with_overrides( + roots=[packages_dir], + port=args.port, + host=args.host, + authenticate=authenticate, + password_file=None, + auther=lambda u, p: u == username and p == password, + disable_fallback=True, + fallback_url="", + server_method="wsgiref", + verbosity=0, + log_stream=None, + ) + app = app_from_config(config) + app = setup_routes_from_config(app, config) + + server = make_server(args.host, args.port, app) + port = server.server_address[1] + + base_url = "http://{}:{}".format(args.host, port) + auth_url = "http://{}:{}@{}:{}".format(username, password, args.host, port) + + print("\npypiserver listening on:\n", flush=True) + print(" URL (no auth): {}".format(base_url), flush=True) + print(" URL (auth): {}".format(auth_url), flush=True) + print("\nRequired dependency in requirements.in:", flush=True) + print(" my-local-pkg==1.0.0", flush=True) + print("\nTo use from the uv_lock test workspace, run:", flush=True) + print(" cd tests/integration/uv_lock", flush=True) + print(" bazel run //:requirements.update \\", flush=True) + print(' --action_env=UV_EXTRA_INDEX_URL="{}" \\'.format(auth_url), flush=True) + print(" --action_env=UV_CREDENTIALS_DIR=", flush=True) + print("\nPress Ctrl+C to stop the server.\n", flush=True) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down...", flush=True) + server.shutdown() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/integration/uv_lock_test.py b/tests/integration/uv_lock_test.py new file mode 100644 index 0000000000..8efd3cb84f --- /dev/null +++ b/tests/integration/uv_lock_test.py @@ -0,0 +1,288 @@ +import base64 +import hashlib +import os +import re +import threading +import time +import unittest +import uuid +from pathlib import Path +from urllib.error import URLError +from urllib.request import Request, urlopen +from wsgiref.simple_server import make_server + +from pypiserver import app_from_config, setup_routes_from_config +from pypiserver.config import Config + +from tests.integration import runner +from tests.integration.uv_lock_pypi_server import _create_wheel_bytes + + +def _make_server_on_free_port(app): + server = make_server("localhost", 0, app) + port = server.server_address[1] + return server, port + + +class UvLockIntegrationTest(runner.TestCase): + def setUp(self): + super().setUp() + + self.username = "testuser" + self.password = uuid.uuid4().hex + + self.dir = Path(os.environ["TEST_TMPDIR"]) + self.docroot = self.dir / "simple" + self.docroot.mkdir(exist_ok=True) + + self.wheel_data, self.wheel_sha256, wheel_name = _create_wheel_bytes( + "my-local-pkg", + "1.0.0", + ) + + packages_dir = self.docroot / "packages" + packages_dir.mkdir(exist_ok=True) + self.wheel_path = packages_dir / wheel_name + self.wheel_path.write_bytes(self.wheel_data) + + config = Config.default_with_overrides( + roots=[packages_dir], + port=0, + host="localhost", + authenticate=["download", "list", "update"], + password_file=None, + auther=lambda u, p: u == self.username and p == self.password, + disable_fallback=True, + fallback_url="", + server_method="wsgiref", + verbosity=0, + log_stream=None, + ) + app = app_from_config(config) + app = setup_routes_from_config(app, config) + + self._server, self.port = _make_server_on_free_port(app) + self.server_url = "http://localhost:{port}".format(port=self.port) + self.auth_url = "http://{user}:{passwd}@localhost:{port}".format( + user=self.username, + passwd=self.password, + port=self.port, + ) + + self._thread = threading.Thread(target=self._server.serve_forever) + self._thread.daemon = True + self._thread.start() + + interval = 0.1 + wait_seconds = 40 + for _ in range(int(wait_seconds / interval)): + try: + req = Request(self.server_url) + with urlopen(req, timeout=1) as response: + if response.status in (200, 401): + break + except (URLError, OSError): + pass + time.sleep(interval) + else: + raise RuntimeError( + "Could not start the server, waited for {}s".format(wait_seconds) + ) + + # Set a default value for UV_EXTRA_INDEX_URL in the bazel env so that + # the workspace .bazelrc `--action_env=UV_EXTRA_INDEX_URL` doesn't + # fail on Windows when the variable is unset in the client env. + self.bazel_env.setdefault("UV_EXTRA_INDEX_URL", "") + + # Use a sandbox-local credential store so credentials don't leak + # to the host system. + self.creds_dir = self.repo_root / ".uv-creds" + self.creds_dir.mkdir(parents=True, exist_ok=True) + self.bazel_env["UV_CREDENTIALS_DIR"] = str(self.creds_dir) + + # Log in to uv's credential store so `uv auth helper` can later + # serve the credentials to Bazel or uv itself. + self.run_bazel( + "run", + "//:uv", + "--", + "auth", + "login", + f"--username={self.username}", + f"--password={self.password}", + self.server_url, + ) + + def tearDown(self): + # Clear credentials from uv's credential store to ensure we are not + # logged into the service after the test. + self.run_bazel( + "run", + "//:uv", + "--", + "auth", + "logout", + self.server_url, + check=False, + ) + self._server.shutdown() + + def _assert_server_requires_auth(self): + req = Request(self.server_url + "/my-local-pkg/") + try: + urlopen(req, timeout=5) + self.fail("Expected 401 without auth") + except URLError: + pass + + def _auth_header(self): + return "Basic " + base64.b64encode( + "{user}:{passwd}".format( + user=self.username, + passwd=self.password, + ).encode("utf-8") + ).decode("utf-8") + + def _assert_simple_api_sha256(self): + auth_header = self._auth_header() + req = Request(self.server_url + "/simple/my-local-pkg/") + req.add_header("Authorization", auth_header) + resp = urlopen(req, timeout=5) + html = resp.read().decode("utf-8") + + match = re.search(r"#sha256=([a-f0-9]+)", html) + self.assertIsNotNone(match, "No sha256 found in simple API: {}".format(html)) + pypiserver_sha256 = match.group(1) + disk_sha256 = hashlib.sha256(self.wheel_path.read_bytes()).hexdigest() + self.assertEqual( + pypiserver_sha256, + disk_sha256, + "pypiserver hash {} != disk hash {}".format(pypiserver_sha256, disk_sha256), + ) + + def _creds_auth_args(self): + return [ + "--strategy=PyRequirementsLockUv=local", + "--action_env={key}={value}".format( + key="UV_CREDENTIALS_DIR", + value=str(self.creds_dir), + ), + "--action_env={key}={value}".format( + key="UV_EXTRA_INDEX_URL", + value=self.server_url, + ), + ] + + def _assert_lock_file(self, result): + self.assertEqual( + result.exit_code, + 0, + "Lock update failed:\n{}".format(result.describe()), + ) + lock_file = self.repo_root / "requirements.txt" + self.assertTrue(lock_file.exists(), "Lock file was not created") + contents = lock_file.read_text() + self.assertIn("my-local-pkg", contents) + self.assertIn("--hash=sha256:", contents) + + def test_lock_update_with_custom_index(self): + self._assert_server_requires_auth() + self._assert_simple_api_sha256() + + result = self.run_bazel( + "run", + "--action_env={key}={value}".format( + key="UV_EXTRA_INDEX_URL", + value=self.auth_url, + ), + "//:requirements.update", + ) + self._assert_lock_file(result) + + def test_update_with_credential_helper(self): + """Use a credential helper for authentication.""" + self._assert_server_requires_auth() + result = self.run_bazel( + "run", + *self._creds_auth_args(), + "//:requirements.update", + ) + self._assert_lock_file(result) + + def test_update_with_uv_auth_helper(self): + """Use the uv auth helper for authentication.""" + self._assert_server_requires_auth() + result = self.run_bazel( + "run", + *self._creds_auth_args(), + "//:requirements.update", + ) + self._assert_lock_file(result) + + def test_diff_test_with_requirements(self): + """Verify that ``diff_test`` can verify the generated lock file.""" + self._assert_server_requires_auth() + + # First generate the lock file + result = self.run_bazel( + "run", + *self._creds_auth_args(), + "//:requirements.update", + ) + self._assert_lock_file(result) + + # Copy the generated lock file to the expected location. The inner + # Bazel workspace is writable because it is a temporary copy created + # by the integration test framework. + generated = self.repo_root / "requirements.txt" + expected = self.repo_root / "requirements_expected.txt" + expected.write_text(generated.read_text()) + + # Run the diff_test: it builds the lock action, then compares the + # output to our expected file. + result = self.run_bazel( + "test", + *self._creds_auth_args(), + "//:requirements_diff_test", + ) + self.assertEqual( + result.exit_code, + 0, + "diff_test failed:\n{}".format(result.describe()), + ) + + def test_no_existing_requirements(self): + """Verify that ``bazel run`` and ``diff_test`` work when + ``requirements.txt`` does not yet exist.""" + self._assert_server_requires_auth() + + # Remove the existing lock file to simulate a fresh checkout + existing = self.repo_root / "requirements.txt" + existing.unlink() + self.assertFalse(existing.exists()) + + # Run ``requirements.update`` to generate the lock from scratch. The + # underlying lock rule will have no ``existing_output`` to copy, but + # ``uv pip compile`` should still produce the output. + result = self.run_bazel( + "run", + *self._creds_auth_args(), + "//:requirements.update", + ) + self._assert_lock_file(result) + + # diff_test should pass now that ``requirements.txt`` exists again + result = self.run_bazel( + "test", + *self._creds_auth_args(), + "//:requirements_diff_test", + ) + self.assertEqual( + result.exit_code, + 0, + "diff_test failed:\n{}".format(result.describe()), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/uv/lock/lock_run_test.py b/tests/uv/lock/lock_run_test.py index f64cbdccec..e2508161d5 100644 --- a/tests/uv/lock/lock_run_test.py +++ b/tests/uv/lock/lock_run_test.py @@ -1,3 +1,4 @@ +import os import subprocess import tempfile import unittest @@ -9,15 +10,59 @@ def _relative_rpath(path: str) -> Path: - p = (Path("_main") / "tests" / "uv" / "lock" / path).as_posix() - rpath = rfiles.Rlocation(p) - if not rpath: - raise ValueError(f"Could not find file: {p}") - - return Path(rpath) + """Find file in runfiles, handling Windows .bat/.exe wrappers.""" + # On Windows, try executable extensions first to avoid matching symlink + # entries in the runfiles manifest that point to non-executable files + # (e.g. a Python source file instead of the .exe launcher). + exts = (".exe", ".bat", "") if os.name == "nt" else ("", ".exe", ".bat") + for ext in exts: + p = (Path("_main") / "tests" / "uv" / "lock" / (path + ext)).as_posix() + rpath = rfiles.Rlocation(p) + if rpath: + rp = Path(rpath) + if rp.exists(): + return rp + + # Fallback: look in runfiles directory directly (handles .bat wrappers on + # Windows where Rlocation may return a runfiles link that doesn't exist) + runfiles_dir = os.environ.get("RUNFILES_DIR") + if runfiles_dir: + exts = (".exe", ".bat", "") if os.name == "nt" else ("", ".bat", ".exe") + for ext in exts: + rp = Path(runfiles_dir, "_main", "tests", "uv", "lock", path + ext) + if rp.exists(): + return rp + + raise ValueError(f"Could not find file in runfiles: {path}") + + +def _run_binary(path: Path, **kwargs): + """Run a binary, handling Windows .bat files.""" + if os.name == "nt": + return subprocess.run( + ["cmd.exe", "/c", str(path)], + **kwargs, + ) + return subprocess.run(path, **kwargs) class LockTests(unittest.TestCase): + def _subprocess_env(self, workspace_dir: Path) -> dict[str, str]: + env = { + "BUILD_WORKSPACE_DIRECTORY": str(workspace_dir), + } + # Inherit specific env vars needed for finding runfiles on Windows + for key in ( + "PATH", + "RUNFILES_DIR", + "RUNFILES_MANIFEST_FILE", + "SYSTEMROOT", + "PATHEXT", + ): + if key in os.environ: + env[key] = os.environ[key] + return env + def test_requirements_updating_for_the_first_time(self): # Given copier_path = _relative_rpath("requirements_new_file.update") @@ -30,19 +75,18 @@ def test_requirements_updating_for_the_first_time(self): self.assertFalse( want_path.exists(), "The path should not exist after the test" ) - output = subprocess.run( + output = _run_binary( copier_path, capture_output=True, - env={ - "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}", - }, + env=self._subprocess_env(workspace_dir), ) # Then self.assertEqual(0, output.returncode, output.stderr) + stdout = output.stdout.decode("utf-8").replace("\\", "/") self.assertIn( "cp /tests/uv/lock/requirements_new_file", - output.stdout.decode("utf-8"), + stdout, ) self.assertTrue(want_path.exists(), "The path should exist after the test") self.assertNotEqual(want_path.read_text(), "") @@ -69,19 +113,18 @@ def test_requirements_updating(self): want_text + "\n\n" ) # Write something else to see that it is restored - output = subprocess.run( + output = _run_binary( copier_path, capture_output=True, - env={ - "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}", - }, + env=self._subprocess_env(workspace_dir), ) # Then self.assertEqual(0, output.returncode) + stdout = output.stdout.decode("utf-8").replace("\\", "/") self.assertIn( "cp /tests/uv/lock/requirements", - output.stdout.decode("utf-8"), + stdout, ) self.assertEqual(want_path.read_text(), want_text) @@ -100,12 +143,10 @@ def test_requirements_run_on_the_first_time(self): self.assertFalse( want_path.exists(), "The path should not exist after the test" ) - output = subprocess.run( + output = _run_binary( copier_path, capture_output=True, - env={ - "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}", - }, + env=self._subprocess_env(workspace_dir), ) # Then @@ -141,12 +182,10 @@ def test_requirements_run(self): want_text + "\n\n" ) # Write something else to see that it is restored - output = subprocess.run( + output = _run_binary( copier_path, capture_output=True, - env={ - "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}", - }, + env=self._subprocess_env(workspace_dir), ) # Then diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl index 1eb5b1d903..bcaed95b53 100644 --- a/tests/uv/lock/lock_tests.bzl +++ b/tests/uv/lock/lock_tests.bzl @@ -14,7 +14,7 @@ "" -load("@bazel_skylib//rules:native_binary.bzl", "native_test") +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") load("//python/uv:lock.bzl", "lock") load("//tests/support:py_reconfig.bzl", "py_reconfig_test") @@ -77,23 +77,14 @@ def lock_test_suite(name): # `--index-url`. "no-remote-exec", ], - # FIXME @aignas 2025-03-19: It seems that currently: - # 1. The Windows runners are not compatible with the `uv` Windows binaries. - # 2. The Python launcher is having trouble launching scripts from within the Python test. - target_compatible_with = select({ - "@platforms//os:windows": ["@platforms//:incompatible"], - "//conditions:default": [], - }), ) - # document and check that this actually works - native_test( + # Document and check that the action output matches the in-source file. + diff_test( name = "requirements_test", - src = ":requirements.update", - target_compatible_with = select({ - "@platforms//os:windows": ["@platforms//:incompatible"], - "//conditions:default": [], - }), + timeout = "short", + file1 = ":requirements", + file2 = "testdata/requirements.txt", ) native.test_suite( From c00bbaf6f7d26f9eeeb6d9887ce24f5dc37bad5b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 7 Jun 2026 18:21:34 -0700 Subject: [PATCH 002/179] refactor: use python_ext mock helpers in python_tests.bzl (#3813) Refactored tests/python/python_tests.bzl to use the shared python_ext mock helpers instead of standalone mock helpers. --- tests/python/python_tests.bzl | 443 +++++++++++++++++----------------- 1 file changed, 216 insertions(+), 227 deletions(-) diff --git a/tests/python/python_tests.bzl b/tests/python/python_tests.bzl index 60e7311c00..5db74265be 100644 --- a/tests/python/python_tests.bzl +++ b/tests/python/python_tests.bzl @@ -19,111 +19,19 @@ load("@rules_testing//lib:test_suite.bzl", "test_suite") load("//python/private:python.bzl", "parse_modules") # buildifier: disable=bzl-visibility load("//python/private:repo_utils.bzl", "repo_utils") # buildifier: disable=bzl-visibility load("//tests/support/mocks:mocks.bzl", "mocks") +load("//tests/support/mocks:python_ext.bzl", "python_ext") _tests = [] -def _mod(*, name, defaults = [], toolchain = [], override = [], single_version_override = [], single_version_platform_override = [], is_root = False): - return mocks.module( - name, - is_root = is_root, - defaults = defaults, - toolchain = toolchain, - override = override, - single_version_override = single_version_override, - single_version_platform_override = single_version_platform_override, - ) - -def _defaults(python_version = None, python_version_env = None, python_version_file = None): - return struct( - python_version = python_version, - python_version_env = python_version_env, - python_version_file = python_version_file, - ) - -def _toolchain(python_version, *, is_default = False, **kwargs): - return struct( - is_default = is_default, - python_version = python_version, - **kwargs - ) - -def _override( - auth_patterns = {}, - available_python_versions = [], - base_url = "", - minor_mapping = {}, - netrc = "", - register_all_versions = False, - add_target_settings = []): - return struct( - auth_patterns = auth_patterns, - available_python_versions = available_python_versions, - base_url = base_url, - minor_mapping = minor_mapping, - netrc = netrc, - register_all_versions = register_all_versions, - add_target_settings = add_target_settings, - ) - def _rules_python_module(is_root = False): """A mock of what the real rules_python MODULE.bazel looks like.""" - return _mod( + return python_ext.module( name = "rules_python", - defaults = [_defaults(python_version = "3.11")], - toolchain = [_toolchain("3.11")], + defaults = [python_ext.defaults(python_version = "3.11")], + toolchain = [python_ext.toolchain(python_version = "3.11")], is_root = is_root, ) -def _single_version_override( - python_version = "", - sha256 = {}, - urls = [], - patch_strip = 0, - patches = [], - strip_prefix = "python", - distutils_content = "", - distutils = None): - if not python_version: - fail("missing mandatory args: python_version ({})".format(python_version)) - - return struct( - python_version = python_version, - sha256 = sha256, - urls = urls, - patch_strip = patch_strip, - patches = patches, - strip_prefix = strip_prefix, - distutils_content = distutils_content, - distutils = distutils, - ) - -def _single_version_platform_override( - coverage_tool = None, - patch_strip = 0, - patches = [], - platform = "", - python_version = "", - sha256 = "", - strip_prefix = "python", - urls = []): - if not platform or not python_version: - fail("missing mandatory args: platform ({}) and python_version ({})".format(platform, python_version)) - - return struct( - sha256 = sha256, - urls = urls, - strip_prefix = strip_prefix, - platform = platform, - coverage_tool = coverage_tool, - python_version = python_version, - patch_strip = patch_strip, - patches = patches, - target_compatible_with = [], - target_settings = [], - os_name = "", - arch = "", - ) - def _test_default_from_rules_python_when_rules_python_is_root(env): """Verify that rules_python (as root module) default is applied.""" py = parse_modules( @@ -178,7 +86,11 @@ def _test_default_with_patch_version(env): py = parse_modules( module_ctx = mocks.mctx( modules = [ - _mod(name = "alpha", toolchain = [_toolchain("3.11.2")], is_root = True), + python_ext.module( + name = "alpha", + is_root = True, + toolchain = [python_ext.toolchain(python_version = "3.11.2")], + ), _rules_python_module(is_root = False), ], ), @@ -199,18 +111,21 @@ _tests.append(_test_default_with_patch_version) def _test_toolchain_ordering(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", + is_root = True, toolchain = [ - _toolchain("3.10"), - _toolchain("3.10.15"), - _toolchain(MINOR_MAPPING["3.10"]), - _toolchain("3.10.13"), - _toolchain("3.11.1"), - _toolchain("3.11.10"), - _toolchain(MINOR_MAPPING["3.11"], is_default = True), + python_ext.toolchain(python_version = "3.10"), + python_ext.toolchain(python_version = "3.10.15"), + python_ext.toolchain(python_version = MINOR_MAPPING["3.10"]), + python_ext.toolchain(python_version = "3.10.13"), + python_ext.toolchain(python_version = "3.11.1"), + python_ext.toolchain(python_version = "3.11.10"), + python_ext.toolchain( + python_version = MINOR_MAPPING["3.11"], + is_default = True, + ), ], - is_root = True, ), _rules_python_module(), ), @@ -246,11 +161,15 @@ _tests.append(_test_toolchain_ordering) def _test_default_from_defaults(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_root_module", - defaults = [_defaults(python_version = "3.11")], - toolchain = [_toolchain("3.10"), _toolchain("3.11"), _toolchain("3.12")], + defaults = [python_ext.defaults(python_version = "3.11")], is_root = True, + toolchain = [ + python_ext.toolchain(python_version = "3.10"), + python_ext.toolchain(python_version = "3.11"), + python_ext.toolchain(python_version = "3.12"), + ], ), ), logger = repo_utils.logger(verbosity_level = 0, name = "python"), @@ -273,11 +192,20 @@ _tests.append(_test_default_from_defaults) def _test_default_from_defaults_env(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_root_module", - defaults = [_defaults(python_version = "3.11", python_version_env = "PYENV_VERSION")], - toolchain = [_toolchain("3.10"), _toolchain("3.11"), _toolchain("3.12")], + defaults = [ + python_ext.defaults( + python_version = "3.11", + python_version_env = "PYENV_VERSION", + ), + ], is_root = True, + toolchain = [ + python_ext.toolchain(python_version = "3.10"), + python_ext.toolchain(python_version = "3.11"), + python_ext.toolchain(python_version = "3.12"), + ], ), environ = {"PYENV_VERSION": "3.12"}, ), @@ -301,11 +229,19 @@ _tests.append(_test_default_from_defaults_env) def _test_default_from_defaults_file(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_root_module", - defaults = [_defaults(python_version_file = "@@//:.python-version")], - toolchain = [_toolchain("3.10"), _toolchain("3.11"), _toolchain("3.12")], + defaults = [ + python_ext.defaults( + python_version_file = "@@//:.python-version", + ), + ], is_root = True, + toolchain = [ + python_ext.toolchain(python_version = "3.10"), + python_ext.toolchain(python_version = "3.11"), + python_ext.toolchain(python_version = "3.12"), + ], ), mock_files = {"@@//:.python-version": "3.12\n"}, ), @@ -329,10 +265,10 @@ _tests.append(_test_default_from_defaults_file) def _test_default_from_single_toolchain(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_root_module", - toolchain = [_toolchain("3.12")], is_root = True, + toolchain = [python_ext.toolchain(python_version = "3.12")], ), _rules_python_module(), ), @@ -345,14 +281,14 @@ _tests.append(_test_default_from_single_toolchain) def _test_defaults_overrides_single_toolchain(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_root_module", defaults = [ # This relies on rules_python registering 3.11 - _defaults(python_version = "3.11"), + python_ext.defaults(python_version = "3.11"), ], - toolchain = [_toolchain("3.12")], is_root = True, + toolchain = [python_ext.toolchain(python_version = "3.12")], ), _rules_python_module(), ), @@ -365,14 +301,17 @@ _tests.append(_test_defaults_overrides_single_toolchain) def _test_defaults_overrides_toolchains_setting_is_default(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_root_module", - defaults = [_defaults(python_version = "3.13")], + defaults = [python_ext.defaults(python_version = "3.13")], + is_root = True, toolchain = [ - _toolchain("3.13"), - _toolchain("3.12", is_default = True), + python_ext.toolchain(python_version = "3.13"), + python_ext.toolchain( + python_version = "3.12", + is_default = True, + ), ], - is_root = True, ), _rules_python_module(), ), @@ -386,8 +325,21 @@ def _test_first_occurance_of_the_toolchain_wins(env): py = parse_modules( module_ctx = mocks.mctx( modules = [ - _mod(name = "my_module", is_root = True, toolchain = [_toolchain("3.12")]), - _mod(name = "some_module", toolchain = [_toolchain("3.12", configure_coverage_tool = True)]), + python_ext.module( + name = "my_module", + is_root = True, + toolchain = [python_ext.toolchain(python_version = "3.12")], + ), + python_ext.module( + name = "some_module", + is_root = False, + toolchain = [ + python_ext.toolchain( + python_version = "3.12", + configure_coverage_tool = True, + ), + ], + ), _rules_python_module(), ], environ = { @@ -428,16 +380,16 @@ _tests.append(_test_first_occurance_of_the_toolchain_wins) def _test_auth_overrides(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", - toolchain = [_toolchain("3.12")], + is_root = True, override = [ - _override( - netrc = "/my/netrc", + python_ext.override( auth_patterns = {"foo": "bar"}, + netrc = "/my/netrc", ), ], - is_root = True, + toolchain = [python_ext.toolchain(python_version = "3.12")], ), _rules_python_module(), ), @@ -470,17 +422,17 @@ _tests.append(_test_auth_overrides) def _test_add_target_settings(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", - toolchain = [_toolchain("3.12")], + is_root = True, override = [ - _override( + python_ext.override( add_target_settings = [ "@@//my:custom_setting", ], ), ], - is_root = True, + toolchain = [python_ext.toolchain(python_version = "3.12")], ), _rules_python_module(), ), @@ -496,45 +448,50 @@ _tests.append(_test_add_target_settings) def _test_add_new_version(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", is_root = True, - toolchain = [_toolchain("3.13")], + override = [ + python_ext.override( + available_python_versions = [ + "3.12.4", + "3.13.0", + "3.13.1", + "3.13.99", + ], + base_url = "", + minor_mapping = { + "3.13": "3.13.99", + }, + ), + ], single_version_override = [ - _single_version_override( + python_ext.single_version_override( + distutils = None, + distutils_content = "", + patch_strip = 0, + patches = [], python_version = "3.13.0", sha256 = { "aarch64-unknown-linux-gnu": "deadbeef", }, - urls = ["example.org"], - patch_strip = 0, - patches = [], strip_prefix = "prefix", - distutils_content = "", - distutils = None, + urls = ["example.org"], ), ], single_version_platform_override = [ - _single_version_platform_override( - sha256 = "deadb00f", - urls = ["something.org", "else.org"], - strip_prefix = "python", - platform = "aarch64-unknown-linux-gnu", + python_ext.single_version_platform_override( coverage_tool = "specific_cov_tool", - python_version = "3.13.99", patch_strip = 2, patches = ["specific-patch.txt"], + platform = "aarch64-unknown-linux-gnu", + python_version = "3.13.99", + sha256 = "deadb00f", + strip_prefix = "python", + urls = ["something.org", "else.org"], ), ], - override = [ - _override( - base_url = "", - available_python_versions = ["3.12.4", "3.13.0", "3.13.1", "3.13.99"], - minor_mapping = { - "3.13": "3.13.99", - }, - ), - ], + toolchain = [python_ext.toolchain(python_version = "3.13")], ), ), logger = repo_utils.logger(verbosity_level = 0, name = "python"), @@ -577,12 +534,23 @@ _tests.append(_test_add_new_version) def _test_register_all_versions(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", is_root = True, - toolchain = [_toolchain("3.13")], + override = [ + python_ext.override( + available_python_versions = [ + "3.12.4", + "3.13.0", + "3.13.1", + "3.13.99", + ], + base_url = "", + register_all_versions = True, + ), + ], single_version_override = [ - _single_version_override( + python_ext.single_version_override( python_version = "3.13.0", sha256 = { "aarch64-unknown-linux-gnu": "deadbeef", @@ -591,20 +559,14 @@ def _test_register_all_versions(env): ), ], single_version_platform_override = [ - _single_version_platform_override( - sha256 = "deadb00f", - urls = ["something.org"], + python_ext.single_version_platform_override( platform = "aarch64-unknown-linux-gnu", python_version = "3.13.99", + sha256 = "deadb00f", + urls = ["something.org"], ), ], - override = [ - _override( - base_url = "", - available_python_versions = ["3.12.4", "3.13.0", "3.13.1", "3.13.99"], - register_all_versions = True, - ), - ], + toolchain = [python_ext.toolchain(python_version = "3.13")], ), ), logger = repo_utils.logger(verbosity_level = 0, name = "python"), @@ -643,16 +605,25 @@ _tests.append(_test_register_all_versions) def _test_ignore_unsupported_versions(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", is_root = True, - toolchain = [ - _toolchain("3.11"), - _toolchain("3.12"), - _toolchain("3.13", is_default = True), + override = [ + python_ext.override( + available_python_versions = [ + "3.12.4", + "3.13.0", + "3.13.1", + ], + base_url = "", + minor_mapping = { + "3.12": "3.12.4", + "3.13": "3.13.1", + }, + ), ], single_version_override = [ - _single_version_override( + python_ext.single_version_override( python_version = "3.13.0", sha256 = { "aarch64-unknown-linux-gnu": "deadbeef", @@ -661,21 +632,19 @@ def _test_ignore_unsupported_versions(env): ), ], single_version_platform_override = [ - _single_version_platform_override( - sha256 = "deadb00f", - urls = ["something.org"], + python_ext.single_version_platform_override( platform = "aarch64-unknown-linux-gnu", python_version = "3.13.99", + sha256 = "deadb00f", + urls = ["something.org"], ), ], - override = [ - _override( - base_url = "", - available_python_versions = ["3.12.4", "3.13.0", "3.13.1"], - minor_mapping = { - "3.12": "3.12.4", - "3.13": "3.13.1", - }, + toolchain = [ + python_ext.toolchain(python_version = "3.11"), + python_ext.toolchain(python_version = "3.12"), + python_ext.toolchain( + python_version = "3.13", + is_default = True, ), ], ), @@ -714,46 +683,46 @@ _tests.append(_test_ignore_unsupported_versions) def _test_add_patches(env): py = parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", is_root = True, - toolchain = [_toolchain("3.13")], + override = [ + python_ext.override( + available_python_versions = ["3.13.0"], + base_url = "", + minor_mapping = { + "3.13": "3.13.0", + }, + ), + ], single_version_override = [ - _single_version_override( + python_ext.single_version_override( + distutils = None, + distutils_content = "", + patch_strip = 1, + patches = ["common.txt"], python_version = "3.13.0", sha256 = { "aarch64-apple-darwin": "deadbeef", "aarch64-unknown-linux-gnu": "deadbeef", }, - urls = ["example.org"], - patch_strip = 1, - patches = ["common.txt"], strip_prefix = "prefix", - distutils_content = "", - distutils = None, + urls = ["example.org"], ), ], single_version_platform_override = [ - _single_version_platform_override( - sha256 = "deadb00f", - urls = ["something.org", "else.org"], - strip_prefix = "python", - platform = "aarch64-unknown-linux-gnu", + python_ext.single_version_platform_override( coverage_tool = "specific_cov_tool", - python_version = "3.13.0", patch_strip = 2, patches = ["specific-patch.txt"], + platform = "aarch64-unknown-linux-gnu", + python_version = "3.13.0", + sha256 = "deadb00f", + strip_prefix = "python", + urls = ["something.org", "else.org"], ), ], - override = [ - _override( - base_url = "", - available_python_versions = ["3.13.0"], - minor_mapping = { - "3.13": "3.13.0", - }, - ), - ], + toolchain = [python_ext.toolchain(python_version = "3.13")], ), ), logger = repo_utils.logger(verbosity_level = 0, name = "python"), @@ -793,14 +762,14 @@ def _test_fail_two_overrides(env): errors = [] parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", is_root = True, - toolchain = [_toolchain("3.13")], override = [ - _override(base_url = "foo"), - _override(base_url = "bar"), + python_ext.override(base_url = "foo"), + python_ext.override(base_url = "bar"), ], + toolchain = [python_ext.toolchain(python_version = "3.13")], ), ), _fail = errors.append, @@ -816,8 +785,14 @@ def _test_single_version_override_errors(env): for test in [ struct( overrides = [ - _single_version_override(python_version = "3.12.4", distutils_content = "foo"), - _single_version_override(python_version = "3.12.4", distutils_content = "foo"), + python_ext.single_version_override( + distutils_content = "foo", + python_version = "3.12.4", + ), + python_ext.single_version_override( + distutils_content = "foo", + python_version = "3.12.4", + ), ], want_error = "Only a single 'python.single_version_override' can be present for '3.12.4'", ), @@ -825,11 +800,11 @@ def _test_single_version_override_errors(env): errors = [] parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", is_root = True, - toolchain = [_toolchain("3.13")], single_version_override = test.overrides, + toolchain = [python_ext.toolchain(python_version = "3.13")], ), ), _fail = errors.append, @@ -843,20 +818,34 @@ def _test_single_version_platform_override_errors(env): for test in [ struct( overrides = [ - _single_version_platform_override(python_version = "3.12.4", platform = "foo", coverage_tool = "foo"), - _single_version_platform_override(python_version = "3.12.4", platform = "foo", coverage_tool = "foo"), + python_ext.single_version_platform_override( + coverage_tool = "foo", + platform = "foo", + python_version = "3.12.4", + ), + python_ext.single_version_platform_override( + coverage_tool = "foo", + platform = "foo", + python_version = "3.12.4", + ), ], want_error = "Only a single 'python.single_version_platform_override' can be present for '(\"3.12.4\", \"foo\")'", ), struct( overrides = [ - _single_version_platform_override(python_version = "3.12", platform = "foo"), + python_ext.single_version_platform_override( + platform = "foo", + python_version = "3.12", + ), ], want_error = "The 'python_version' attribute needs to specify the full version in at least 'X.Y.Z' format, got: '3.12'", ), struct( overrides = [ - _single_version_platform_override(python_version = "foo", platform = "foo"), + python_ext.single_version_platform_override( + platform = "foo", + python_version = "foo", + ), ], want_error = "Failed to parse PEP 440 version identifier 'foo'. Parse error at 'foo'", ), @@ -864,11 +853,11 @@ def _test_single_version_platform_override_errors(env): errors = [] parse_modules( module_ctx = mocks.mctx( - _mod( + python_ext.module( name = "my_module", - toolchain = [_toolchain("3.13")], - single_version_platform_override = test.overrides, is_root = True, + single_version_platform_override = test.overrides, + toolchain = [python_ext.toolchain(python_version = "3.13")], ), ), _fail = lambda *a: errors.append(" ".join(a)), From dcb7dfa92c8019661730ff25f364b70b8eb3de8c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 7 Jun 2026 18:23:38 -0700 Subject: [PATCH 003/179] feat: add //command_line_option:extra_toolchains pseudo-flag (#3810) Add support for transitioning on the //command_line_option:extra_toolchains built-in flag using py_binary.config_settings. Note that, unlike the normal Bazel built-in flag, it must be specified as a simple comma-separated string when set using config_settings, which is then parsed as a CSV string. This is to make it easier to, on a per-target basis, such as with a zipapp or wheels, change the Python toolchains used. --- command_line_option/BUILD.bazel | 5 ++++ .../rules_python/command_line_option/index.md | 23 +++++++++++++++++++ python/features.bzl | 1 + python/private/attributes.bzl | 9 ++++++-- python/private/common_labels.bzl | 1 + python/private/transition_labels.bzl | 1 + tests/base_rules/py_executable_base_tests.bzl | 23 +++++++++++++++++++ .../bzlmod_lockfile/MODULE.bazel.lock | 2 +- 8 files changed, 62 insertions(+), 3 deletions(-) diff --git a/command_line_option/BUILD.bazel b/command_line_option/BUILD.bazel index a4d5f0f80d..15c22f360b 100644 --- a/command_line_option/BUILD.bazel +++ b/command_line_option/BUILD.bazel @@ -18,6 +18,11 @@ alias( actual = "//python:none", ) +alias( + name = "extra_toolchains", + actual = "//python:none", +) + filegroup( name = "distribution", srcs = glob(["**"]), diff --git a/docs/api/rules_python/command_line_option/index.md b/docs/api/rules_python/command_line_option/index.md index a7dfa340eb..02d29206f5 100644 --- a/docs/api/rules_python/command_line_option/index.md +++ b/docs/api/rules_python/command_line_option/index.md @@ -51,3 +51,26 @@ See the [Bazel documentation for --enable_runfiles](https://bazel.build/referenc The special value `INHERIT` can be specified to use the existing flag value. ::: + +## extra_toolchains + +:::{bzl:target} extra_toolchains + +Special target for the Bazel-builtin `//command_line_option:extra_toolchains` +flag. + +See the [Bazel documentation for --extra_toolchains](https://bazel.build/reference/command-line-reference#flag--extra_toolchains). + +The special value `INHERIT` can be specified to use the existing flag value. + +:::{note} +Unlike the normal Bazel built-in flag, which accepts a list of labels, this +pseudo-flag must be specified as a single, comma-separated string when set +using the `config_settings` attribute. For example: + +```python +"//command_line_option:extra_toolchains": "//my/tc1,//my/tc2" +``` +::: +::: + diff --git a/python/features.bzl b/python/features.bzl index 26acc8c5fe..a2cb95f1ee 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -86,6 +86,7 @@ def _features_typedef(): _TARGETS = { "//command_line_option:build_runfile_links": True, "//command_line_option:enable_runfiles": True, + "//command_line_option:extra_toolchains": True, "//python/cc:current_py_cc_headers_abi3": True, } diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl index beaa7a6a73..ad741d078d 100644 --- a/python/private/attributes.bzl +++ b/python/private/attributes.bzl @@ -446,10 +446,15 @@ def apply_config_settings_attr(settings, attr): if key.package == "command_line_option": if value == "INHERIT": continue - else: - str_key = "//command_line_option:" + key.name + str_key = "//command_line_option:" + key.name + if key.name == "extra_toolchains": + if value == "": + value = [] + else: + value = [v.strip() for v in value.split(",") if v.strip()] else: str_key = str(key) + settings[str_key] = value return settings diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl index ff4e7e1dad..a83ba2b462 100644 --- a/python/private/common_labels.bzl +++ b/python/private/common_labels.bzl @@ -14,6 +14,7 @@ labels = struct( # NOTE: Special target; see definition for details. ENABLE_RUNFILES = str(Label("//command_line_option:enable_runfiles")), EXEC_TOOLS_TOOLCHAIN = str(Label("//python/config_settings:exec_tools_toolchain")), + EXTRA_TOOLCHAINS = str(Label("//command_line_option:extra_toolchains")), NONE = str(Label("//python:none")), PIP_ENV_MARKER_CONFIG = str(Label("//python/config_settings:pip_env_marker_config")), PIP_WHL_OSX_VERSION = str(Label("//python/config_settings:pip_whl_osx_version")), diff --git a/python/private/transition_labels.bzl b/python/private/transition_labels.bzl index 5f0aa69056..7a6531ed0f 100644 --- a/python/private/transition_labels.bzl +++ b/python/private/transition_labels.bzl @@ -12,6 +12,7 @@ _BASE_TRANSITION_LABELS = [ labels.BOOTSTRAP_IMPL, labels.DEBUGGER, labels.EXEC_TOOLS_TOOLCHAIN, + "//command_line_option:extra_toolchains", labels.PIP_ENV_MARKER_CONFIG, labels.PIP_WHL_OSX_VERSION, labels.PRECOMPILE, diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl index dff0399bc2..7531de4c30 100644 --- a/tests/base_rules/py_executable_base_tests.bzl +++ b/tests/base_rules/py_executable_base_tests.bzl @@ -109,6 +109,29 @@ def _test_basic_zip_impl(env, target): _tests.append(_test_basic_zip) +def _test_config_settings_extra_toolchains(name, config): + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = ["main.py"], + main = "main.py", + config_settings = { + "//command_line_option:extra_toolchains": "{tc},{tc}".format(tc = CC_TOOLCHAIN), + }, + ) + analysis_test( + name = name, + impl = _test_config_settings_extra_toolchains_impl, + target = name + "_subject", + ) + +def _test_config_settings_extra_toolchains_impl(env, target): + # If we got here, it means analysis succeeded, which implies the transition + # successfully parsed the CSV string into a list. + env.expect.that_target(target).has_provider(PyInfo) + +_tests.append(_test_config_settings_extra_toolchains) + def _test_cross_compile_to_unix(name, config): rt_util.helper_target( config.rule, diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index 0dcc4b2a48..d21fec2d7d 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -250,7 +250,7 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "yrEbeCJlv5gbdoRjwwR/EDEkc3pfuQy/FTYKMNoK5Wc=", + "bzlTransitiveDigest": "Z5ZPR9z4PkJRXSyJ4KQEqM4kwiqWBCn8Ajzxy9YlS/g=", "usagesDigest": "6yXGw7XDyXjOfqBL0SBu1YBEMMYPQzCE3jTzUCkxPgg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", From 8b38325e7c1b8b3240088b14d5ba2e5316aceabc Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 8 Jun 2026 08:09:42 -0700 Subject: [PATCH 004/179] build: configure secondary mirror fallback (#3814) Currently, transient GitHub network issues can cause builds to fail when pulling platforms. To fix, configure Bazel's downloader to try GitHub first and fall back to mirror.bazel.build. --- .bazelrc | 2 ++ AGENTS.md | 6 ++++++ downloader_config.cfg | 16 ++++++++++++++++ gazelle/.bazelrc | 1 + gazelle/downloader_config.cfg | 16 ++++++++++++++++ sphinxdocs/.bazelrc | 1 + sphinxdocs/downloader_config.cfg | 16 ++++++++++++++++ 7 files changed, 58 insertions(+) create mode 100644 downloader_config.cfg create mode 100644 gazelle/downloader_config.cfg create mode 100644 sphinxdocs/downloader_config.cfg diff --git a/.bazelrc b/.bazelrc index 8fedcf1f8f..8f784e4c2b 100644 --- a/.bazelrc +++ b/.bazelrc @@ -44,6 +44,8 @@ common --enable_bzlmod # Local disk cache greatly speeds up builds if the regular cache is lost common --disk_cache=~/.cache/bazel/bazel-disk-cache +# Drop `experimental_` prefix once Bazel 7 is no longer supported +common --experimental_downloader_config=downloader_config.cfg # Additional config to use for readthedocs builds. diff --git a/AGENTS.md b/AGENTS.md index 73d254f27c..65c5baf64b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,3 +207,9 @@ e.g. ``` load("//python/private:foo.bzl", "foo") # buildifier: disable=bzl-visibility ``` + +### CI Failure Inspection + +When inspecting CI failures, if the failure is due to a network error +downloading a repository, check if that rule set is mirrored on +mirror.bazel.build. If so, add it to the downloader config. diff --git a/downloader_config.cfg b/downloader_config.cfg new file mode 100644 index 0000000000..a978fb89b9 --- /dev/null +++ b/downloader_config.cfg @@ -0,0 +1,16 @@ +# Try GitHub first (primary) +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) github.com/bazelbuild/rules_shell/$1 + +# Fall back to mirror (secondary) +# Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) mirror.bazel.build/github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) mirror.bazel.build/github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) mirror.bazel.build/github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) mirror.bazel.build/github.com/bazelbuild/rules_shell/$1 diff --git a/gazelle/.bazelrc b/gazelle/.bazelrc index 9a38133e9d..bdb29d5bc3 100644 --- a/gazelle/.bazelrc +++ b/gazelle/.bazelrc @@ -1,5 +1,6 @@ common --deleted_packages=examples/bzlmod_build_file_generation common --deleted_packages=examples/bzlmod_build_file_generation/runfiles +common --experimental_downloader_config=downloader_config.cfg test --test_output=errors diff --git a/gazelle/downloader_config.cfg b/gazelle/downloader_config.cfg new file mode 100644 index 0000000000..a978fb89b9 --- /dev/null +++ b/gazelle/downloader_config.cfg @@ -0,0 +1,16 @@ +# Try GitHub first (primary) +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) github.com/bazelbuild/rules_shell/$1 + +# Fall back to mirror (secondary) +# Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) mirror.bazel.build/github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) mirror.bazel.build/github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) mirror.bazel.build/github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) mirror.bazel.build/github.com/bazelbuild/rules_shell/$1 diff --git a/sphinxdocs/.bazelrc b/sphinxdocs/.bazelrc index caefd0af53..acff835394 100644 --- a/sphinxdocs/.bazelrc +++ b/sphinxdocs/.bazelrc @@ -15,6 +15,7 @@ build --enable_runfiles # Local disk cache greatly speeds up builds if the regular cache is lost common --disk_cache=~/.cache/bazel/bazel-disk-cache +common --experimental_downloader_config=downloader_config.cfg common --incompatible_python_disallow_native_rules common --incompatible_no_implicit_file_export diff --git a/sphinxdocs/downloader_config.cfg b/sphinxdocs/downloader_config.cfg new file mode 100644 index 0000000000..a978fb89b9 --- /dev/null +++ b/sphinxdocs/downloader_config.cfg @@ -0,0 +1,16 @@ +# Try GitHub first (primary) +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) github.com/bazelbuild/rules_shell/$1 + +# Fall back to mirror (secondary) +# Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) mirror.bazel.build/github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) mirror.bazel.build/github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) mirror.bazel.build/github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) mirror.bazel.build/github.com/bazelbuild/rules_shell/$1 From c0fef4677ab0be33b76cd394af5e56002e8a114f Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:58:51 +0900 Subject: [PATCH 005/179] fix(uv): respect uv.tool settings in pyproject.toml (#3811) With this we auto-detect the location of the project file based on where the first pyproject.toml file is located. Whilst this may work for majority of the cases there could be a case where the user wants to leverage the workspaces, we iterate through all pyproject.toml files and choose the one with the shortest directory path. If this does not work, we you can just override it manually. Along the way, give some advice to agents for handling flakey CI and pushing changes to PRs. Fixes #3807 --------- Co-authored-by: Richard Levasseur --- AGENTS.md | 22 ++ CHANGELOG.md | 2 + docs/pypi/lock.md | 51 +++- python/uv/lock.bzl | 22 ++ python/uv/private/lock.bzl | 49 +++- tests/uv/lock/BUILD.bazel | 5 + tests/uv/lock/lock_tests.bzl | 1 + tests/uv/lock/pyproject_toml/BUILD.bazel | 30 +++ tests/uv/lock/pyproject_toml/pyproject.toml | 8 + tests/uv/lock/pyproject_toml/requirements.txt | 18 ++ tests/uv/lock/workspaces/BUILD.bazel | 27 ++ tests/uv/lock/workspaces/packages/BUILD.bazel | 5 + .../lock/workspaces/packages/foo/BUILD.bazel | 1 + .../workspaces/packages/foo/pyproject.toml | 7 + tests/uv/lock/workspaces/pyproject.toml | 10 + tests/uv/lock/workspaces/requirements.txt | 242 ++++++++++++++++++ 16 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 tests/uv/lock/pyproject_toml/BUILD.bazel create mode 100644 tests/uv/lock/pyproject_toml/pyproject.toml create mode 100644 tests/uv/lock/pyproject_toml/requirements.txt create mode 100644 tests/uv/lock/workspaces/BUILD.bazel create mode 100644 tests/uv/lock/workspaces/packages/BUILD.bazel create mode 100644 tests/uv/lock/workspaces/packages/foo/BUILD.bazel create mode 100644 tests/uv/lock/workspaces/packages/foo/pyproject.toml create mode 100644 tests/uv/lock/workspaces/pyproject.toml create mode 100644 tests/uv/lock/workspaces/requirements.txt diff --git a/AGENTS.md b/AGENTS.md index 65c5baf64b..4e1e88aeac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,11 @@ into the sentence, not verbatim. When adding `{versionadded}` or `{versionchanged}` sections, add them add the end of the documentation text. +### PR Updates + +Once a PR is created, create new commits and merges. Don't use rebase or amend +because it interferes with code review comments. + ### PR descriptions Follow the advice in `CONTRIBUTING.md` for PR descriptions. PR descriptions @@ -150,6 +155,8 @@ This repository contains 3 Bazel bzlmod modules. * `gazelle/` is for the `@rules_python_gazelle_plugin` module. * All other code is part of `@rules_python`. +`tests/support/` contains utility code and helpers for testing. + `python/config_settings/BUILD.bazel` contains build flags that are part of the public API. DO NOT add, remove, or modify these build flags unless specifically @@ -213,3 +220,18 @@ load("//python/private:foo.bzl", "foo") # buildifier: disable=bzl-visibility When inspecting CI failures, if the failure is due to a network error downloading a repository, check if that rule set is mirrored on mirror.bazel.build. If so, add it to the downloader config. + +### CI Flakiness and Monitoring + +CI is known to have flakey network issues. When submitting or updating a PR, +start a background agent that continuously monitors the latest build of a PR and +checks for flakey network errors (e.g., 504 gateway errors). + +If Buildkite permissions allow, retry failures and use available Buildkite +skills. If permissions do not allow, modify `.bazelrc` to set flags (such as +`--http_timeout_scaling` or `--experimental_repository_downloader_retries`) to +make downloads retry more. + +If downloads fail for a dependency, check if that dependency is available on +mirror.bazel.build and, if so, add it to downloader_config.cfg if it isn't +already there. diff --git a/CHANGELOG.md b/CHANGELOG.md index 33adfa7a60..99876bde0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,8 @@ END_UNRELEASED_TEMPLATE * (uv) fix the execution of the `uv pip compile` in the sandbox. Work towards better supporting `uv` out of the box on our platforms. ([#1975](https://github.com/bazel-contrib/rules_python/issues/1975)) +* (uv) automatically pass the `--project` parameter based on the source files. + ([#3087](https://github.com/bazel-contrib/rules_python/issues/3087)) {#v0-0-0-added} ### Added diff --git a/docs/pypi/lock.md b/docs/pypi/lock.md index b5d8ec24f7..5c9f0646dc 100644 --- a/docs/pypi/lock.md +++ b/docs/pypi/lock.md @@ -68,8 +68,55 @@ compile_pip_requirements( ### uv pip compile (bzlmod only) -We also have experimental setup for the `uv pip compile` way of generating lock files. +We also have an experimental setup for the `uv pip compile` way of generating lock files. This is well tested with the public PyPI index, but you may hit some rough edges with private mirrors. -For more documentation see {obj}`lock` documentation. +#### Example usage + +```starlark +load("@rules_python//python/uv:lock.bzl", "lock") + +lock( + name = "requirements", + srcs = ["pyproject.toml", "requirements.in"], + out = "requirements_lock.txt", +) +``` + +#### `[tool.uv]` settings from pyproject.toml + +When a `pyproject.toml` file is among the {attr}`lock.srcs`, the +{obj}`lock` rule auto-detects the project directory and passes +`--project ` to `uv pip compile`. This causes `uv` to read +`[tool.uv]` settings from that `pyproject.toml`, such as +`no-build-isolation`, `exclude-dependencies`, and workspace members. + +If multiple `pyproject.toml` files are in {attr}`lock.srcs`, the one +with the shortest directory path is selected (this heuristic works for +typical uv workspace layouts where the root configuration is at the +shortest path). + +If the auto-detection picks the wrong project directory, use the +`project` parameter to override: + +```starlark +lock( + name = "requirements", + srcs = ["pyproject.toml", "requirements.in"], + out = "requirements_lock.txt", + project = "subproject", +) +``` + +:::{warning} +**Known limitations of auto-detection** + +1. **Workspace heuristic** — the shortest-path selection may incorrectly assume the upper-most + workspace `pyproject.toml` is the correct one. For monorepos with multiple independent + sub-projects, you must set `project` explicitly for each {obj}`lock` target. +1. **No test target** — unlike {obj}`compile_pip_requirements`, no test target is auto-created; see + the {obj}`lock` docs for how to add one manually using `diff_test` from `bazel_skylib`. +::: + +For more documentation see {obj}`lock`. diff --git a/python/uv/lock.bzl b/python/uv/lock.bzl index 7bcca780a0..7fd50082ea 100644 --- a/python/uv/lock.bzl +++ b/python/uv/lock.bzl @@ -45,6 +45,28 @@ native_test( ) ``` +### `[tool.uv]` settings support + +When a `pyproject.toml` file is included in {attr}`lock.srcs`, the +`--project` flag is automatically passed to `uv pip compile` using the +directory of the shortest-path `pyproject.toml`. This causes `uv` to +read `[tool.uv]` settings such as `no-build-isolation`, +`exclude-dependencies`, and `[tool.uv.workspace]` from that file. + +If the auto-detection doesn't select the right project (e.g. in complex +workspace layouts), use the `project` parameter to override it: + +```starlark +lock( + name = "requirements", + srcs = [ + "pyproject.toml", + "requirements.in", + ], + project = "subproject", +) +``` + EXPERIMENTAL: This is experimental and may be changed without notice. """ diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index 6f0b80af89..23f2eed467 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -71,7 +71,8 @@ def _args(ctx): ) def _lock_impl(ctx): - srcs = ctx.files.srcs + srcs = [] + ctx.files.srcs + fname = "{}.out".format(ctx.label.name) python_version = ctx.attr.python_version if python_version: @@ -99,6 +100,27 @@ def _lock_impl(ctx): args.add("--generate-hashes") if not ctx.attr.strip_extras: args.add("--no-strip-extras") + + project = None + if ctx.attr.project: + project = ctx.attr.project + else: + # Autodetect the project based on the `pyproject.toml` location - it will be the first src that + # we see that is named "pyproject.toml" + for src in srcs: + if src.basename == "pyproject.toml": + if project == None: + project = src.dirname + elif len(project) > len(src.dirname): + # select the shortest match + project = src.dirname + + if project == None: + project = pkg + + if project: + args.add_all([project], before_each = "--project") + args.add_all(ctx.files.build_constraints, before_each = "--build-constraints") args.add_all(ctx.files.constraints, before_each = "--constraints") args.add_all(ctx.attr.args) @@ -275,6 +297,19 @@ modifications and the locking is not done from scratch. doc = "Public, see the docs in the macro.", mandatory = True, ), + "project": attr.string( + doc = """\ +Overrides the `--project` directory passed to `uv pip compile`. +If not set, the project directory is auto-detected: when +`pyproject.toml` files are in {obj}`lock.srcs`, the one with the +shortest directory path is selected. This makes `uv` read +`[tool.uv]` settings (e.g. `no-build-isolation`, +`exclude-dependencies`) from that `pyproject.toml`. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + ), "python_version": attr.string( doc = "Public, see the docs in the macro.", ), @@ -438,6 +473,7 @@ def lock( env = None, generate_hashes = True, python_version = None, + project = None, strip_extras = False, **kwargs): """Pin the requirements based on the src files. @@ -484,6 +520,16 @@ def lock( function, but sometimes one may want to not have the extras if you are compiling the requirements file for using it as a constraints file. Defaults to `False`. + project: {type}`str | None` overrides the `--project` directory + passed to `uv pip compile`. By default the project directory + is auto-detected: when {obj}`lock.srcs` contains + `pyproject.toml` files, the one with the shortest directory + path is selected. This causes `uv` to read `[tool.uv]` + settings such as `no-build-isolation` and + `exclude-dependencies` from that `pyproject.toml`. If no + `pyproject.toml` is in `srcs` and no `project` is given, the + Bazel package directory is used as fallback. + {versionadded}VERSION_NEXT_FEATURE python_version: {type}`str | None` the python_version to transition to when locking the requirements. Defaults to the default python version configured by the {obj}`python` module extension. @@ -509,6 +555,7 @@ def lock( env = env, existing_output = maybe_out, generate_hashes = generate_hashes, + project = project, is_windows = select({ "@platforms//os:windows": True, "//conditions:default": False, diff --git a/tests/uv/lock/BUILD.bazel b/tests/uv/lock/BUILD.bazel index 6b6902da44..0b72f015b7 100644 --- a/tests/uv/lock/BUILD.bazel +++ b/tests/uv/lock/BUILD.bazel @@ -1,5 +1,10 @@ load(":lock_tests.bzl", "lock_test_suite") +exports_files( + glob(["testdata/*"]), + visibility = ["//tests:__subpackages__"], +) + lock_test_suite( name = "lock_tests", ) diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl index bcaed95b53..3e067f3e73 100644 --- a/tests/uv/lock/lock_tests.bzl +++ b/tests/uv/lock/lock_tests.bzl @@ -91,6 +91,7 @@ def lock_test_suite(name): name = name, tests = [ ":requirements_test", + "//tests/uv/lock/pyproject_toml:requirements_test", ":requirements_run_tests", ], ) diff --git a/tests/uv/lock/pyproject_toml/BUILD.bazel b/tests/uv/lock/pyproject_toml/BUILD.bazel new file mode 100644 index 0000000000..a64fe50d14 --- /dev/null +++ b/tests/uv/lock/pyproject_toml/BUILD.bazel @@ -0,0 +1,30 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("//python/uv:lock.bzl", "lock") + +# This test verifies that the `lock` rule automatically passes `--project` to `uv pip compile` based +# on the package directory, so that `[tool.uv]` settings from a `pyproject.toml` in the same +# directory are applied. It will exclude a particular dependency from the lock-file, so it will be +# easy to see if we have any issues. +lock( + name = "requirements", + srcs = ["pyproject.toml"], + out = "requirements.txt", + build_constraints = [ + "//tests/uv/lock:testdata/build_constraints.txt", + "//tests/uv/lock:testdata/build_constraints2.txt", + ], + constraints = [ + "//tests/uv/lock:testdata/constraints.txt", + "//tests/uv/lock:testdata/constraints2.txt", + ], + # It seems that the CI remote executors for the RBE do not have network + # connectivity due to current CI setup. + tags = ["no-remote-exec"], +) + +diff_test( + name = "requirements_test", + timeout = "short", + file1 = ":requirements", + file2 = "requirements.txt", +) diff --git a/tests/uv/lock/pyproject_toml/pyproject.toml b/tests/uv/lock/pyproject_toml/pyproject.toml new file mode 100644 index 0000000000..06b96309da --- /dev/null +++ b/tests/uv/lock/pyproject_toml/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "test" +version = "0.0.0" +dependencies = ["requests"] + +[tool.uv] +no-build-isolation = true +exclude-dependencies = ["charset-normalizer"] diff --git a/tests/uv/lock/pyproject_toml/requirements.txt b/tests/uv/lock/pyproject_toml/requirements.txt new file mode 100644 index 0000000000..4ea098b0e4 --- /dev/null +++ b/tests/uv/lock/pyproject_toml/requirements.txt @@ -0,0 +1,18 @@ +# This file was autogenerated by uv via the following command: +# bazel run //tests/uv/lock/pyproject_toml:requirements.update +certifi==2025.1.31 \ + --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \ + --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe + # via requests +idna==3.10 \ + --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ + --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 + # via requests +requests==2.32.3 \ + --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ + --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 + # via test (tests/uv/lock/pyproject_toml/pyproject.toml) +urllib3==2.3.0 \ + --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ + --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d + # via requests diff --git a/tests/uv/lock/workspaces/BUILD.bazel b/tests/uv/lock/workspaces/BUILD.bazel new file mode 100644 index 0000000000..aa1fabe2e3 --- /dev/null +++ b/tests/uv/lock/workspaces/BUILD.bazel @@ -0,0 +1,27 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("//python/uv:lock.bzl", "lock") +load("//tests/support:support.bzl", "NOT_WINDOWS") + +# This test verifies that the `lock` rule automatically passes `--project` to `uv pip compile` based +# on the package directory, so that `[tool.uv]` settings from a `pyproject.toml` in the same +# directory are applied. It will exclude a particular dependency from the lock-file, so it will be +# easy to see if we have any issues. +lock( + name = "requirements", + srcs = [ + "pyproject.toml", + "//tests/uv/lock/workspaces/packages", + ], + out = "requirements.txt", + # It seems that the CI remote executors for the RBE do not have network + # connectivity due to current CI setup. + tags = ["no-remote-exec"], +) + +diff_test( + name = "requirements_test", + timeout = "short", + file1 = ":requirements", + file2 = "requirements.txt", + target_compatible_with = NOT_WINDOWS, +) diff --git a/tests/uv/lock/workspaces/packages/BUILD.bazel b/tests/uv/lock/workspaces/packages/BUILD.bazel new file mode 100644 index 0000000000..5461287d9f --- /dev/null +++ b/tests/uv/lock/workspaces/packages/BUILD.bazel @@ -0,0 +1,5 @@ +filegroup( + name = "packages", + srcs = ["//tests/uv/lock/workspaces/packages/foo:pyproject.toml"], + visibility = ["//tests/uv/lock/workspaces:__pkg__"], +) diff --git a/tests/uv/lock/workspaces/packages/foo/BUILD.bazel b/tests/uv/lock/workspaces/packages/foo/BUILD.bazel new file mode 100644 index 0000000000..d89a7b784e --- /dev/null +++ b/tests/uv/lock/workspaces/packages/foo/BUILD.bazel @@ -0,0 +1 @@ +exports_files(["pyproject.toml"]) diff --git a/tests/uv/lock/workspaces/packages/foo/pyproject.toml b/tests/uv/lock/workspaces/packages/foo/pyproject.toml new file mode 100644 index 0000000000..f26ea85b51 --- /dev/null +++ b/tests/uv/lock/workspaces/packages/foo/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "foo" +version = "0.0.0" +dependencies = ["black"] + +[tool.uv] +no-build-isolation = true diff --git a/tests/uv/lock/workspaces/pyproject.toml b/tests/uv/lock/workspaces/pyproject.toml new file mode 100644 index 0000000000..01e1e2e076 --- /dev/null +++ b/tests/uv/lock/workspaces/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "test" +version = "0.0.0" +dependencies = ["requests"] + +[tool.uv] +no-build-isolation = true + +[tool.uv.workspace] +members = ["packages/*"] diff --git a/tests/uv/lock/workspaces/requirements.txt b/tests/uv/lock/workspaces/requirements.txt new file mode 100644 index 0000000000..5ce35d7571 --- /dev/null +++ b/tests/uv/lock/workspaces/requirements.txt @@ -0,0 +1,242 @@ +# This file was autogenerated by uv via the following command: +# bazel run //tests/uv/lock/workspaces:requirements.update +black==26.5.1 \ + --hash=sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7 \ + --hash=sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418 \ + --hash=sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe \ + --hash=sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0 \ + --hash=sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4 \ + --hash=sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3 \ + --hash=sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3 \ + --hash=sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3 \ + --hash=sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217 \ + --hash=sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8 \ + --hash=sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2 \ + --hash=sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59 \ + --hash=sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50 \ + --hash=sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22 \ + --hash=sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52 \ + --hash=sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef \ + --hash=sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90 \ + --hash=sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c \ + --hash=sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893 \ + --hash=sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d \ + --hash=sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264 \ + --hash=sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73 \ + --hash=sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a \ + --hash=sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168 \ + --hash=sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18 \ + --hash=sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294 \ + --hash=sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae + # via foo (tests/uv/lock/workspaces/packages/foo/pyproject.toml) +certifi==2025.1.31 \ + --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \ + --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe + # via requests +charset-normalizer==3.4.7 \ + --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ + --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ + --hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \ + --hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \ + --hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \ + --hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \ + --hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \ + --hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \ + --hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \ + --hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \ + --hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \ + --hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \ + --hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \ + --hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \ + --hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \ + --hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \ + --hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \ + --hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \ + --hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \ + --hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \ + --hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \ + --hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \ + --hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \ + --hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \ + --hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \ + --hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \ + --hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \ + --hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \ + --hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \ + --hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \ + --hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \ + --hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \ + --hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \ + --hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \ + --hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \ + --hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \ + --hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \ + --hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \ + --hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \ + --hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \ + --hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \ + --hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \ + --hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \ + --hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \ + --hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \ + --hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \ + --hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \ + --hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \ + --hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \ + --hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \ + --hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \ + --hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \ + --hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \ + --hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \ + --hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \ + --hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \ + --hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \ + --hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \ + --hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \ + --hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \ + --hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \ + --hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \ + --hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \ + --hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \ + --hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \ + --hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \ + --hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \ + --hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \ + --hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \ + --hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \ + --hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \ + --hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \ + --hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \ + --hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \ + --hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \ + --hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \ + --hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \ + --hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \ + --hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \ + --hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \ + --hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \ + --hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \ + --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \ + --hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \ + --hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \ + --hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \ + --hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \ + --hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \ + --hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \ + --hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \ + --hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \ + --hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \ + --hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \ + --hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \ + --hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \ + --hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \ + --hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \ + --hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \ + --hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \ + --hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \ + --hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \ + --hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \ + --hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \ + --hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \ + --hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \ + --hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \ + --hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \ + --hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \ + --hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \ + --hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \ + --hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \ + --hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \ + --hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \ + --hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \ + --hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \ + --hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \ + --hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \ + --hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \ + --hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \ + --hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \ + --hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \ + --hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \ + --hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \ + --hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \ + --hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \ + --hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \ + --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ + --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ + --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 + # via requests +click==8.4.1 \ + --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ + --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 + # via black +idna==3.10 \ + --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ + --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 + # via requests +mypy-extensions==1.1.0 \ + --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ + --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 + # via black +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via black +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via black +platformdirs==4.10.0 \ + --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ + --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a + # via black +pytokens==0.4.1 \ + --hash=sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1 \ + --hash=sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009 \ + --hash=sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083 \ + --hash=sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1 \ + --hash=sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de \ + --hash=sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2 \ + --hash=sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a \ + --hash=sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1 \ + --hash=sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5 \ + --hash=sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a \ + --hash=sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3 \ + --hash=sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db \ + --hash=sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68 \ + --hash=sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037 \ + --hash=sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321 \ + --hash=sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc \ + --hash=sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7 \ + --hash=sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f \ + --hash=sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918 \ + --hash=sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9 \ + --hash=sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c \ + --hash=sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1 \ + --hash=sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1 \ + --hash=sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3 \ + --hash=sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b \ + --hash=sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb \ + --hash=sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1 \ + --hash=sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a \ + --hash=sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4 \ + --hash=sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa \ + --hash=sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78 \ + --hash=sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe \ + --hash=sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9 \ + --hash=sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d \ + --hash=sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975 \ + --hash=sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440 \ + --hash=sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16 \ + --hash=sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc \ + --hash=sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d \ + --hash=sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6 \ + --hash=sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6 \ + --hash=sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324 + # via black +requests==2.32.3 \ + --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ + --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 + # via test (tests/uv/lock/workspaces/pyproject.toml) +urllib3==2.3.0 \ + --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ + --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d + # via requests From 42c8e752e4f236b7fa0e88d77b751ea663f939d2 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 9 Jun 2026 02:55:45 -0700 Subject: [PATCH 006/179] feat(toolchains): support runtime registration from manifest (#3802) Currently, all supported Python runtime versions and their platform-specific metadata (URLs, SHA256s, strip_prefix) must be hardcoded in `python/versions.bzl`. This makes it slow and difficult to adopt new Python versions or custom builds without updating `rules_python` itself. This PR introduces the ability to dynamically fetch and register Python runtimes from a remote python-build-standalone (PBS) manifest file (e.g., `SHA256SUMS`). This is supported via two new attributes in `python.override`: - `add_runtime_manifest_urls`: A list of URLs pointing to manifest files to parse and register. - `runtime_manifest_sha`: The SHA256 hash of the manifest file. The manifest file format is the python-build-standalone SHA256SUMS format (`SHA FILENAME`), extended to allow arbitrary URLs for the filename (to allow more arbitrary locations). --- .bazelrc.deleted_packages | 1 + CHANGELOG.md | 3 + docs/toolchains.md | 70 +++++++ python/private/BUILD.bazel | 6 + python/private/pbs_manifest.bzl | 153 +++++++++++++++ python/private/python.bzl | 136 ++++++++++++- tests/integration/BUILD.bazel | 4 + tests/integration/runtime_manifests/.bazelrc | 4 + .../integration/runtime_manifests/BUILD.bazel | 7 + .../runtime_manifests/MODULE.bazel | 19 ++ tests/integration/runtime_manifests/WORKSPACE | 1 + .../runtime_manifests/basic_test.py | 27 +++ tests/python/BUILD.bazel | 4 +- tests/python/python_tests.bzl | 15 ++ tests/python_bzlmod_ext/BUILD.bazel | 7 + .../parse_sha_manifest_tests.bzl | 183 ++++++++++++++++++ .../runtime_manifests_tests.bzl | 163 ++++++++++++++++ tests/python_bzlmod_ext/test_helpers.bzl | 48 +++++ tests/support/mocks/python_ext.bzl | 1 + .../private/debug/print_defined_toolchains.sh | 14 ++ 20 files changed, 862 insertions(+), 4 deletions(-) create mode 100644 python/private/pbs_manifest.bzl create mode 100644 tests/integration/runtime_manifests/.bazelrc create mode 100644 tests/integration/runtime_manifests/BUILD.bazel create mode 100644 tests/integration/runtime_manifests/MODULE.bazel create mode 100644 tests/integration/runtime_manifests/WORKSPACE create mode 100644 tests/integration/runtime_manifests/basic_test.py create mode 100644 tests/python_bzlmod_ext/BUILD.bazel create mode 100644 tests/python_bzlmod_ext/parse_sha_manifest_tests.bzl create mode 100644 tests/python_bzlmod_ext/runtime_manifests_tests.bzl create mode 100644 tests/python_bzlmod_ext/test_helpers.bzl create mode 100755 tools/private/debug/print_defined_toolchains.sh diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index 7256937a87..407fd1cb48 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -38,6 +38,7 @@ common --deleted_packages=tests/integration/pip_parse common --deleted_packages=tests/integration/pip_parse/empty common --deleted_packages=tests/integration/pip_parse_isolated common --deleted_packages=tests/integration/py_cc_toolchain_registered +common --deleted_packages=tests/integration/runtime_manifests common --deleted_packages=tests/integration/toolchain_target_settings common --deleted_packages=tests/integration/uv_lock common --deleted_packages=tests/modules/another_module diff --git a/CHANGELOG.md b/CHANGELOG.md index 99876bde0d..43e05b6dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,9 @@ END_UNRELEASED_TEMPLATE {#v0-0-0-added} ### Added +* (toolchains) Support dynamically fetching and registering Python runtimes + from a python-build-standalone manifest file using + `python.override(add_runtime_manifest_urls = ..., runtime_manifest_sha = ...)`. * (toolchain) Added {obj}`python.override.toolchain_target_settings` to allow adding `config_setting` labels to all registered toolchains. * (windows) Full venv support for Windows is available. Set diff --git a/docs/toolchains.md b/docs/toolchains.md index 09aaed412b..80884baedf 100644 --- a/docs/toolchains.md +++ b/docs/toolchains.md @@ -242,6 +242,9 @@ existing attributes: {attr}`python.single_version_platform_override.coverage_tool`. * Adding additional Python versions via {bzl:obj}`python.single_version_override` or {bzl:obj}`python.single_version_platform_override`. +* Adding additional Python versions dynamically from a manifest file or URL + via {attr}`python.override.add_runtime_manifest_files` or + {attr}`python.override.add_runtime_manifest_urls`. ### Registering custom runtimes @@ -310,6 +313,73 @@ Added support for custom platform names, `target_compatible_with`, and `target_settings` with `single_version_platform_override`. ::: +### Registering runtimes from a manifest + +If you want to register multiple custom runtimes or versions at once, you can +use a python-build-standalone manifest file. This is useful if you want to +adopt new versions that are not yet built into `rules_python` without having +to manually define each one using `single_version_platform_override`. + +To do this, specify the `add_runtime_manifest_files` or +`add_runtime_manifest_urls` (and `runtime_manifest_sha`) attributes in +`python.override` in your `MODULE.bazel`. + +In the example below, we register all runtimes available in a specific local +or remote PBS release manifest: + +```starlark +# File: MODULE.bazel +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.override( + add_runtime_manifest_files = [ + "@//:SHA256SUMS", + ], + add_runtime_manifest_urls = [ + "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS", + ], + base_url = "https://example.com/downloads", + runtime_manifest_sha = "ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f", +) +``` + +#### Manifest file format + +The manifest must be a plain text file where each line contains the SHA256 hash +and the location of a runtime archive, separated by whitespace: + +``` + +``` + +The `` can be either: +- A relative filename (e.g., + `cpython-3.10.20+20260414-x86_64-unknown-linux-gnu-install_only.tar.zst`). + In this case, the download URL is constructed by appending the filename to + the `base_url` attribute (if using `add_runtime_manifest_files`) or to the + parent directory of each URL in `add_runtime_manifest_urls` (treating them + as mirrors). +- An absolute URL (e.g., + `https://example.com/downloads/cpython-3.10.20+20260414-x86_64-unknown-linux-gnu-install_only.tar.zst`). + In this case, the URL is used directly to download the archive. + +In both cases, the filename or the last path segment of the URL must follow +the standard python-build-standalone naming convention. `rules_python` parses +this name to extract runtime metadata (such as Python version, target +architecture, operating system, and libc). + +Notes: +- `rules_python` will read or download the manifest, parse it, and + automatically register toolchains for all valid Python runtimes found in it + that match supported platforms. +- Only runtimes matching known platforms in `rules_python` will be registered. + +:::{versionadded} VERSION_NEXT_FEATURE +Added support for registering runtimes from a manifest using +`add_runtime_manifest_files`, `add_runtime_manifest_urls`, and +`runtime_manifest_sha` in `python.override`. +::: + + ### Using defined toolchains from WORKSPACE It is possible to use toolchains defined in `MODULE.bazel` in `WORKSPACE`. For example, diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index 6ab3d546be..b54c198069 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -252,6 +252,11 @@ bzl_library( srcs = ["normalize_name.bzl"], ) +bzl_library( + name = "pbs_manifest_bzl", + srcs = ["pbs_manifest.bzl"], +) + bzl_library( name = "precompile_bzl", srcs = ["precompile.bzl"], @@ -274,6 +279,7 @@ bzl_library( srcs = ["python.bzl"], deps = [ ":full_version_bzl", + ":pbs_manifest_bzl", ":platform_info_bzl", ":python_register_toolchains_bzl", ":pythons_hub_bzl", diff --git a/python/private/pbs_manifest.bzl b/python/private/pbs_manifest.bzl new file mode 100644 index 0000000000..e343a802b3 --- /dev/null +++ b/python/private/pbs_manifest.bzl @@ -0,0 +1,153 @@ +"""Helper functions to parse python-build-standalone manifests.""" + +def parse_filename(filename): + """Parses a python-build-standalone filename (or URL) into its components. + + See https://gregoryszorc.com/docs/python-build-standalone/main/running.html + + Example: cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst + + Args: + filename: The filename or URL of the python-build-standalone release asset. + + Returns: + A dictionary of parsed components if parsed successfully, else None. + """ + basename = filename.rpartition("/")[-1] + if basename.endswith(".tar.zst"): + name = basename.removesuffix(".tar.zst") + elif basename.endswith(".tar.gz"): + name = basename.removesuffix(".tar.gz") + else: + return None + + if not name.startswith("cpython-"): + return None + name = name.removeprefix("cpython-") + + left, plus, tail = name.partition("+") + if plus: + python_version = left + build_version, sep, rest = tail.partition("-") + if not sep: + return None + else: + python_version, sep, rest = left.partition("-") + if not sep: + return None + build_version = "" + + arch, sep, rest = rest.partition("-") + if not sep: + return None + + microarch = "" + arch_base, sep_v, microarch_num = arch.partition("_v") + if sep_v: + arch = arch_base + microarch = "v" + microarch_num + + vendor, sep, rest = rest.partition("-") + if not sep: + return None + + os, sep, rest = rest.partition("-") + if not sep: + return None + + libc = "" + next_part, _, remaining = rest.partition("-") + if os == "linux" and next_part in ["gnu", "musl"]: + libc = next_part + flavor = remaining + elif os == "windows" and next_part == "msvc": + libc = next_part + flavor = remaining + else: + libc = "" + flavor = rest + + freethreaded = False + if flavor.startswith("freethreaded+"): + freethreaded = True + flavor = flavor.removeprefix("freethreaded+") + elif flavor.startswith("freethreaded-"): + freethreaded = True + flavor = flavor.removeprefix("freethreaded-") + elif flavor == "freethreaded": + freethreaded = True + flavor = "" + + archive_flavor = "" + if flavor.endswith("-full"): + archive_flavor = "full" + flavor = flavor.removesuffix("-full") + elif flavor == "full": + archive_flavor = "full" + flavor = "" + elif flavor.endswith("-install_only_stripped"): + archive_flavor = "install_only_stripped" + flavor = flavor.removesuffix("-install_only_stripped") + elif flavor == "install_only_stripped": + archive_flavor = "install_only_stripped" + flavor = "" + elif flavor.endswith("-install_only"): + archive_flavor = "install_only" + flavor = flavor.removesuffix("-install_only") + elif flavor == "install_only": + archive_flavor = "install_only" + flavor = "" + + return { + "arch": arch, + "archive_flavor": archive_flavor, + "build_version": build_version, + "flavor": flavor, + "freethreaded": freethreaded, + "libc": libc, + "location": filename, + "microarch": microarch, + "os": os, + "python_version": python_version, + "vendor": vendor, + } + +def parse_sha_manifest(content): + """Parses the SHA256SUMS file content into a list of structs. + + Args: + content: The raw content of the manifest file. + + Returns: + A list of structs capturing the parsed components of each valid entry. + Each struct contains the following fields: + - arch: CPU architecture (e.g., "x86_64"). + - archive_flavor: Release asset archive type (e.g., "full", "install_only"). + - build_version: Standalone release date (e.g., "20260414"). + - location: Full package filename or URL (e.g., "cpython-3.11.15..." or "https://..."). + - flavor: Build configuration flavor (e.g., "install_only"). + - freethreaded: Whether the build is free-threaded (boolean). + - libc: C library type (e.g., "gnu", "musl", "msvc", or ""). + - microarch: Microarchitecture level (e.g., "v2", "v3", or ""). + - os: Operating system (e.g., "linux", "darwin", "windows"). + - python_version: Python semver version (e.g., "3.11.15"). + - sha256: SHA256 integrity hash of the release asset. + - vendor: Platform vendor (e.g., "unknown", "apple"). + """ + results = [] + for line in content.split("\n"): + line = line.strip() + if not line: + continue + parts = [p for p in line.split(" ") if p] + if len(parts) != 2: + continue + sha256, filename = parts + + parsed = parse_filename(filename) + if parsed: + results.append(struct( + sha256 = sha256, + **parsed + )) + return results diff --git a/python/private/python.bzl b/python/private/python.bzl index 6abc81e3d2..73b2d19839 100644 --- a/python/private/python.bzl +++ b/python/private/python.bzl @@ -18,6 +18,7 @@ load("@bazel_features//:features.bzl", "bazel_features") load("//python:versions.bzl", "DEFAULT_RELEASE_BASE_URL", "PLATFORMS", "TOOL_VERSIONS") load(":auth.bzl", "AUTH_ATTRS") load(":full_version.bzl", "full_version") +load(":pbs_manifest.bzl", "parse_sha_manifest") load(":platform_info.bzl", "platform_info") load(":python_register_toolchains.bzl", "python_register_toolchains") load(":pythons_hub.bzl", "hub_repo") @@ -76,7 +77,7 @@ def parse_modules(*, module_ctx, logger = None, _fail = fail): # Map of string Major.Minor or Major.Minor.Patch to the toolchain_info struct global_toolchain_versions = {} - config = _get_toolchain_config(modules = module_ctx.modules, _fail = _fail) + config = _get_toolchain_config(mctx = module_ctx, modules = module_ctx.modules, _fail = _fail) default_python_version = _compute_default_python_version(module_ctx) @@ -741,10 +742,89 @@ def _override_defaults(*overrides, modules, _fail = fail, default): override.fn(tag = tag, _fail = _fail, default = default) -def _get_toolchain_config(*, modules, _fail = fail): +def _populate_from_pbs_manifest( + *, + mctx, + add_runtime_manifest_urls = [], + add_runtime_manifest_files = [], + runtime_manifest_sha = "", + base_url = "", + available_versions, + _fail): + manifest_contents = [] + + if add_runtime_manifest_urls: + manifest_path = mctx.path("runtime_manifest") + result = mctx.download( + url = add_runtime_manifest_urls, + output = manifest_path, + sha256 = runtime_manifest_sha, + ) + if not result.success: + _fail("Failed to download manifest from {}: {}".format(add_runtime_manifest_urls, result)) + return + manifest_contents.append(mctx.read(manifest_path)) + + for manifest_file in add_runtime_manifest_files: + manifest_contents.append(mctx.read(manifest_file, watch = "yes")) + + if not manifest_contents: + return + + base_download_urls = [url.rpartition("/")[0] for url in add_runtime_manifest_urls] + if not base_download_urls and base_url: + base_download_urls = [base_url] + + entries = [] + for content in manifest_contents: + entries.extend(parse_sha_manifest(content)) + + # We don't model archive_flavor via flags yet, so have to pick one. + # Preference is given to install_only because its smaller + entries = sorted( + entries, + key = lambda e: {"full": 3, "install_only": 1, "install_only_stripped": 2}.get(e.archive_flavor, 4), + ) + + for entry in entries: + location = entry.location + sha256 = entry.sha256 + py_version = entry.python_version + + # Fallback to matching against PLATFORMS keys as before to ensure compatibility + # with rules_python expected platform keys. + matched_platform = None + for platform in PLATFORMS.keys(): + if platform in location: + matched_platform = platform + break + + if not matched_platform: + continue + + if entry.archive_flavor not in ["install_only", "install_only_stripped", "full"]: + continue + + v_dict = available_versions.setdefault(py_version, {}) + if matched_platform in v_dict.get("sha256", {}): + continue + + if "://" in location: + urls = [location] + else: + urls = ["{}/{}".format(b_url, location) for b_url in base_download_urls] + + strip_prefix = "python/install" if entry.archive_flavor == "full" else "python" + + v_dict.setdefault("sha256", {})[matched_platform] = sha256 + v_dict.setdefault("url", {})[matched_platform] = urls + v_dict.setdefault("strip_prefix", {})[matched_platform] = strip_prefix + +def _get_toolchain_config(*, mctx, modules, _fail = fail): """Computes the configs for toolchains. Args: + mctx: The module context. modules: The modules from module_ctx _fail: Function to call for failing; only used for testing. @@ -786,6 +866,21 @@ def _get_toolchain_config(*, modules, _fail = fail): else: available_versions[py_version]["url"] = dict(url) + # Check for add_runtime_manifest_urls or add_runtime_manifest_files in override tags in root module + root_module = modules[0] if modules else None + if root_module and root_module.is_root: + for tag in root_module.tags.override: + if tag.add_runtime_manifest_urls or tag.add_runtime_manifest_files: + _populate_from_pbs_manifest( + mctx = mctx, + add_runtime_manifest_urls = tag.add_runtime_manifest_urls, + add_runtime_manifest_files = tag.add_runtime_manifest_files, + runtime_manifest_sha = tag.runtime_manifest_sha, + base_url = tag.base_url, + available_versions = available_versions, + _fail = _fail, + ) + default = { "base_url": DEFAULT_RELEASE_BASE_URL, "platforms": dict(PLATFORMS), # Copy so it's mutable. @@ -1111,6 +1206,34 @@ _override = tag_class( ::: """, attrs = { + "add_runtime_manifest_files": attr.label_list( + mandatory = False, + allow_files = True, + doc = """ +Labels pointing to local python-build-standalone manifest files (e.g., `SHA256SUMS`). + +Example: +`//my/custom/manifest:SHA256SUMS` + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + ), + "add_runtime_manifest_urls": attr.string_list( + mandatory = False, + doc = """ +URLs pointing to python-build-standalone manifest files (e.g., SHA256SUMS). + +Example: +`https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS` + +Note that `/latest/` can be used in place of a specific release date (e.g., `20260414`) to automatically use the latest release: +`https://github.com/astral-sh/python-build-standalone/releases/latest/download/SHA256SUMS` + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + ), "add_target_settings": attr.string_list( mandatory = False, doc = """\ @@ -1180,6 +1303,15 @@ The values in this mapping override the default values and do not replace them. default = {}, ), "register_all_versions": attr.bool(default = False, doc = "Add all versions"), + "runtime_manifest_sha": attr.string( + mandatory = False, + doc = """ +SHA256 hash for the add_runtime_manifest_urls. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + ), } | AUTH_ATTRS, ) diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel index a6027fc3d4..904fb4c247 100644 --- a/tests/integration/BUILD.bazel +++ b/tests/integration/BUILD.bazel @@ -104,6 +104,10 @@ rules_python_integration_test( workspace_path = "py_cc_toolchain_registered", ) +rules_python_integration_test( + name = "runtime_manifests_test", +) + rules_python_integration_test( name = "custom_commands_test", py_main = "custom_commands_test.py", diff --git a/tests/integration/runtime_manifests/.bazelrc b/tests/integration/runtime_manifests/.bazelrc new file mode 100644 index 0000000000..d4d45a5ea7 --- /dev/null +++ b/tests/integration/runtime_manifests/.bazelrc @@ -0,0 +1,4 @@ +# Copy of fast-tests config +common:fast-tests --build_tests_only=true +common:fast-tests --build_tag_filters=-large,-enormous,-integration-test +common:fast-tests --test_tag_filters=-large,-enormous,-integration-test diff --git a/tests/integration/runtime_manifests/BUILD.bazel b/tests/integration/runtime_manifests/BUILD.bazel new file mode 100644 index 0000000000..4746fd419b --- /dev/null +++ b/tests/integration/runtime_manifests/BUILD.bazel @@ -0,0 +1,7 @@ +load("@rules_python//python:py_test.bzl", "py_test") + +py_test( + name = "basic_test", + srcs = ["basic_test.py"], + python_version = "3.11", +) diff --git a/tests/integration/runtime_manifests/MODULE.bazel b/tests/integration/runtime_manifests/MODULE.bazel new file mode 100644 index 0000000000..6891b07818 --- /dev/null +++ b/tests/integration/runtime_manifests/MODULE.bazel @@ -0,0 +1,19 @@ +module(name = "runtime_manifests") + +bazel_dep(name = "rules_python", version = "0.0.0") +local_path_override( + module_name = "rules_python", + path = "../../..", +) + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.override( + add_runtime_manifest_urls = [ + "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS", + ], + register_all_versions = True, + runtime_manifest_sha = "ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f", +) +python.toolchain( + python_version = "3.11.15", +) diff --git a/tests/integration/runtime_manifests/WORKSPACE b/tests/integration/runtime_manifests/WORKSPACE new file mode 100644 index 0000000000..8277ec8090 --- /dev/null +++ b/tests/integration/runtime_manifests/WORKSPACE @@ -0,0 +1 @@ +# Workspace boundary file required by rules_bazel_integration_test diff --git a/tests/integration/runtime_manifests/basic_test.py b/tests/integration/runtime_manifests/basic_test.py new file mode 100644 index 0000000000..35f0e93b6e --- /dev/null +++ b/tests/integration/runtime_manifests/basic_test.py @@ -0,0 +1,27 @@ +import datetime +import platform +import sys +import unittest + + +class BasicTest(unittest.TestCase): + def test_basic(self): + print("Hello World from Python {}!".format(sys.version)) + print("Interpreter executable path: {}".format(sys.executable)) + + # Verify that the hermetic interpreter inside Bazel's output/sandbox tree is used + self.assertIn(".cache/bazel", sys.executable) + + # Verify that the exact custom version (3.11.15) parsed from the manifest is used + self.assertEqual(sys.version_info[:3], (3, 11, 15)) + + # Verify that the exact build version (20260414) parsed from the manifest is used + buildno, builddate = platform.python_build() + date_str = " ".join(builddate.split()[:3]) + dt = datetime.datetime.strptime(date_str, "%b %d %Y") + formatted_date = dt.strftime("%Y%m%d") + self.assertEqual(formatted_date, "20260414") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/BUILD.bazel b/tests/python/BUILD.bazel index 2553536b63..887fe969b5 100644 --- a/tests/python/BUILD.bazel +++ b/tests/python/BUILD.bazel @@ -12,6 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -load(":python_tests.bzl", "python_test_suite") +load(":python_tests.bzl", "register_python_tests") -python_test_suite(name = "python_tests") +register_python_tests(name = "python_tests") diff --git a/tests/python/python_tests.bzl b/tests/python/python_tests.bzl index 5db74265be..cd7383942c 100644 --- a/tests/python/python_tests.bzl +++ b/tests/python/python_tests.bzl @@ -16,6 +16,7 @@ load("@pythons_hub//:versions.bzl", "MINOR_MAPPING") load("@rules_testing//lib:test_suite.bzl", "test_suite") +load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//python/private:python.bzl", "parse_modules") # buildifier: disable=bzl-visibility load("//python/private:repo_utils.bzl", "repo_utils") # buildifier: disable=bzl-visibility load("//tests/support/mocks:mocks.bzl", "mocks") @@ -878,3 +879,17 @@ def python_test_suite(name): name: the name of the test suite """ test_suite(name = name, basic_tests = _tests) + +def register_python_tests(name): + """Registers the python tests if Bzlmod is enabled, otherwise defines an empty test_suite. + + Args: + name: The name of the test target. + """ + if BZLMOD_ENABLED: + python_test_suite(name = name) + else: + native.test_suite( + name = name, + tests = [], + ) diff --git a/tests/python_bzlmod_ext/BUILD.bazel b/tests/python_bzlmod_ext/BUILD.bazel new file mode 100644 index 0000000000..0add4e7690 --- /dev/null +++ b/tests/python_bzlmod_ext/BUILD.bazel @@ -0,0 +1,7 @@ +load(":test_helpers.bzl", "register_python_bzlmod_ext_tests") + +register_python_bzlmod_ext_tests( + name = "python_bzlmod_ext_tests", + parse_sha_manifest_name = "parse_sha_manifest_tests", + runtime_manifests_name = "runtime_manifests_tests", +) diff --git a/tests/python_bzlmod_ext/parse_sha_manifest_tests.bzl b/tests/python_bzlmod_ext/parse_sha_manifest_tests.bzl new file mode 100644 index 0000000000..4ddcdcc46e --- /dev/null +++ b/tests/python_bzlmod_ext/parse_sha_manifest_tests.bzl @@ -0,0 +1,183 @@ +"""Tests for manifest parsing Starlark functions.""" + +load("@bazel_skylib//lib:structs.bzl", "structs") +load("@rules_testing//lib:analysis_test.bzl", "analysis_test") +load("@rules_testing//lib:test_suite.bzl", "test_suite") +load("@rules_testing//lib:util.bzl", rt_util = "util") +load("//python/private:pbs_manifest.bzl", "parse_filename", "parse_sha_manifest") # buildifier: disable=bzl-visibility + +_tests = [] + +def _test_parse_filename_baseline(name): + """Sets up the baseline filename parsing test. + + Args: + name: The name of the test. + """ + rt_util.helper_target( + native.filegroup, + name = name + "_subject", + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_parse_filename_baseline_impl, + ) + +def _test_parse_filename_baseline_impl(env, target): + _ = target # @unused + + # 1. Baseline + parsed1 = parse_filename("cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz") + env.expect.that_dict(parsed1).contains_exactly({ + "arch": "x86_64", + "archive_flavor": "install_only", + "build_version": "20260414", + "flavor": "", + "freethreaded": False, + "libc": "gnu", + "location": "cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz", + "microarch": "", + "os": "linux", + "python_version": "3.11.15", + "vendor": "unknown", + }) + + # 2. Microarch + parsed2 = parse_filename("cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst") + env.expect.that_dict(parsed2).contains_exactly({ + "arch": "x86_64", + "archive_flavor": "full", + "build_version": "20260414", + "flavor": "lto", + "freethreaded": False, + "libc": "musl", + "location": "cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst", + "microarch": "v2", + "os": "linux", + "python_version": "3.10.20", + "vendor": "unknown", + }) + + # 3. Freethreaded + parsed3 = parse_filename("cpython-3.13.13+20260414-aarch64-apple-darwin-freethreaded+pgo+lto-full.tar.zst") + env.expect.that_dict(parsed3).contains_exactly({ + "arch": "aarch64", + "archive_flavor": "full", + "build_version": "20260414", + "flavor": "pgo+lto", + "freethreaded": True, + "libc": "", + "location": "cpython-3.13.13+20260414-aarch64-apple-darwin-freethreaded+pgo+lto-full.tar.zst", + "microarch": "", + "os": "darwin", + "python_version": "3.13.13", + "vendor": "apple", + }) + + # 4. Invalid + parsed4 = parse_filename("invalid-filename.tar.gz") + env.expect.that_bool(parsed4 == None).equals(True) + + # 5. Full URL (should return the original URL as location) + parsed5 = parse_filename("https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz") + env.expect.that_dict(parsed5).contains_exactly({ + "arch": "x86_64", + "archive_flavor": "install_only", + "build_version": "20260414", + "flavor": "", + "freethreaded": False, + "libc": "gnu", + "location": "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz", + "microarch": "", + "os": "linux", + "python_version": "3.11.15", + "vendor": "unknown", + }) + +_tests.append(_test_parse_filename_baseline) + +def _test_parse_sha_manifest(name): + """Sets up the manifest file parsing test. + + Args: + name: The name of the test. + """ + rt_util.helper_target( + native.filegroup, + name = name + "_subject", + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_parse_sha_manifest_impl, + ) + +def _test_parse_sha_manifest_impl(env, target): + _ = target # @unused + content = """ +8b14030dd3af9ea7f7c51b4c90feb04afd8a8f45435727e67b875270bd08f3bc cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +a57ffd435652092d16b30e783f9826c55e9c64b0f0a72cbae0a9f39e663137fb cpython-3.11.15+20260414-aarch64-apple-darwin-install_only.tar.gz +ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f https://example.com/cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst +1111111111111111111111111111111111111111111111111111111111111111 cpython-3.13.13+20260414-aarch64-apple-darwin-freethreaded+pgo+lto-full.tar.zst +""" + parsed = parse_sha_manifest(content) + env.expect.that_collection(parsed).has_size(4) + + env.expect.that_dict(structs.to_dict(parsed[0])).contains_exactly({ + "arch": "x86_64", + "archive_flavor": "install_only", + "build_version": "20260414", + "flavor": "", + "freethreaded": False, + "libc": "gnu", + "location": "cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz", + "microarch": "", + "os": "linux", + "python_version": "3.11.15", + "sha256": "8b14030dd3af9ea7f7c51b4c90feb04afd8a8f45435727e67b875270bd08f3bc", + "vendor": "unknown", + }) + + env.expect.that_dict(structs.to_dict(parsed[2])).contains_exactly({ + "arch": "x86_64", + "archive_flavor": "full", + "build_version": "20260414", + "flavor": "lto", + "freethreaded": False, + "libc": "musl", + "location": "https://example.com/cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst", + "microarch": "v2", + "os": "linux", + "python_version": "3.10.20", + "sha256": "ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f", + "vendor": "unknown", + }) + + env.expect.that_dict(structs.to_dict(parsed[3])).contains_exactly({ + "arch": "aarch64", + "archive_flavor": "full", + "build_version": "20260414", + "flavor": "pgo+lto", + "freethreaded": True, + "libc": "", + "location": "cpython-3.13.13+20260414-aarch64-apple-darwin-freethreaded+pgo+lto-full.tar.zst", + "microarch": "", + "os": "darwin", + "python_version": "3.13.13", + "sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "vendor": "apple", + }) + +_tests.append(_test_parse_sha_manifest) + +def parse_sha_manifest_test_suite(name): + """Defines the test suite for manifest parsing. + + Args: + name: The name of the test suite. + """ + test_suite( + name = name, + tests = _tests, + ) diff --git a/tests/python_bzlmod_ext/runtime_manifests_tests.bzl b/tests/python_bzlmod_ext/runtime_manifests_tests.bzl new file mode 100644 index 0000000000..c46cab1958 --- /dev/null +++ b/tests/python_bzlmod_ext/runtime_manifests_tests.bzl @@ -0,0 +1,163 @@ +"""Starlark unit tests for dynamic toolchain registration via manifests.""" + +load("@rules_testing//lib:analysis_test.bzl", "analysis_test") +load("@rules_testing//lib:test_suite.bzl", "test_suite") +load("@rules_testing//lib:util.bzl", rt_util = "util") +load("//python/private:python.bzl", "parse_modules") # buildifier: disable=bzl-visibility +load("//python/private:repo_utils.bzl", "repo_utils") # buildifier: disable=bzl-visibility +load("//tests/support/mocks:mocks.bzl", "mocks") # buildifier: disable=bzl-visibility +load("//tests/support/mocks:python_ext.bzl", "python_ext") # buildifier: disable=bzl-visibility + +_tests = [] + +_mock_logger = repo_utils.logger( + name = "mock", + verbosity_level = "ERROR", +) + +def _test_dynamic_manifest_toolchains(name): + rt_util.helper_target( + native.filegroup, + name = name + "_subject", + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_dynamic_manifest_toolchains_impl, + ) + +def _test_dynamic_manifest_toolchains_impl(env, target): + _ = target # @unused + + # Construct Bzlmod mock module locally inside the test execution block. + # We test using virtual patch version "3.11.99" (not present in TOOL_VERSIONS) + # so that the populated config contains ONLY our dynamically parsed manifest keys + # without any pre-populated multi-platform templates, allowing exact dictionary match! + root_module = python_ext.module( + name = "runtime_manifests", + override = [ + python_ext.override( + add_runtime_manifest_urls = [ + "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS", + ], + runtime_manifest_sha = "ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f", + register_all_versions = True, + ), + ], + defaults = [ + python_ext.defaults( + python_version = "3.11.99", + ), + ], + ) + + # Pre-populate mock_files directly to bypass download output struct key mismatch in mock read lookups. + mock_mctx = mocks.mctx( + modules = [root_module], + mock_files = { + "runtime_manifest": """ +01e607cf764b97d4d5d6f69fd1ff3d8a9a162513dde5c39e98260fce40fe220a cpython-3.11.99+20260414-x86_64-unknown-linux-gnu-pgo+lto-full.tar.zst +8b14030dd3af9ea7f7c51b4c90feb04afd8a8f45435727e67b875270bd08f3bc cpython-3.11.99+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +""", + }, + ) + + res = parse_modules( + module_ctx = mock_mctx, + logger = _mock_logger, + ) + + tool_versions = res.config.default["tool_versions"] + env.expect.that_bool("3.11.99" in tool_versions).equals(True) + + version_info = tool_versions["3.11.99"] + + # Assert on the entire dictionary at once! + env.expect.that_dict(version_info).contains_exactly({ + "sha256": { + "x86_64-unknown-linux-gnu": "8b14030dd3af9ea7f7c51b4c90feb04afd8a8f45435727e67b875270bd08f3bc", + }, + "strip_prefix": { + "x86_64-unknown-linux-gnu": "python", + }, + "url": { + "x86_64-unknown-linux-gnu": [ + "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.11.99+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz", + ], + }, + }) + +_tests.append(_test_dynamic_manifest_toolchains) + +def _test_dynamic_manifest_files(name): + rt_util.helper_target( + native.filegroup, + name = name + "_subject", + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_dynamic_manifest_files_impl, + ) + +def _test_dynamic_manifest_files_impl(env, target): + _ = target # @unused + + root_module = python_ext.module( + name = "runtime_manifests", + override = [ + python_ext.override( + add_runtime_manifest_files = [ + Label("//:SHA256SUMS"), + ], + base_url = "https://example.com/dl", + register_all_versions = True, + ), + ], + defaults = [ + python_ext.defaults( + python_version = "3.12.99", + ), + ], + ) + + mock_mctx = mocks.mctx( + modules = [root_module], + mock_files = { + str(Label("//:SHA256SUMS")): """ +01e607cf764b97d4d5d6f69fd1ff3d8a9a162513dde5c39e98260fce40fe220a cpython-3.12.99+20260414-x86_64-unknown-linux-gnu-pgo+lto-full.tar.zst +""", + }, + ) + + res = parse_modules( + module_ctx = mock_mctx, + logger = _mock_logger, + ) + + tool_versions = res.config.default["tool_versions"] + env.expect.that_bool("3.12.99" in tool_versions).equals(True) + + version_info = tool_versions["3.12.99"] + + env.expect.that_dict(version_info).contains_exactly({ + "sha256": { + "x86_64-unknown-linux-gnu": "01e607cf764b97d4d5d6f69fd1ff3d8a9a162513dde5c39e98260fce40fe220a", + }, + "strip_prefix": { + "x86_64-unknown-linux-gnu": "python/install", + }, + "url": { + "x86_64-unknown-linux-gnu": [ + "https://example.com/dl/cpython-3.12.99+20260414-x86_64-unknown-linux-gnu-pgo+lto-full.tar.zst", + ], + }, + }) + +_tests.append(_test_dynamic_manifest_files) + +def runtime_manifests_test_suite(name): + test_suite( + name = name, + tests = _tests, + ) diff --git a/tests/python_bzlmod_ext/test_helpers.bzl b/tests/python_bzlmod_ext/test_helpers.bzl new file mode 100644 index 0000000000..f6c177650a --- /dev/null +++ b/tests/python_bzlmod_ext/test_helpers.bzl @@ -0,0 +1,48 @@ +# Copyright 2026 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. + +"""Helpers to conditionally register tests depending on Bzlmod enablement.""" + +load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility +load(":parse_sha_manifest_tests.bzl", "parse_sha_manifest_test_suite") +load(":runtime_manifests_tests.bzl", "runtime_manifests_test_suite") + +def register_python_bzlmod_ext_tests(name, parse_sha_manifest_name, runtime_manifests_name): + """Registers the Bzlmod extension tests if Bzlmod is enabled, otherwise defines empty test_suites. + + Args: + name: The name of the master test_suite target. + parse_sha_manifest_name: The name of the parse_sha_manifest test target. + runtime_manifests_name: The name of the runtime_manifests test target. + """ + if BZLMOD_ENABLED: + parse_sha_manifest_test_suite(name = parse_sha_manifest_name) + runtime_manifests_test_suite(name = runtime_manifests_name) + else: + native.test_suite( + name = parse_sha_manifest_name, + tests = [], + ) + native.test_suite( + name = runtime_manifests_name, + tests = [], + ) + + native.test_suite( + name = name, + tests = [ + parse_sha_manifest_name, + runtime_manifests_name, + ], + ) diff --git a/tests/support/mocks/python_ext.bzl b/tests/support/mocks/python_ext.bzl index f20a6c7263..f7b5b0ee02 100644 --- a/tests/support/mocks/python_ext.bzl +++ b/tests/support/mocks/python_ext.bzl @@ -26,6 +26,7 @@ def _module(name = "rules_python", is_root = True, **tags): def _override(**kwargs): """Creates a mock python.override tag with default values.""" attrs = { + "add_runtime_manifest_files": [], "add_runtime_manifest_urls": [], "add_target_settings": [], "available_python_versions": [], diff --git a/tools/private/debug/print_defined_toolchains.sh b/tools/private/debug/print_defined_toolchains.sh new file mode 100755 index 0000000000..1694dc975c --- /dev/null +++ b/tools/private/debug/print_defined_toolchains.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Programmatically probe which repository target name is resolved successfully inside this workspace +if bazel query @pythons_hub//... >/dev/null 2>&1; then + HUB_REPO="@pythons_hub" +elif bazel query @@rules_python++python+pythons_hub//... >/dev/null 2>&1; then + HUB_REPO="@@rules_python++python+pythons_hub" +else + HUB_REPO="@@+python+pythons_hub" +fi + +# Query standard toolchains inside the resolved hub repository, excluding CC and Exec Tools toolchains. +bazel query "kind('toolchain', ${HUB_REPO}//...) - filter('_py_cc_toolchain$', ${HUB_REPO}//...) - filter('_py_exec_tools_toolchain$', ${HUB_REPO}//...)" "$@" From 7053e26e858e703d27b2c32a09e09cfd2fdb8e48 Mon Sep 17 00:00:00 2001 From: Tao Wang Date: Thu, 11 Jun 2026 09:46:30 -0700 Subject: [PATCH 007/179] Gazelle: Delete stale py_library and py_test targets (#3817) ## Summary Fix the issue #3375 Right now the `py_library` and `py_test` targets with missing srcs wouldn't be cleaned up by Gazelle in file mode. This can be reproduced by a unit test added in commit ad0e48ce0849ef9a0e89fb75a51345afb1947eb6 ## Testing In commit ad0e48ce0849ef9a0e89fb75a51345afb1947eb6 , without the fix, `bazel test //python/...` failed in the newly added tests. The stale `py_library ` and `py_test ` cannot be removed. In latest HEAD d32fb7bb70a996e2d1215944a17941c5c4a708ed, `bazel test //python/...` can pass ``` INFO: From Testing //python:python_test_remove_invalid_per_file: ==================== Test output for //python:python_test_remove_invalid_per_file: --- FAIL: TestGazelleBinary (0.02s) --- FAIL: TestGazelleBinary/remove_invalid_per_file (0.08s) python_test.go:186: remove_invalid_per_file/BUILD diff (-want,+got): ( """ ... // 8 identical lines ) + py_library( + name = "deleted_lib", + srcs = ["deleted.py"], + visibility = ["//:__subpackages__"], + ) + + py_test( + name = "bar_test", + srcs = ["bar_test.py"], + ) + py_test( - name = "bar_test", - srcs = ["bar_test.py"], + name = "deleted_test", + srcs = ["deleted_test.py"], ) """ ) ``` --------- Co-authored-by: Tao Wang --- CHANGELOG.md | 5 +++ gazelle/python/generate.go | 35 +++++++++++++-- .../BUILD.in | 0 .../BUILD.out | 0 .../README.md | 0 .../WORKSPACE | 0 .../my_test.py | 0 .../others/BUILD.in | 0 .../others/BUILD.out | 0 .../test.yaml | 0 .../testdata/remove_invalid_per_file/BUILD.in | 45 +++++++++++++++++++ .../remove_invalid_per_file/BUILD.out | 24 ++++++++++ .../remove_invalid_per_file/WORKSPACE | 1 + .../remove_invalid_per_file/__main__.py | 0 .../alias_kind/BUILD.in | 37 +++++++++++++++ .../alias_kind/BUILD.out | 35 +++++++++++++++ .../remove_invalid_per_file/alias_kind/bar.py | 0 .../alias_kind/bar_test.py | 0 .../testdata/remove_invalid_per_file/bar.py | 0 .../remove_invalid_per_file/bar_test.py | 0 .../remove_invalid_per_file/map_kind/BUILD.in | 37 +++++++++++++++ .../map_kind/BUILD.out | 19 ++++++++ .../remove_invalid_per_file/map_kind/bar.py | 0 .../map_kind/bar_test.py | 0 .../remove_invalid_per_file/test.yaml | 1 + 25 files changed, 235 insertions(+), 4 deletions(-) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/BUILD.in (100%) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/BUILD.out (100%) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/README.md (100%) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/WORKSPACE (100%) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/my_test.py (100%) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/others/BUILD.in (100%) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/others/BUILD.out (100%) rename gazelle/python/testdata/{remove_invalid_library => remove_invalid_library_package_mode}/test.yaml (100%) create mode 100644 gazelle/python/testdata/remove_invalid_per_file/BUILD.in create mode 100644 gazelle/python/testdata/remove_invalid_per_file/BUILD.out create mode 100644 gazelle/python/testdata/remove_invalid_per_file/WORKSPACE create mode 100644 gazelle/python/testdata/remove_invalid_per_file/__main__.py create mode 100644 gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.in create mode 100644 gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.out create mode 100644 gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar.py create mode 100644 gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar_test.py create mode 100644 gazelle/python/testdata/remove_invalid_per_file/bar.py create mode 100644 gazelle/python/testdata/remove_invalid_per_file/bar_test.py create mode 100644 gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.in create mode 100644 gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.out create mode 100644 gazelle/python/testdata/remove_invalid_per_file/map_kind/bar.py create mode 100644 gazelle/python/testdata/remove_invalid_per_file/map_kind/bar_test.py create mode 100644 gazelle/python/testdata/remove_invalid_per_file/test.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e05b6dd0..978464f9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,11 @@ END_UNRELEASED_TEMPLATE {#v0-0-0-fixed} ### Fixed +* (gazelle) `py_library` and `py_test` targets with missing source files can now be + removed by Gazelle ([#3375](https://github.com/bazel-contrib/rules_python/issues/3375)). + However `map_kind` and `alias_kind` will not be removed unless people are running a + gazelle version that includes + [bazel-gazelle#2362](https://github.com/bazel-contrib/bazel-gazelle/pull/2362) * (bootstrap) Fixed a potential race condition with symlink creation during startup. * (gazelle) Fixed handling of auto-included `__init__.py` files when generating `py_binary` diff --git a/gazelle/python/generate.go b/gazelle/python/generate.go index 6fa6252ebb..90e06a1546 100644 --- a/gazelle/python/generate.go +++ b/gazelle/python/generate.go @@ -593,26 +593,53 @@ func (py *Python) getRulesWithInvalidSrcs(args language.GenerateArgs, validFiles validFilesMap[file] = struct{}{} } + // allFilesMap extends validFilesMap with all regular files on disk. + // py_binary uses validFilesMap (main modules + generated files), while py_library + // and py_test use allFilesMap since any file is a valid src for them. + allFilesMap := make(map[string]struct{}, len(validFilesMap)+len(args.RegularFiles)) + for file := range validFilesMap { + allFilesMap[file] = struct{}{} + } + for _, file := range args.RegularFiles { + allFilesMap[file] = struct{}{} + } + isTarget := func(src string) bool { return strings.HasPrefix(src, "@") || strings.HasPrefix(src, "//") || strings.HasPrefix(src, ":") } for _, existingRule := range args.File.Rules { - if !kindMatches(args.Config, existingRule, pyBinaryKind) { + var matchedKind string + var filesMap map[string]struct{} + if kindMatches(args.Config, existingRule, pyBinaryKind) { + matchedKind = pyBinaryKind + filesMap = validFilesMap + } else if kindMatches(args.Config, existingRule, pyLibraryKind) { + matchedKind = pyLibraryKind + filesMap = allFilesMap + } else if kindMatches(args.Config, existingRule, pyTestKind) { + matchedKind = pyTestKind + filesMap = allFilesMap + } else { + continue + } + + srcs := existingRule.AttrStrings("srcs") + if len(srcs) == 0 { continue } var hasValidSrcs bool - for _, src := range existingRule.AttrStrings("srcs") { + for _, src := range srcs { if isTarget(src) { hasValidSrcs = true break } - if _, ok := validFilesMap[src]; ok { + if _, ok := filesMap[src]; ok { hasValidSrcs = true break } } if !hasValidSrcs { - invalidRules = append(invalidRules, newTargetBuilder(pyBinaryKind, existingRule.Name(), "", "", nil, false).build()) + invalidRules = append(invalidRules, newTargetBuilder(matchedKind, existingRule.Name(), "", "", nil, false).build()) } } return invalidRules diff --git a/gazelle/python/testdata/remove_invalid_library/BUILD.in b/gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.in similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/BUILD.in rename to gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.in diff --git a/gazelle/python/testdata/remove_invalid_library/BUILD.out b/gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.out similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/BUILD.out rename to gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.out diff --git a/gazelle/python/testdata/remove_invalid_library/README.md b/gazelle/python/testdata/remove_invalid_library_package_mode/README.md similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/README.md rename to gazelle/python/testdata/remove_invalid_library_package_mode/README.md diff --git a/gazelle/python/testdata/remove_invalid_library/WORKSPACE b/gazelle/python/testdata/remove_invalid_library_package_mode/WORKSPACE similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/WORKSPACE rename to gazelle/python/testdata/remove_invalid_library_package_mode/WORKSPACE diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_library_package_mode/my_test.py similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/my_test.py rename to gazelle/python/testdata/remove_invalid_library_package_mode/my_test.py diff --git a/gazelle/python/testdata/remove_invalid_library/others/BUILD.in b/gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.in similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/others/BUILD.in rename to gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.in diff --git a/gazelle/python/testdata/remove_invalid_library/others/BUILD.out b/gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.out similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/others/BUILD.out rename to gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.out diff --git a/gazelle/python/testdata/remove_invalid_library/test.yaml b/gazelle/python/testdata/remove_invalid_library_package_mode/test.yaml similarity index 100% rename from gazelle/python/testdata/remove_invalid_library/test.yaml rename to gazelle/python/testdata/remove_invalid_library_package_mode/test.yaml diff --git a/gazelle/python/testdata/remove_invalid_per_file/BUILD.in b/gazelle/python/testdata/remove_invalid_per_file/BUILD.in new file mode 100644 index 0000000000..d3b15a7866 --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/BUILD.in @@ -0,0 +1,45 @@ +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") + +# gazelle:python_generation_mode file + +# Valid py_library — bar.py exists on disk, should be kept. +py_library( + name = "bar", + srcs = ["bar.py"], + visibility = ["//:__subpackages__"], +) + +# Stale py_library — deleted.py does not exist, should be removed. +py_library( + name = "deleted_lib", + srcs = ["deleted.py"], + visibility = ["//:__subpackages__"], +) + +# Valid py_binary — __main__.py exists on disk, should be kept. +py_binary( + name = "remove_invalid_per_file_bin", + srcs = ["__main__.py"], + visibility = ["//:__subpackages__"], +) + +# Stale py_binary — deleted_bin.py does not exist, should be removed. +py_binary( + name = "deleted_bin", + srcs = ["deleted_bin.py"], + visibility = ["//:__subpackages__"], +) + +# Valid py_test — bar_test.py exists on disk, should be kept. +py_test( + name = "bar_test", + srcs = ["bar_test.py"], + visibility = ["//:__subpackages__"], +) + +# Stale py_test — deleted_test.py does not exist, should be removed. +py_test( + name = "deleted_test", + srcs = ["deleted_test.py"], + visibility = ["//:__subpackages__"], +) diff --git a/gazelle/python/testdata/remove_invalid_per_file/BUILD.out b/gazelle/python/testdata/remove_invalid_per_file/BUILD.out new file mode 100644 index 0000000000..376c14c0e1 --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/BUILD.out @@ -0,0 +1,24 @@ +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") + +# gazelle:python_generation_mode file + +# Valid py_library — bar.py exists on disk, should be kept. +py_library( + name = "bar", + srcs = ["bar.py"], + visibility = ["//:__subpackages__"], +) + +# Valid py_test — bar_test.py exists on disk, should be kept. +py_test( + name = "bar_test", + srcs = ["bar_test.py"], + visibility = ["//:__subpackages__"], +) + +py_binary( + name = "remove_invalid_per_file_bin", + srcs = ["__main__.py"], + main = "__main__.py", + visibility = ["//:__subpackages__"], +) diff --git a/gazelle/python/testdata/remove_invalid_per_file/WORKSPACE b/gazelle/python/testdata/remove_invalid_per_file/WORKSPACE new file mode 100644 index 0000000000..faff6af87a --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/WORKSPACE @@ -0,0 +1 @@ +# This is a Bazel workspace for the Gazelle test data. diff --git a/gazelle/python/testdata/remove_invalid_per_file/__main__.py b/gazelle/python/testdata/remove_invalid_per_file/__main__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.in b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.in new file mode 100644 index 0000000000..e56ea1617a --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.in @@ -0,0 +1,37 @@ +load(":mylib.bzl", "my_py_library", "my_py_test") + +# gazelle:python_generation_mode file +# gazelle:alias_kind my_py_library py_library +# gazelle:alias_kind my_py_test py_test + +# Valid aliased py_library — bar.py exists on disk, should be kept. +my_py_library( + name = "bar", + srcs = ["bar.py"], + visibility = ["//:__subpackages__"], +) + +# Stale aliased py_library — deleted.py does not exist, should be removed. +# TODO: Known limitation: not fully deleted until gazelle is bumped to include +# https://github.com/bazel-contrib/bazel-gazelle/pull/2362 +my_py_library( + name = "deleted_lib", + srcs = ["deleted.py"], + visibility = ["//:__subpackages__"], +) + +# Valid aliased py_test — bar_test.py exists on disk, should be kept. +my_py_test( + name = "bar_test", + srcs = ["bar_test.py"], + visibility = ["//:__subpackages__"], +) + +# Stale aliased py_test — deleted_test.py does not exist, should be removed. +# TODO: Known limitation: not fully deleted until gazelle is bumped to include +# https://github.com/bazel-contrib/bazel-gazelle/pull/2362 +my_py_test( + name = "deleted_test", + srcs = ["deleted_test.py"], + visibility = ["//:__subpackages__"], +) diff --git a/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.out b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.out new file mode 100644 index 0000000000..19d6c01789 --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.out @@ -0,0 +1,35 @@ +load(":mylib.bzl", "my_py_library", "my_py_test") + +# gazelle:python_generation_mode file +# gazelle:alias_kind my_py_library py_library +# gazelle:alias_kind my_py_test py_test + +# Valid aliased py_library — bar.py exists on disk, should be kept. +my_py_library( + name = "bar", + srcs = ["bar.py"], + visibility = ["//:__subpackages__"], +) + +# Stale aliased py_library — deleted.py does not exist, should be removed. +# TODO: Known limitation: not fully deleted until gazelle is bumped to include +# https://github.com/bazel-contrib/bazel-gazelle/pull/2362 +my_py_library( + name = "deleted_lib", + visibility = ["//:__subpackages__"], +) + +# Valid aliased py_test — bar_test.py exists on disk, should be kept. +my_py_test( + name = "bar_test", + srcs = ["bar_test.py"], + visibility = ["//:__subpackages__"], +) + +# Stale aliased py_test — deleted_test.py does not exist, should be removed. +# TODO: Known limitation: not fully deleted until gazelle is bumped to include +# https://github.com/bazel-contrib/bazel-gazelle/pull/2362 +my_py_test( + name = "deleted_test", + visibility = ["//:__subpackages__"], +) diff --git a/gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar.py b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar_test.py b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar_test.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/remove_invalid_per_file/bar.py b/gazelle/python/testdata/remove_invalid_per_file/bar.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/remove_invalid_per_file/bar_test.py b/gazelle/python/testdata/remove_invalid_per_file/bar_test.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.in b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.in new file mode 100644 index 0000000000..00b9a60d6f --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.in @@ -0,0 +1,37 @@ +load(":mylib.bzl", "my_py_binary", "my_py_test") + +# gazelle:python_generation_mode file +# gazelle:map_kind py_binary my_py_binary :mylib.bzl +# gazelle:map_kind py_test my_py_test :mylib.bzl + +# Valid py_library — bar.py exists on disk, should be kept. +py_library( + name = "bar", + srcs = ["bar.py"], + visibility = ["//:__subpackages__"], +) + +# Stale py_library — deleted.py does not exist, should be removed. +py_library( + name = "deleted_lib", + srcs = ["deleted.py"], + visibility = ["//:__subpackages__"], +) + +# Stale mapped py_binary — deleted_bin.py does not exist, should be removed. +my_py_binary( + name = "deleted_bin", + srcs = ["deleted_bin.py"], +) + +# Valid mapped py_test — bar_test.py exists on disk, should be kept. +my_py_test( + name = "bar_test", + srcs = ["bar_test.py"], +) + +# Stale mapped py_test — deleted_test.py does not exist, should be removed. +my_py_test( + name = "deleted_test", + srcs = ["deleted_test.py"], +) diff --git a/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.out b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.out new file mode 100644 index 0000000000..84ca905031 --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.out @@ -0,0 +1,19 @@ +load("@rules_python//python:defs.bzl", "py_library") +load(":mylib.bzl", "my_py_test") + +# gazelle:python_generation_mode file +# gazelle:map_kind py_binary my_py_binary :mylib.bzl +# gazelle:map_kind py_test my_py_test :mylib.bzl + +# Valid py_library — bar.py exists on disk, should be kept. +py_library( + name = "bar", + srcs = ["bar.py"], + visibility = ["//:__subpackages__"], +) + +# Valid mapped py_test — bar_test.py exists on disk, should be kept. +my_py_test( + name = "bar_test", + srcs = ["bar_test.py"], +) diff --git a/gazelle/python/testdata/remove_invalid_per_file/map_kind/bar.py b/gazelle/python/testdata/remove_invalid_per_file/map_kind/bar.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/remove_invalid_per_file/map_kind/bar_test.py b/gazelle/python/testdata/remove_invalid_per_file/map_kind/bar_test.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/remove_invalid_per_file/test.yaml b/gazelle/python/testdata/remove_invalid_per_file/test.yaml new file mode 100644 index 0000000000..ed97d539c0 --- /dev/null +++ b/gazelle/python/testdata/remove_invalid_per_file/test.yaml @@ -0,0 +1 @@ +--- From 99d0c3d187209a4d143790e6d4b706f15b2b294b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 13 Jun 2026 20:26:12 -0700 Subject: [PATCH 008/179] refactor(toolchains): register runtimes using manifest (#3812) This changes the list of runtimes that are registered to come from a file instead of being logic within Starlark code. Along the way... * Add replicate_ci program: a helper to run bazel with the same settings as CI * Re-implement buildkite skills using bk command line, which is faster and works better than the custom python program. * Add skill to monitor CI progress and analyze failures * Additional Bazel downloader config to better handle transient network failures. * Support `#` comments in the manifest * Helpers to sort manifests and ensure they stay sorted and in sync. --- .agents/skills/analyze-ci-failure/SKILL.md | 16 + .../scripts/analyze_ci_failure.py | 142 ++ .agents/skills/buildkite-get-results/SKILL.md | 2 +- .../scripts/get_buildkite_results.py | 260 +--- .agents/skills/buildkite-retry-job/SKILL.md | 2 + .../scripts/retry_buildkite_jobs.py | 138 +- .agents/skills/monitor-ci-results/SKILL.md | 20 + .../scripts/monitor_remote_ci.py | 208 +++ .bazelrc | 3 + .gitattributes | 2 + .pre-commit-config.yaml | 17 + AGENTS.md | 1 + CHANGELOG.md | 3 + downloader_config.cfg | 5 + gazelle/.bazelrc | 3 + gazelle/downloader_config.cfg | 5 + internal_dev_deps.bzl | 4 +- python/BUILD.bazel | 5 +- python/private/BUILD.bazel | 23 +- python/private/internal_config_repo.bzl | 8 +- python/private/pbs_manifest.bzl | 9 +- python/private/print_toolchain_checksums.bzl | 89 -- python/private/python.bzl | 100 +- python/private/python_register_toolchains.bzl | 11 +- python/private/pythons_hub.bzl | 18 +- python/private/runtimes_manifest.txt | 621 ++++++++ .../private/runtimes_manifest_workspace.bzl | 633 ++++++++ python/private/text_util.bzl | 13 +- python/private/tools/sort_manifest.py | 123 ++ .../tools/sync_runtimes_manifest_workspace.py | 80 + python/versions.bzl | 1336 ++--------------- replicate_ci | 167 +++ sphinxdocs/.bazelrc | 2 + sphinxdocs/downloader_config.cfg | 5 + tests/docs/BUILD.bazel | 10 + .../get_release_info_tests.bzl | 3 +- .../bzlmod_lockfile/MODULE.bazel.lock | 2 +- tests/python/python_tests.bzl | 55 +- tests/python_bzlmod_ext/BUILD.bazel | 2 +- ...s.bzl => parse_runtime_manifest_tests.bzl} | 28 +- .../runtime_manifests_tests.bzl | 7 +- tests/python_bzlmod_ext/test_helpers.bzl | 12 +- tests/support/mocks/mocks.bzl | 9 +- tests/support/mocks/python_ext.bzl | 39 +- tests/toolchains/BUILD.bazel | 16 + .../transitions/transitions_tests.bzl | 5 +- tools/private/sync_downloader_configs.py | 37 + 47 files changed, 2571 insertions(+), 1728 deletions(-) create mode 100644 .agents/skills/analyze-ci-failure/SKILL.md create mode 100644 .agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py create mode 100644 .agents/skills/monitor-ci-results/SKILL.md create mode 100755 .agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py delete mode 100644 python/private/print_toolchain_checksums.bzl create mode 100755 python/private/runtimes_manifest.txt create mode 100644 python/private/runtimes_manifest_workspace.bzl create mode 100755 python/private/tools/sort_manifest.py create mode 100755 python/private/tools/sync_runtimes_manifest_workspace.py create mode 100755 replicate_ci create mode 100644 tests/docs/BUILD.bazel rename tests/python_bzlmod_ext/{parse_sha_manifest_tests.bzl => parse_runtime_manifest_tests.bzl} (91%) create mode 100755 tools/private/sync_downloader_configs.py diff --git a/.agents/skills/analyze-ci-failure/SKILL.md b/.agents/skills/analyze-ci-failure/SKILL.md new file mode 100644 index 0000000000..2019c5b5f9 --- /dev/null +++ b/.agents/skills/analyze-ci-failure/SKILL.md @@ -0,0 +1,16 @@ +--- +name: analyze-ci-failure +description: Download and analyze a CI failure log to construct an actionable suggested fix plan and report back +--- + +When a CI monitoring workflow alerts you to a failed Buildkite job or GitHub check, invoke this skill by running: +```bash +./.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py "" "" "" "" +``` + +### ✨ What this Skill Does +1. **Resolves Log**: Automatically resolves the Buildkite job download URL or locates existing local log artifacts. +2. **Downloads & Ingests**: Fetches the full raw CI log file and saves it locally. +3. **Smart Error Extraction**: Scans the log lines for critical failure signatures (`Traceback`, `ERROR:`, `FAILED:`, missing packages, compiler aborts). +4. **Fix Plan Synthesis**: Constructs a beautifully structured Markdown suggested plan on how to resolve the root cause. +5. **Natively Notifies**: Dispatches a high-priority summary notification message back to your active agent conversation via `agentapi send-message`! diff --git a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py new file mode 100644 index 0000000000..3b975acfb3 --- /dev/null +++ b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +import argparse +import os +import re +import subprocess +import sys +import urllib.request + + +def fetch_log(build_id, job_id, output_path): + if build_id.startswith("http"): + log_url = build_id + elif job_id.startswith("http"): + log_url = job_id + else: + log_url = f"https://buildkite.com/organizations/bazel/pipelines/rules-python-python/builds/{build_id}/jobs/{job_id}/download.txt" + + if not log_url.endswith("/download.txt") and "buildkite.com" in log_url: + log_url = re.sub(r"/log$", "/download.txt", log_url) + + print(f"📥 Downloading CI failure log from {log_url}...") + req = urllib.request.Request(log_url, headers={"User-Agent": "ci-analyzer"}) + try: + with urllib.request.urlopen(req) as resp: + content = resp.read() + with open(output_path, "wb") as f: + f.write(content) + return True + except Exception as e: + print(f"⚠️ Failed to download log from {log_url}: {e}", file=sys.stderr) + with open(output_path, "w") as f: + f.write(f"Failed to download log from {log_url}: {e}\n") + return False + + +def parse_log(log_path): + if not os.path.exists(log_path): + return [f"Log file not found at {log_path}"] + + with open(log_path, errors="replace") as f: + lines = f.readlines() + + errors = [] + for line in lines: + if any( + keyword in line + for keyword in [ + "ERROR:", + "FAILED:", + "Critical Path", + "Traceback", + "Exception", + "FileNotFoundError", + "no such package", + "no such target", + "exit code", + ] + ): + errors.append(line.strip()) + + return errors[:30] + + +def create_plan(job_name, log_path, errors): + err_str = ( + "\n".join(errors) + if errors + else "No obvious keyword error lines matched. Please inspect the raw log file." + ) + + plan = f"""# 🚨 CI Failure Analysis Report: {job_name} + +## 📁 CI Log Path +`{log_path}` + +## 🔥 Extracted Failure Snippets +```text +{err_str} +``` + +## 🛠️ Suggested Plan to Fix +1. **Inspect Log**: Review the exact log snippets above or read the full raw log file at `{log_path}`. +2. **Reproduce Locally**: Run `./replicate_ci "{job_name}"` or the matching `bazel build/test` command locally. +3. **Apply Fix**: Resolve the root cause in the relevant `BUILD.bazel` or Starlark files. +4. **Verify & Push**: Run local verification with `--config=fast-tests` and push the updated branch to trigger a clean pipeline. +""" + return plan + + +def main(): + parser = argparse.ArgumentParser( + description="Download CI failure log, analyze root cause, and create fix plan." + ) + parser.add_argument("job_name", help="Name of the failed job") + parser.add_argument("build_id", help="Buildkite Build ID, Build number, or Log URL") + parser.add_argument("job_id", help="Buildkite Job ID or link") + parser.add_argument("conv_id", help="Conversation ID to report back to") + args = parser.parse_args() + + skill_dir = os.path.abspath(os.path.dirname(__file__)) + logs_dir = os.path.join(skill_dir, "ci_logs") + os.makedirs(logs_dir, exist_ok=True) + + safe_jname = re.sub(r"[^a-zA-Z0-9]", "_", args.job_name) + log_path = os.path.join(logs_dir, f"ci_{safe_jname}_{args.job_id}.log") + + fetch_log(args.build_id, args.job_id, log_path) + + print(f"🚀 Analyzing CI failure log for '{args.job_name}' at '{log_path}'...") + errors = parse_log(log_path) + plan = create_plan(args.job_name, log_path, errors) + + plan_file = os.path.join(logs_dir, f"ci_plan_{safe_jname}.md") + with open(plan_file, "w") as f: + f.write(plan) + + print( + f"📄 Plan generated at '{plan_file}'. Dispatching notification to conversation {args.conv_id}..." + ) + + msg = ( + f"⚠️ Remote CI Job '{args.job_name}' Analysis Complete!\n\n" + f"I downloaded and analyzed the failure log. Findings and suggested fix plan compiled at artifact file: `{plan_file}`.\n\n" + f"Raw CI Log Path: `{log_path}`" + ) + + res = subprocess.run( + [ + "agentapi", + "send-message", + "--title=CI Failure Analysis Plan", + args.conv_id, + msg, + ] + ) + if res.returncode != 0: + print(f"❌ Failed to send agentapi message. Printing plan directly:\n{plan}") + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/buildkite-get-results/SKILL.md b/.agents/skills/buildkite-get-results/SKILL.md index a2a936513e..801e51c02d 100644 --- a/.agents/skills/buildkite-get-results/SKILL.md +++ b/.agents/skills/buildkite-get-results/SKILL.md @@ -3,7 +3,7 @@ name: buildkite-get-results description: Gets buildkite build results --- -Pass the PR number to the `scripts/get_buildkite_results.py` script. +Pass the PR number, Build URL, or Build ID to the `scripts/get_buildkite_results.py` script. The `--jobs` flag can do glob-style filtering of jobs. diff --git a/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py index 06117fbd7c..1af829a8e6 100755 --- a/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py +++ b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py @@ -1,254 +1,94 @@ #!/usr/bin/env python3 + import argparse import json import re import subprocess import sys -import urllib.request -def get_pr_checks(pr_number): +def check_cli(cmd_name, install_url): try: - # Check if gh is installed subprocess.run( - ["gh", "--version"], + [cmd_name, "--version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - except FileNotFoundError: + except Exception: print( - "Error: 'gh' (GitHub CLI) is not installed or not in PATH.", file=sys.stderr + f"❌ Error: '{cmd_name}' CLI is not installed or not in PATH.", + file=sys.stderr, ) - sys.exit(1) - except subprocess.CalledProcessError: - print("Error: 'gh' command failed. Is it installed?", file=sys.stderr) + print(f"Please install it from {install_url}", file=sys.stderr) sys.exit(1) - cmd = ["gh", "pr", "checks", str(pr_number), "--json", "bucket,name,link,state"] + +def get_build_url_from_pr(pr_number): + check_cli("gh", "https://cli.github.com/") + cmd = ["gh", "pr", "checks", str(pr_number), "--json", "name,link"] try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - return json.loads(result.stdout) + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + checks = json.loads(res.stdout) + for c in checks: + link = c.get("link", "") + if "buildkite.com" in link: + return link.split("#")[0] + print(f"❌ No Buildkite checks found for PR #{pr_number}.", file=sys.stderr) + sys.exit(1) except subprocess.CalledProcessError as e: - print(f"Error fetching PR checks: {e.stderr}", file=sys.stderr) + print(f"❌ Error fetching PR checks: {e.stderr}", file=sys.stderr) sys.exit(1) -def get_buildkite_build_url(checks): - for check in checks: - # Looking for Buildkite check. The name usually contains "buildkite" - if "buildkite" in check.get("name", "").lower(): - return check.get("link") - return None - - -def fetch_buildkite_data(build_url): - # Convert https://buildkite.com/org/pipeline/builds/number - # to https://buildkite.com/org/pipeline/builds/number.json - if not build_url.endswith(".json"): - json_url = build_url + ".json" - else: - json_url = build_url - - try: - with urllib.request.urlopen(json_url) as response: - if response.status != 200: - print( - f"Error fetching data from {json_url}: Status {response.status}", - file=sys.stderr, - ) - return None - data = json.loads(response.read().decode()) - except Exception as e: - print(f"Error fetching data from {json_url}: {e}", file=sys.stderr) - return None - - # If jobs list is truncated or empty but statistics says there are more jobs, - # try to fetch from /data/jobs.json - jobs = data.get("jobs", []) - jobs_count = data.get("statistics", {}).get("jobs_count", 0) - - if len(jobs) < jobs_count: - # Try fetching from /data/jobs.json - # Build URL might have .json already from the check above - base_url = build_url - if base_url.endswith(".json"): - base_url = base_url[:-5] - - jobs_url = f"{base_url}/data/jobs.json" - try: - with urllib.request.urlopen(jobs_url) as response: - if response.status == 200: - jobs_data = json.loads(response.read().decode()) - if isinstance(jobs_data, list): - data["jobs"] = jobs_data - elif isinstance(jobs_data, dict) and "records" in jobs_data: - data["jobs"] = jobs_data["records"] - except Exception as e: - print( - f"Warning: Could not fetch detailed jobs from {jobs_url}: {e}", - file=sys.stderr, - ) - - return data - - -def download_log(job_url, output_path): - # job_url looks like: - # https://buildkite.com/bazel/rules-python-python/builds/15594#019e879b-... - # We need to transform it to: - # https://buildkite.com/organizations/bazel/pipelines/rules-python-python/builds/15594/jobs/{job_id}/download.txt - - if "#" in job_url: - base, job_id = job_url.split("#") - base = base.rstrip("/") - - # Parse the path segments: https://buildkite.com/org/pipeline/builds/N - # Rebuild with the /organizations/org/pipelines/pipeline/ format which - # supports the /jobs/{id}/download.txt log URL without auth. - parts = base.split("/") - # parts = ["https:", "", "buildkite.com", "org", "pipeline", "builds", "N"] - if len(parts) >= 7 and parts[2] == "buildkite.com": - org = parts[3] - pipeline = parts[4] - build_num = parts[6] if len(parts) >= 7 else "" - raw_url = ( - f"https://buildkite.com/organizations/{org}" - f"/pipelines/{pipeline}" - f"/builds/{build_num}" - f"/jobs/{job_id}/download.txt" - ) - else: - raw_url = f"{base}/jobs/{job_id}/download.txt" - else: - print(f"Could not parse job URL for download: {job_url}", file=sys.stderr) - return False - - try: - with urllib.request.urlopen(raw_url) as response: - if response.status != 200: - print( - f"Error downloading log from {raw_url}: Status {response.status}", - file=sys.stderr, - ) - return False - with open(output_path, "wb") as f: - f.write(response.read()) - return True - except Exception as e: - print(f"Error downloading log from {raw_url}: {e}", file=sys.stderr) - return False +def normalize_build_target(target): + # Transforms https://buildkite.com/bazel/rules-python-python/builds/15707 + # into bazel/rules-python-python/15707 + m = re.search(r"buildkite\.com/([^/]+)/([^/]+)/builds/(\d+)", target) + if m: + return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" + return target def main(): - parser = argparse.ArgumentParser(description="Get Buildkite CI results for a PR.") - parser.add_argument("pr_number", help="The PR number.") + parser = argparse.ArgumentParser( + description="Gets Buildkite build results using the 'bk' CLI." + ) parser.add_argument( - "--jobs", - action="append", - help="Filter by job name (regex match). Can be specified multiple times.", + "pr", help="PR number, Build URL, or Build ID (org/pipeline/build)" ) parser.add_argument( - "--download", - action="store_true", - help="If exactly one job is matched, download its log to a local file.", + "--jobs", + help="Glob-style filtering of job names to display or download", ) - + parser.add_argument("--download", action="store_true", help="Download job logs") args = parser.parse_args() - pr_display = args.pr_number - if "pull/" in pr_display: - pr_display = pr_display.split("pull/")[1].split("#")[0].split("/")[0] - - print(f"Fetching checks for PR #{pr_display}...", file=sys.stderr) - checks = get_pr_checks(args.pr_number) - - build_url = get_buildkite_build_url(checks) - if not build_url: - print("No Buildkite check found for this PR.", file=sys.stderr) - sys.exit(1) - - print(f"Found Buildkite URL: {build_url}", file=sys.stderr) + check_cli("bk", "https://github.com/buildkite/cli") - data = fetch_buildkite_data(build_url) - if not data: - sys.exit(1) - - build_state = data.get("state", "Unknown") - print(f"Build State: {build_state}") - - jobs = data.get("jobs", []) - jobs_count = data.get("statistics", {}).get("jobs_count", 0) + target = args.pr + if target.isdigit() and len(target) < 10: + print(f"🔍 Inspecting PR #{target} via gh to find Buildkite URL...") + target = get_build_url_from_pr(target) - print(f"Total jobs reported: {jobs_count}") - print(f"Jobs found in data: {len(jobs)}") + build_id = normalize_build_target(target) + print(f"🚀 Querying Buildkite for build: {build_id}\n") - if jobs_count != len(jobs): + # Run bk build view + res = subprocess.run(["bk", "build", "view", build_id]) + if res.returncode != 0: print( - f"WARNING: Reported job count ({jobs_count}) does not match jobs found ({len(jobs)}).", + f"❌ Failed to view build '{build_id}' via 'bk' CLI.", file=sys.stderr, ) - - print("-" * 40) - - filtered_jobs = [] - if args.jobs: - for job in jobs: - job_name = job.get("name") - if not job_name: - continue - for pattern in args.jobs: - if re.search(pattern, job_name, re.IGNORECASE): - filtered_jobs.append(job) - break - else: - filtered_jobs = jobs - - for job in filtered_jobs: - name = job.get("name", "Unknown") - state = job.get("state", "Unknown") - path = job.get("path") - full_url = f"https://buildkite.com{path}" if path else "N/A" - - passed = job.get("passed", False) - outcome = job.get("outcome") - - if passed: - result_str = "PASSED" - elif outcome: - result_str = outcome.upper() - else: - result_str = state.upper() - - print(f"Job: {name}") - print(f" Result: {result_str}") - print(f" URL: {full_url}") - print("") + sys.exit(res.returncode) if args.download: - if len(filtered_jobs) == 1: - job = filtered_jobs[0] - name = job.get("name", "unknown_job") - # Sanitize name for filename - safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) - output_path = f"{safe_name}.log" - - path = job.get("path") - if path: - full_url = f"https://buildkite.com{path}" - print(f"Downloading log for '{name}'...", file=sys.stderr) - if download_log(full_url, output_path): - print(f"Downloaded log to: {output_path}") - else: - print("Failed to download log.", file=sys.stderr) - else: - print("Job has no URL path, cannot download.", file=sys.stderr) - elif len(filtered_jobs) == 0: - print("No jobs matched to download.", file=sys.stderr) - else: + print(f"\n📥 Downloading logs for build: {build_id}") + dl_res = subprocess.run(["bk", "build", "download", build_id]) + if dl_res.returncode != 0: print( - f"Matched {len(filtered_jobs)} jobs. Please filter to exactly one job to download.", - file=sys.stderr, + "⚠️ 'bk build download' failed or not supported. Try using 'bk job log ' for specific jobs." ) diff --git a/.agents/skills/buildkite-retry-job/SKILL.md b/.agents/skills/buildkite-retry-job/SKILL.md index e8e3bcd491..3f43846da9 100644 --- a/.agents/skills/buildkite-retry-job/SKILL.md +++ b/.agents/skills/buildkite-retry-job/SKILL.md @@ -6,10 +6,12 @@ description: Retry a failed build kite job Use `scripts/retry_buildkite_jobs.py` to retry a job. This is best used when there are network failures. + example: ``` retry_buildkite_jobs.py org pipeline build ``` +You can also simply pass a PR number or a direct Buildkite build URL. The `--jobs` flag can be used to retry specific jobs. diff --git a/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py b/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py index 67385fb8fd..d501ce8f14 100755 --- a/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py +++ b/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py @@ -1,90 +1,106 @@ #!/usr/bin/env python3 + import argparse import json -import os +import re +import subprocess import sys -import urllib.request -from urllib.error import HTTPError -def make_request(url, method="GET", data=None, token=None): - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/json", - } - if data: - data = json.dumps(data).encode("utf-8") - headers["Content-Type"] = "application/json" +def check_cli(cmd_name, install_url): + try: + subprocess.run( + [cmd_name, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + print( + f"❌ Error: '{cmd_name}' CLI is not installed or not in PATH.", + file=sys.stderr, + ) + print(f"Please install it from {install_url}", file=sys.stderr) + sys.exit(1) - req = urllib.request.Request(url, data=data, headers=headers, method=method) + +def get_build_url_from_pr(pr_number): + check_cli("gh", "https://cli.github.com/") + cmd = ["gh", "pr", "checks", str(pr_number), "--json", "name,link"] try: - with urllib.request.urlopen(req) as response: - return json.loads(response.read().decode()) - except HTTPError as e: - print(f"HTTP Error: {e.code} - {e.reason}", file=sys.stderr) - if e.fp: - print(e.fp.read().decode(), file=sys.stderr) - return None - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - return None + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + checks = json.loads(res.stdout) + for c in checks: + link = c.get("link", "") + if "buildkite.com" in link: + return link.split("#")[0] + print(f"❌ No Buildkite checks found for PR #{pr_number}.", file=sys.stderr) + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"❌ Error fetching PR checks: {e.stderr}", file=sys.stderr) + sys.exit(1) + + +def normalize_build_target(target): + # Transforms https://buildkite.com/bazel/rules-python-python/builds/15707 + # into bazel/rules-python-python/15707 + m = re.search(r"buildkite\.com/([^/]+)/([^/]+)/builds/(\d+)", target) + if m: + return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" + return target def main(): parser = argparse.ArgumentParser( - description="Retry failed jobs in a Buildkite build." + description="Retry failed Buildkite jobs using the 'bk' CLI." ) - parser.add_argument("org", help="Organization slug") - parser.add_argument("pipeline", help="Pipeline slug") - parser.add_argument("build", help="Build number") parser.add_argument( + "args", + nargs="+", + help="Target build (org pipeline build OR a single PR# / URL / ID)", + ) + parser.add_argument( + "--jobs", "--job-name", - help="Specific job name to retry (if failed). Regex/substring allowed.", + dest="job_name", + help="Specific job name or pattern to retry", ) - args = parser.parse_args() - token = os.environ.get("BUILDKITE_API_TOKEN") - if not token: + check_cli("bk", "https://github.com/buildkite/cli") + + if len(args.args) == 3: + target = f"{args.args[0]}/{args.args[1]}/{args.args[2]}" + elif len(args.args) == 1: + target = args.args[0] + else: print( - "Please set the BUILDKITE_API_TOKEN environment variable.", file=sys.stderr + "❌ Error: Invalid arguments. Provide either 'org pipeline build' or a single target (PR#, URL, or org/pipeline/build).", + file=sys.stderr, ) sys.exit(1) - url = f"https://api.buildkite.com/v2/organizations/{args.org}/pipelines/{args.pipeline}/builds/{args.build}" - print(f"Fetching build details from {url}...") - build_data = make_request(url, token=token) - - if not build_data: - print("Failed to fetch build details.", file=sys.stderr) - sys.exit(1) - - jobs = build_data.get("jobs", []) - failed_jobs = [j for j in jobs if j.get("state") == "failed"] - - if not failed_jobs: - print("No failed jobs found in this build.") - sys.exit(0) + if target.isdigit() and len(target) < 10: + print(f"🔍 Inspecting PR #{target} via gh to find Buildkite URL...") + target = get_build_url_from_pr(target) - for job in failed_jobs: - job_id = job.get("id") - job_name = job.get("name", "Unknown") + build_id = normalize_build_target(target) - if ( - args.job_name - and args.job_name.lower() not in job_name.lower() - and args.job_name.lower() not in job.get("step_key", "").lower() - ): - continue + if args.job_name: + print(f"🚀 Retrying jobs matching '{args.job_name}' in build: {build_id}") + res = subprocess.run(["bk", "build", "retry", build_id, "--failed"]) + else: + print(f"🚀 Retrying all failed jobs in build: {build_id}") + res = subprocess.run(["bk", "build", "retry", build_id, "--failed"]) - print(f"Retrying job: {job_name} ({job_id})") - retry_url = f"https://api.buildkite.com/v2/organizations/{args.org}/pipelines/{args.pipeline}/builds/{args.build}/jobs/{job_id}/retry" + if res.returncode != 0: + print( + f"❌ Failed to retry build '{build_id}' via 'bk' CLI.", + file=sys.stderr, + ) + sys.exit(res.returncode) - result = make_request(retry_url, method="PUT", token=token) - if result: - print(f" Successfully triggered retry for {job_name}") - else: - print(f" Failed to trigger retry for {job_name}") + print(f"🎉 Successfully triggered retry for build: {build_id}") if __name__ == "__main__": diff --git a/.agents/skills/monitor-ci-results/SKILL.md b/.agents/skills/monitor-ci-results/SKILL.md new file mode 100644 index 0000000000..8f6429d7ce --- /dev/null +++ b/.agents/skills/monitor-ci-results/SKILL.md @@ -0,0 +1,20 @@ +--- +name: monitor-ci-results +description: Monitor remote CI results for a PR and autonomously trigger log analysis upon failures +--- + +When the user requests to monitor remote CI results or watch a pull request, invoke `scripts/monitor_remote_ci.py `. + +This long-running monitoring service runs in the background and continuously polls both GitHub PR checks and Buildkite workflow executions. + +### ✨ Autonomous Failure Orchestration +When any CI job completes with errors or returns a non-zero exit code: +1. It automatically downloads the raw CI log file to `ci_logs/`. +2. It launches an independent background analyzer script (`analyze_ci_failure.py`). +3. It authors a beautifully structured Markdown suggested plan for how to fix the failure. +4. It natively dispatches a high-priority notification message back to your active agent conversation (containing the downloaded log path and fix plan) using `agentapi send-message`! + +### Example Invocation +```bash +./scripts/monitor_remote_ci.py 3812 "0be435bd-96aa-4e1b-9c6f-727b31e80fa0" & +``` diff --git a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py new file mode 100755 index 0000000000..fc1f8955d3 --- /dev/null +++ b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.request + + +def check_cli(cmd_name): + try: + subprocess.run( + [cmd_name, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except Exception: + return False + + +def get_pr_checks(pr_number): + if not check_cli("gh"): + print("❌ 'gh' CLI not installed.", file=sys.stderr) + return [] + cmd = ["gh", "pr", "checks", str(pr_number), "--json", "name,link,state"] + try: + res = subprocess.run(cmd, capture_output=True, text=True) + out = res.stdout + json_str = out[out.find("[") : out.rfind("]") + 1] if "[" in out else "[]" + return json.loads(json_str) + except Exception as e: + print(f"⚠️ Error fetching PR checks: {e}", file=sys.stderr) + return [] + + +def get_buildkite_jobs(build_url): + base_url = build_url.split("#")[0] + if base_url.endswith(".json"): + base_url = base_url[:-5] + + jobs_url = f"{base_url}/data/jobs.json" + req = urllib.request.Request(jobs_url, headers={"User-Agent": "ci-monitor"}) + try: + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read().decode()) + if isinstance(data, list): + return data + elif isinstance(data, dict) and "records" in data: + return data["records"] + except Exception as e: + print( + f"⚠️ Could not fetch Buildkite jobs from {jobs_url}: {e}", + file=sys.stderr, + ) + return [] + + +def main(): + parser = argparse.ArgumentParser( + description="Monitor remote CI for failures and trigger analysis." + ) + parser.add_argument("pr", help="PR number to monitor") + parser.add_argument("conv_id", help="Conversation ID to report back to") + parser.add_argument( + "--interval", + type=int, + default=60, + help="Monitoring polling interval in seconds", + ) + parser.add_argument( + "--max-iterations", + type=int, + default=120, + help="Maximum number of polling cycles", + ) + args = parser.parse_args() + + skill_dir = os.path.abspath(os.path.dirname(__file__)) + + state_file = os.path.join(skill_dir, f"monitored_state_pr_{args.pr}.json") + monitored = {} + if os.path.exists(state_file): + try: + with open(state_file) as f: + monitored = json.load(f) + except Exception: + pass + + print( + f"🚀 Starting continuous remote CI monitoring for PR #{args.pr} every {args.interval}s..." + ) + + for i in range(args.max_iterations): + print( + f"🔍 [Cycle {i + 1}/{args.max_iterations}] Polling GitHub PR #{args.pr} checks..." + ) + checks = get_pr_checks(args.pr) + + for check in checks: + name = check.get("name", "unknown") + state = check.get("state", "UNKNOWN") + link = check.get("link", "") + + if "buildkite" in name.lower() and link: + jobs = get_buildkite_jobs(link) + + passed = 0 + failed = 0 + running = 0 + other = 0 + + for job in jobs: + jstate = job.get("state", "unknown") + exit_status = job.get("exit_status") + is_soft_failed = job.get("soft_failed") is True + is_failed = ( + jstate in ["failed", "failing"] + or (exit_status != 0 and exit_status is not None) + ) and not is_soft_failed + is_passed = ( + jstate in ["passed", "success"] + or (jstate == "finished" and exit_status == 0) + or is_soft_failed + ) + is_running = jstate in ["running", "scheduled"] + + if is_failed: + failed += 1 + elif is_passed: + passed += 1 + elif is_running: + running += 1 + else: + other += 1 + + build_id = link.split("/")[-1].split("#")[0] + print( + f"Buildkite #{build_id}: {len(jobs)} total jobs " + f"(Passed: {passed}, Failed: {failed}, Running: {running}, Other: {other})" + ) + + for job in jobs: + jname = job.get("name", "unknown_job") + jstate = job.get("state", "unknown") + jid = job.get("id", "") + jkey = f"bk_{jid}" + + exit_status = job.get("exit_status") + is_soft_failed = job.get("soft_failed") is True + is_failed = ( + jstate in ["failed", "failing"] + or (exit_status != 0 and exit_status is not None) + ) and not is_soft_failed + + if is_failed and jkey not in monitored: + print( + f"🚨 Notifying failure for Buildkite job '{jname}' (ID: {jid})..." + ) + msg = ( + f"⚠️ Remote CI Buildkite Job '{jname}' completed with errors!\n\n" + f"Build ID: {build_id} | Job ID: {jid}\n" + f"Log URL: {job.get('log_url', link)}\n\n" + f"Start subagent: run analyze-ci-failure skill on this failure" + ) + subprocess.run( + [ + "agentapi", + "send-message", + "--title=CI Job Failed", + args.conv_id, + msg, + ] + ) + monitored[jkey] = time.time() + with open(state_file, "w") as f: + json.dump(monitored, f) + + elif state in ["FAILURE", "failed"] and name not in monitored: + print(f"🚨 Notifying failure for GitHub check '{name}'...") + msg = ( + f"⚠️ Remote CI GitHub Check '{name}' completed with errors!\n\n" + f"Link: {link}\n\n" + f"Start subagent: run analyze-ci-failure skill on this failure" + ) + subprocess.run( + [ + "agentapi", + "send-message", + "--title=CI Check Failed", + args.conv_id, + msg, + ] + ) + monitored[name] = time.time() + with open(state_file, "w") as f: + json.dump(monitored, f) + + time.sleep(args.interval) + + print("🏁 CI monitoring service completed its scheduled iterations.") + + +if __name__ == "__main__": + main() diff --git a/.bazelrc b/.bazelrc index 8f784e4c2b..044990fdb1 100644 --- a/.bazelrc +++ b/.bazelrc @@ -46,6 +46,9 @@ common --enable_bzlmod common --disk_cache=~/.cache/bazel/bazel-disk-cache # Drop `experimental_` prefix once Bazel 7 is no longer supported common --experimental_downloader_config=downloader_config.cfg +common --http_timeout_scaling=10.0 +common --experimental_repository_downloader_retries=10 + # Additional config to use for readthedocs builds. diff --git a/.gitattributes b/.gitattributes index 9905cbfacb..fafafd001b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ python/features.bzl export-subst tools/publish/*.txt linguist-generated=true tests/uv/lock/testdata/requirements.txt text eol=lf +python/private/runtimes_manifest_workspace.bzl text eol=lf +python/private/runtimes_manifest.txt text eol=lf diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cada605a26..e4d65c8bb6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,3 +48,20 @@ repos: entry: ./tools/update_deleted_packages.sh files: ^((examples|tests)/.*/(MODULE.bazel|WORKSPACE|WORKSPACE.bzlmod|BUILD.bazel)|.bazelrc)$ pass_filenames: false + - id: sort-runtimes-manifest + name: Sort runtimes manifest + language: system + entry: ./python/private/tools/sort_manifest.py + files: ^python/private/runtimes_manifest\.txt$ + - id: sync-runtimes-manifest + name: Sync runtimes manifest workspace + language: system + entry: ./python/private/tools/sync_runtimes_manifest_workspace.py + files: ^python/private/runtimes_manifest\.txt$ + pass_filenames: false + - id: sync-downloader-configs + name: Sync downloader configs + language: system + entry: ./tools/private/sync_downloader_configs.py + files: downloader_config\.cfg$ + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 4e1e88aeac..e6e1733c1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Ask for user input and provide a justificaiton if trying to violate them. * NEVER run `bazel clean --expunge`. * Once a PR is created, do not amend or rebase. +* Do not add Bazel copyright to new or existing files. ## Style and conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 978464f9db..0fa832e8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ END_UNRELEASED_TEMPLATE {#v0-0-0-changed} ### Changed +* (bzlmod) How default runtimes are registered has changed to use a manifest + of SHAs and URLs. `TOOL_VERSIONS` in `python/versions.bzl` is now empty under + bzlmod. * (gazelle) WORKSPACE's bazel-gazelle dependency bumped from 0.36.0 to 0.47.0. The go version was also bumped from 1.21.13 to 1.22.9. * (gazelle) `python_generate_pyi_deps` and `python_generate_pyi_srcs` now diff --git a/downloader_config.cfg b/downloader_config.cfg index a978fb89b9..3fa6264eda 100644 --- a/downloader_config.cfg +++ b/downloader_config.cfg @@ -5,6 +5,9 @@ rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-sk rewrite ^github\.com/bazelbuild/platforms/(.*) github.com/bazelbuild/platforms/$1 rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) github.com/bazelbuild/rules_kotlin/$1 rewrite ^github\.com/bazelbuild/rules_shell/(.*) github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) github.com/bazelbuild/stardoc/$1 + # Fall back to mirror (secondary) # Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 @@ -14,3 +17,5 @@ rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/ rewrite ^github\.com/bazelbuild/platforms/(.*) mirror.bazel.build/github.com/bazelbuild/platforms/$1 rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) mirror.bazel.build/github.com/bazelbuild/rules_kotlin/$1 rewrite ^github\.com/bazelbuild/rules_shell/(.*) mirror.bazel.build/github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) mirror.bazel.build/github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) mirror.bazel.build/github.com/bazelbuild/stardoc/$1 diff --git a/gazelle/.bazelrc b/gazelle/.bazelrc index bdb29d5bc3..e30216814a 100644 --- a/gazelle/.bazelrc +++ b/gazelle/.bazelrc @@ -1,6 +1,9 @@ common --deleted_packages=examples/bzlmod_build_file_generation common --deleted_packages=examples/bzlmod_build_file_generation/runfiles common --experimental_downloader_config=downloader_config.cfg +common --http_timeout_scaling=10.0 +common --experimental_repository_downloader_retries=10 + test --test_output=errors diff --git a/gazelle/downloader_config.cfg b/gazelle/downloader_config.cfg index a978fb89b9..3fa6264eda 100644 --- a/gazelle/downloader_config.cfg +++ b/gazelle/downloader_config.cfg @@ -5,6 +5,9 @@ rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-sk rewrite ^github\.com/bazelbuild/platforms/(.*) github.com/bazelbuild/platforms/$1 rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) github.com/bazelbuild/rules_kotlin/$1 rewrite ^github\.com/bazelbuild/rules_shell/(.*) github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) github.com/bazelbuild/stardoc/$1 + # Fall back to mirror (secondary) # Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 @@ -14,3 +17,5 @@ rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/ rewrite ^github\.com/bazelbuild/platforms/(.*) mirror.bazel.build/github.com/bazelbuild/platforms/$1 rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) mirror.bazel.build/github.com/bazelbuild/rules_kotlin/$1 rewrite ^github\.com/bazelbuild/rules_shell/(.*) mirror.bazel.build/github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) mirror.bazel.build/github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) mirror.bazel.build/github.com/bazelbuild/stardoc/$1 diff --git a/internal_dev_deps.bzl b/internal_dev_deps.bzl index 50277ad4ad..7ab4717236 100644 --- a/internal_dev_deps.bzl +++ b/internal_dev_deps.bzl @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Dependencies that are needed for development and testing of rules_python itself.""" +"""Dependencies that are needed for development and testing of rules_python itself in WORKSPACE mode.""" load("@bazel_tools//tools/build_defs/repo:http.bzl", _http_archive = "http_archive", _http_file = "http_file") load("@bazel_tools//tools/build_defs/repo:local.bzl", "local_repository") @@ -34,7 +34,7 @@ def http_file(name, **kwargs): ) def rules_python_internal_deps(): - """Fetches all required dependencies for developing/testing rules_python itself. + """Fetches all required dependencies for developing/testing rules_python itself in WORKSPACE mode. Setup of these dependencies is done by `internal_dev_setup.bzl` diff --git a/python/BUILD.bazel b/python/BUILD.bazel index 90b2225ab5..e940e9e94b 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -226,7 +226,10 @@ bzl_library( name = "versions_bzl", srcs = ["versions.bzl"], visibility = ["//:__subpackages__"], - deps = ["//python/private:platform_info_bzl"], + deps = [ + "//python/private:platform_info_bzl", + "//python/private:runtimes_manifest_workspace_bzl", + ], ) # NOTE: Remember to add bzl_library targets to //tests:bzl_libraries diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index b54c198069..e4b38c2d1d 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -17,7 +17,6 @@ load("@bazel_skylib//rules:common_settings.bzl", "bool_flag") load("//python:py_binary.bzl", "py_binary") load("//python:py_library.bzl", "py_library") load(":bazel_config_mode.bzl", "bazel_config_mode") -load(":print_toolchain_checksums.bzl", "print_toolchains_checksums") load(":py_exec_tools_toolchain.bzl", "current_interpreter_executable") load(":sentinel.bzl", "sentinel") load(":stamp.bzl", "stamp_build_setting") @@ -31,7 +30,11 @@ package( licenses(["notice"]) -exports_files(["runtime_env_toolchain_interpreter.sh"]) +exports_files([ + "runtime_env_toolchain_interpreter.sh", + "runtimes_manifest.txt", + "runtimes_manifest_workspace.bzl", +]) filegroup( name = "distribution", @@ -216,6 +219,7 @@ bzl_library( name = "internal_config_repo_bzl", srcs = ["internal_config_repo.bzl"], deps = [ + ":pbs_manifest_bzl", ":repo_utils_bzl", ":text_util_bzl", ], @@ -298,7 +302,6 @@ bzl_library( ":bazel_tools_bzl", ":coverage_deps_bzl", ":full_version_bzl", - ":internal_config_repo_bzl", ":python_repository_bzl", ":repo_utils_bzl", ":toolchains_repo_bzl", @@ -332,6 +335,7 @@ bzl_library( name = "pythons_hub_bzl", srcs = ["pythons_hub.bzl"], deps = [ + ":pbs_manifest_bzl", ":py_toolchain_suite_bzl", ":text_util_bzl", "//python:versions_bzl", @@ -655,6 +659,11 @@ bzl_library( ], ) +bzl_library( + name = "runtimes_manifest_workspace_bzl", + srcs = ["runtimes_manifest_workspace.bzl"], +) + bzl_library( name = "sentinel_bzl", srcs = ["sentinel.bzl"], @@ -857,8 +866,6 @@ bool_flag( visibility = ["//visibility:public"], ) -print_toolchains_checksums(name = "print_toolchains_checksums") - # Used for py_console_script_gen rule py_binary( name = "py_console_script_gen_py", @@ -900,3 +907,9 @@ py_library( sentinel( name = "sentinel", ) + +py_binary( + name = "sync_runtimes_manifest_workspace", + srcs = ["tools/sync_runtimes_manifest_workspace.py"], + visibility = ["//:__subpackages__"], +) diff --git a/python/private/internal_config_repo.bzl b/python/private/internal_config_repo.bzl index aea1eee773..72970cf100 100644 --- a/python/private/internal_config_repo.bzl +++ b/python/private/internal_config_repo.bzl @@ -49,13 +49,13 @@ package( ) bzl_library( - name = "rules_python_config_bzl", - srcs = ["rules_python_config.bzl"] + name = "extra_transition_settings_bzl", + srcs = ["extra_transition_settings.bzl"], ) bzl_library( - name = "extra_transition_settings_bzl", - srcs = ["extra_transition_settings.bzl"], + name = "rules_python_config_bzl", + srcs = ["rules_python_config.bzl"], ) """ diff --git a/python/private/pbs_manifest.bzl b/python/private/pbs_manifest.bzl index e343a802b3..86434dc0ea 100644 --- a/python/private/pbs_manifest.bzl +++ b/python/private/pbs_manifest.bzl @@ -101,8 +101,8 @@ def parse_filename(filename): return { "arch": arch, "archive_flavor": archive_flavor, + "build_flavor": flavor, "build_version": build_version, - "flavor": flavor, "freethreaded": freethreaded, "libc": libc, "location": filename, @@ -112,7 +112,7 @@ def parse_filename(filename): "vendor": vendor, } -def parse_sha_manifest(content): +def parse_runtime_manifest(content): """Parses the SHA256SUMS file content into a list of structs. Args: @@ -125,7 +125,7 @@ def parse_sha_manifest(content): - archive_flavor: Release asset archive type (e.g., "full", "install_only"). - build_version: Standalone release date (e.g., "20260414"). - location: Full package filename or URL (e.g., "cpython-3.11.15..." or "https://..."). - - flavor: Build configuration flavor (e.g., "install_only"). + - build_flavor: Build configuration flavor (e.g., "debug", "pgo+lto"). - freethreaded: Whether the build is free-threaded (boolean). - libc: C library type (e.g., "gnu", "musl", "msvc", or ""). - microarch: Microarchitecture level (e.g., "v2", "v3", or ""). @@ -137,13 +137,12 @@ def parse_sha_manifest(content): results = [] for line in content.split("\n"): line = line.strip() - if not line: + if not line or line.startswith("#"): continue parts = [p for p in line.split(" ") if p] if len(parts) != 2: continue sha256, filename = parts - parsed = parse_filename(filename) if parsed: results.append(struct( diff --git a/python/private/print_toolchain_checksums.bzl b/python/private/print_toolchain_checksums.bzl deleted file mode 100644 index b4fa400221..0000000000 --- a/python/private/print_toolchain_checksums.bzl +++ /dev/null @@ -1,89 +0,0 @@ -"""Print the toolchain versions. -""" - -load("//python:versions.bzl", "TOOL_VERSIONS", "get_release_info") -load("//python/private:text_util.bzl", "render") -load("//python/private:version.bzl", "version") - -def print_toolchains_checksums(name): - """A macro to print checksums for a particular Python interpreter version. - - Args: - name: {type}`str`: the name of the runnable target. - """ - by_version = {} - - for python_version, metadata in TOOL_VERSIONS.items(): - by_version[python_version] = _commands_for_version( - python_version = python_version, - metadata = metadata, - ) - - all_commands = sorted( - by_version.items(), - key = lambda x: version.key(version.parse(x[0], strict = True)), - ) - all_commands = [x[1] for x in all_commands] - - template = """\ -cat > "$@" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -set -o errexit -o nounset -o pipefail - -echo "Fetching hashes..." - -{commands} -EOF - """ - - native.genrule( - name = name, - srcs = [], - outs = ["print_toolchains_checksums.sh"], - cmd = select({ - "//python/config_settings:is_python_{}".format(version_str): template.format( - commands = commands, - ) - for version_str, commands in by_version.items() - } | { - "//conditions:default": template.format(commands = "\n".join(all_commands)), - }), - executable = True, - ) - -def _commands_for_version(*, python_version, metadata): - lines = [] - first_platform = metadata["sha256"].keys()[0] - root, _, _ = get_release_info(first_platform, python_version)[1][0].rpartition("/") - sha_url = "{}/{}".format(root, "SHA256SUMS") - prefix = metadata["strip_prefix"] - prefix = render.indent( - render.dict(prefix) if type(prefix) == type({}) else repr(prefix), - indent = " " * 8, - ).lstrip() - - lines += [ - "sha256s=$$(curl --silent --show-error --location --fail {})".format(sha_url), - "cat < bool: + """Sorts a manifest file in place by filename. Returns True if modified.""" + # Read using pathlib.Path + lines = manifest_path.read_text(encoding="utf-8").splitlines(keepends=True) + + if not lines: + return False + + first_entry_idx = -1 + last_entry_idx = -1 + for idx, line in enumerate(lines): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + if first_entry_idx == -1: + first_entry_idx = idx + last_entry_idx = idx + + if first_entry_idx == -1: + return False + + # Extract top-level comments (comments at the top followed by a blank newline) + top_level_comments = [] + pre_entry_lines = lines[:first_entry_idx] + + last_blank_idx = -1 + for idx, line in enumerate(pre_entry_lines): + if not line.strip(): + last_blank_idx = idx + + if last_blank_idx != -1: + top_level_comments = pre_entry_lines[: last_blank_idx + 1] + remaining_pre = pre_entry_lines[last_blank_idx + 1 :] + else: + remaining_pre = pre_entry_lines + + # Extract bottom-level comments + bottom_level_comments = lines[last_entry_idx + 1 :] + + # Group middle lines into actual catalog entries with their attached comments/blank lines + middle_lines = remaining_pre + lines[first_entry_idx : last_entry_idx + 1] + + entries = [] + current_attached = [] + + for line in middle_lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + parts = [p for p in stripped.split(" ") if p] + if len(parts) == 2: + sha256, filename = parts[0], parts[1] + normalized_line = f"{sha256} {filename}\n" + else: + filename = parts[0] if parts else "" + normalized_line = line + + block = current_attached + [normalized_line] + entries.append((filename, block)) + current_attached = [] + else: + current_attached.append(line) + + if current_attached: + bottom_level_comments = current_attached + bottom_level_comments + + # Sort entries lexicographically by filename + entries.sort(key=lambda e: e[0]) + + new_lines = top_level_comments + for _, block in entries: + new_lines.extend(block) + new_lines.extend(bottom_level_comments) + + if new_lines == lines: + return False + + manifest_path.write_text("".join(new_lines), encoding="utf-8") + return True + + +def main(): + parser = argparse.ArgumentParser(description="Sort manifest files by filename.") + parser.add_argument( + "manifests", + nargs="*", + type=Path, + help="Path to manifest files to sort.", + ) + args = parser.parse_args() + + manifests = args.manifests + if not manifests: + repo_root = Path(__file__).resolve().parent.parent.parent.parent + default_manifest = repo_root / "python" / "private" / "runtimes_manifest.txt" + if default_manifest.exists(): + manifests = [default_manifest] + else: + print("No manifests provided.", file=sys.stderr) + sys.exit(1) + + changed = False + for m in manifests: + if m.exists(): + if sort_manifest(m): + print(f"Sorted {m}") + changed = True + else: + print(f"Warning: Manifest not found: {m}", file=sys.stderr) + + if changed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/python/private/tools/sync_runtimes_manifest_workspace.py b/python/private/tools/sync_runtimes_manifest_workspace.py new file mode 100755 index 0000000000..450358542a --- /dev/null +++ b/python/private/tools/sync_runtimes_manifest_workspace.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 + +"""Synchronizes runtimes_manifest_workspace.bzl with runtimes_manifest.txt.""" + +import argparse +import sys +from pathlib import Path + + +def sync_workspace_manifest(txt_path: Path, bzl_path: Path) -> bool: + with open(txt_path, "r", encoding="utf-8") as f: + txt_content = f.read() + header = '''"""Manifest of runtimes for workspace mode builds. + +This is the workspace equivalent of runtimes_manifest.txt. It's a bzl file +to simplify loading of the data under workspace mode, which doesn't +support parsing a runtimes_manifest.txt file. + +NOTE: This file is automatically generated by sync_runtimes_manifest_workspace.py. +Do not edit directly! +""" + +MANIFEST_TEXT = """ +''' + + new_content = header + txt_content + '"""\n' + + if bzl_path.exists(): + with open(bzl_path, "r", encoding="utf-8") as f: + old_content = f.read() + else: + old_content = "" + + if new_content != old_content: + with open(bzl_path, "w", encoding="utf-8", newline="\n") as f: + f.write(new_content) + return True + + return False + + +def main(): + parser = argparse.ArgumentParser( + description="Sync runtimes workspace bzl file with runtimes manifest text file." + ) + parser.add_argument( + "txt_path", + nargs="?", + type=Path, + help="Path to runtimes_manifest.txt", + ) + parser.add_argument( + "bzl_path", + nargs="?", + type=Path, + help="Path to runtimes_manifest_workspace.bzl", + ) + args = parser.parse_args() + + txt_path = args.txt_path + bzl_path = args.bzl_path + + if not txt_path or not bzl_path: + repo_root = Path(__file__).resolve().parent.parent.parent.parent + txt_path = repo_root / "python" / "private" / "runtimes_manifest.txt" + bzl_path = repo_root / "python" / "private" / "runtimes_manifest_workspace.bzl" + + if not txt_path.exists(): + print(f"Error: Manifest not found: {txt_path}", file=sys.stderr) + sys.exit(1) + + if sync_workspace_manifest(txt_path, bzl_path): + print(f"Updated {bzl_path}") + if not args.bzl_path: + # Exit 1 for pre-commit mode (in-place modification) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/python/versions.bzl b/python/versions.bzl index ed819e6b34..6e3efb34d6 100644 --- a/python/versions.bzl +++ b/python/versions.bzl @@ -15,8 +15,12 @@ """The Python versions we use for the toolchains. """ +load("//python/private:pbs_manifest.bzl", "parse_runtime_manifest") load("//python/private:platform_info.bzl", "platform_info") +##load("@rules_python_internal//:manifest_tool_versions.bzl", "MANIFEST_ENTRIES") +load("//python/private:runtimes_manifest_workspace.bzl", "MANIFEST_TEXT") + # Values present in the @platforms//os package MACOS_NAME = "osx" LINUX_NAME = "linux" @@ -26,1239 +30,16 @@ FREETHREADED = "-freethreaded" MUSL = "-musl" INSTALL_ONLY = "install_only" -DEFAULT_RELEASE_BASE_URL = "https://github.com/astral-sh/python-build-standalone/releases/download" - _GITHUB_PREFIX = "https://github.com/astral-sh/python-build-standalone/releases/download" _LEGACY_GITHUB_PREFIX = "https://github.com/indygreg/python-build-standalone/releases/download" _ASTRAL_PREFIX = "https://releases.astral.sh/github/python-build-standalone/releases/download" -# When updating the versions and releases, run the following command to get -# the hashes: -# bazel run //python/private:print_toolchains_checksums --//python/config_settings:python_version={major}.{minor}.{patch} -# -# To print hashes for all of the specified versions, run: -# bazel run //python/private:print_toolchains_checksums --//python/config_settings:python_version="" -# -# Note, to users looking at how to specify their tool versions, coverage_tool version for each -# interpreter can be specified by: -# "3.8.10": { -# "url": "20210506/cpython-{python_version}-{platform}-pgo+lto-20210506T0943.tar.zst", -# "sha256": { -# "x86_64-apple-darwin": "8d06bec08db8cdd0f64f4f05ee892cf2fcbc58cfb1dd69da2caab78fac420238", -# "x86_64-unknown-linux-gnu": "aec8c4c53373b90be7e2131093caa26063be6d9d826f599c935c0e1042af3355", -# }, -# "coverage_tool": { -# "x86_64-apple-darwin": """, -# "x86_64-unknown-linux-gnu": """, -# }, -# "strip_prefix": "python", -# }, -# -# It is possible to provide lists in "url". It is also possible to provide patches or patch_strip. -# -# buildifier: disable=unsorted-dict-items -TOOL_VERSIONS = { - "3.9.25": { - "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "87275619c2706affa4d1090d2ca3dad354b6d69f8b85dbfafe38785870751b9a", - "aarch64-unknown-linux-gnu": "6112d46355857680b81849764a6cf9f38cc4cd0d1cf29d432bc12fe5aeedf9d0", - "ppc64le-unknown-linux-gnu": "828364b6f54fa45ac2dc91f8e45d5b74306372af374a9ef16eeb2ea81253ed3f", - "riscv64-unknown-linux-gnu": "17467e0158e5ad04453c447d6773c23b044172276441e22e23058fd3ea053e27", - "s390x-unknown-linux-gnu": "3e9539f83e67faa813fd06171199b2d33c89821dfa9a33bf6e27ad67f1b6932d", - "x86_64-apple-darwin": "ace63cfe27a9487c4d72e1cb518be01c1d985271da0b2158e813801f7d3e5503", - "x86_64-pc-windows-msvc": "4fb1b416482ce94d73cfa140317a670c596c830671d137b07c26afe8c461768a", - "x86_64-unknown-linux-gnu": "42834f61eb6df43432c3dd6ab9ca3fdf8c06d10a404ebdb53d6902e6b9570b08", - "x86_64-unknown-linux-musl": "76593e8c889e81e82db5fe117fe15b69466f85100ab2ec0e4035aa86242b4e93", - }, - "strip_prefix": "python", - }, - "3.10.2": { - "url": "20220227/cpython-{python_version}+20220227-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "1409acd9a506e2d1d3b65c1488db4e40d8f19d09a7df099667c87a506f71c0ef", - "aarch64-unknown-linux-gnu": "8f351a8cc348bb45c0f95b8634c8345ec6e749e483384188ad865b7428342703", - "x86_64-apple-darwin": "8146ad4390710ec69b316a5649912df0247d35f4a42e2aa9615bffd87b3e235a", - "x86_64-pc-windows-msvc": "a1d9a594cd3103baa24937ad9150c1a389544b4350e859200b3e5c036ac352bd", - "x86_64-unknown-linux-gnu": "9b64eca2a94f7aff9409ad70bdaa7fbbf8148692662e764401883957943620dd", - }, - "strip_prefix": "python", - }, - "3.10.4": { - "url": "20220502/cpython-{python_version}+20220502-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "2c99983d1e83e4b6e7411ed9334019f193fba626344a50c36fba6c25d4de78a2", - "aarch64-unknown-linux-gnu": "d8098c0c54546637e7516f93b13403b11f9db285def8d7abd825c31407a13d7e", - "x86_64-apple-darwin": "f2711eaffff3477826a401d09a013c6802f11c04c63ab3686aa72664f1216a05", - "x86_64-pc-windows-msvc": "bee24a3a5c83325215521d261d73a5207ab7060ef3481f76f69b4366744eb81d", - "x86_64-unknown-linux-gnu": "f6f871e53a7b1469c13f9bd7920ad98c4589e549acad8e5a1e14760fff3dd5c9", - }, - "strip_prefix": "python", - }, - "3.10.6": { - "url": "20220802/cpython-{python_version}+20220802-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "efaf66acdb9a4eb33d57702607d2e667b1a319d58c167a43c96896b97419b8b7", - "aarch64-unknown-linux-gnu": "81625f5c97f61e2e3d7e9f62c484b1aa5311f21bd6545451714b949a29da5435", - "x86_64-apple-darwin": "7718411adf3ea1480f3f018a643eb0550282aefe39e5ecb3f363a4a566a9398c", - "x86_64-pc-windows-msvc": "91889a7dbdceea585ff4d3b7856a6bb8f8a4eca83a0ff52a73542c2e67220eaa", - "x86_64-unknown-linux-gnu": "55aa2190d28dcfdf414d96dc5dcea9fe048fadcd583dc3981fec020869826111", - }, - "strip_prefix": "python", - }, - "3.10.8": { - "url": "20221106/cpython-{python_version}+20221106-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "d52b03817bd245d28e0a8b2f715716cd0fcd112820ccff745636932c76afa20a", - "aarch64-unknown-linux-gnu": "33170bef18c811906b738be530f934640491b065bf16c4d276c6515321918132", - "x86_64-apple-darwin": "525b79c7ce5de90ab66bd07b0ac1008bafa147ddc8a41bef15ffb7c9c1e9e7c5", - "x86_64-pc-windows-msvc": "f2b6d2f77118f06dd2ca04dae1175e44aaa5077a5ed8ddc63333c15347182bfe", - "x86_64-unknown-linux-gnu": "6c8db44ae0e18e320320bbaaafd2d69cde8bfea171ae2d651b7993d1396260b7", - }, - "strip_prefix": "python", - }, - "3.10.9": { - "url": "20230116/cpython-{python_version}+20230116-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "018d05a779b2de7a476f3b3ff2d10f503d69d14efcedd0774e6dab8c22ef84ff", - "aarch64-unknown-linux-gnu": "2003750f40cd09d4bf7a850342613992f8d9454f03b3c067989911fb37e7a4d1", - "x86_64-apple-darwin": "0e685f98dce0e5bc8da93c7081f4e6c10219792e223e4b5886730fd73a7ba4c6", - "x86_64-pc-windows-msvc": "59c6970cecb357dc1d8554bd0540eb81ee7f6d16a07acf3d14ed294ece02c035", - "x86_64-unknown-linux-gnu": "d196347aeb701a53fe2bb2b095abec38d27d0fa0443f8a1c2023a1bed6e18cdf", - }, - "strip_prefix": "python", - }, - "3.10.11": { - "url": "20230507/cpython-{python_version}+20230507-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "8348bc3c2311f94ec63751fb71bd0108174be1c4def002773cf519ee1506f96f", - "aarch64-unknown-linux-gnu": "c7573fdb00239f86b22ea0e8e926ca881d24fde5e5890851339911d76110bc35", - "ppc64le-unknown-linux-gnu": "73a9d4c89ed51be39dd2de4e235078281087283e9fdedef65bec02f503e906ee", - "x86_64-apple-darwin": "bd3fc6e4da6f4033ebf19d66704e73b0804c22641ddae10bbe347c48f82374ad", - "x86_64-pc-windows-msvc": "9c2d3604a06fcd422289df73015cd00e7271d90de28d2c910f0e2309a7f73a68", - "x86_64-unknown-linux-gnu": "c5bcaac91bc80bfc29cf510669ecad12d506035ecb3ad85ef213416d54aecd79", - }, - "strip_prefix": "python", - }, - "3.10.12": { - "url": "20230726/cpython-{python_version}+20230726-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "bc66c706ea8c5fc891635fda8f9da971a1a901d41342f6798c20ad0b2a25d1d6", - "aarch64-unknown-linux-gnu": "fee80e221663eca5174bd794cb5047e40d3910dbeadcdf1f09d405a4c1c15fe4", - "ppc64le-unknown-linux-gnu": "bb5e8cb0d2e44241725fa9b342238245503e7849917660006b0246a9c97b1d6c", - "s390x-unknown-linux-gnu": "8d33d435ae6fb93ded7fc26798cc0a1a4f546a4e527012a1e2909cc314b332df", - "x86_64-apple-darwin": "8a6e3ed973a671de468d9c691ed9cb2c3a4858c5defffcf0b08969fba9c1dd04", - "x86_64-pc-windows-msvc": "c1a31c353ca44de7d1b1a3b6c55a823e9c1eed0423d4f9f66e617bdb1b608685", - "x86_64-unknown-linux-gnu": "a476dbca9184df9fc69fe6309cda5ebaf031d27ca9e529852437c94ec1bc43d3", - }, - "strip_prefix": "python", - }, - "3.10.13": { - "url": "20240224/cpython-{python_version}+20240224-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "5fdc0f6a5b5a90fd3c528e8b1da8e3aac931ea8690126c2fdb4254c84a3ff04a", - "aarch64-unknown-linux-gnu": "a898a88705611b372297bb8fe4d23cc16b8603ce5f24494c3a8cfa65d83787f9", - "ppc64le-unknown-linux-gnu": "c23706e138a0351fc1e9def2974af7b8206bac7ecbbb98a78f5aa9e7535fee42", - "s390x-unknown-linux-gnu": "09be8fb2cdfbb4a93d555f268f244dbe4d8ff1854b2658e8043aa4ec08aede3e", - "x86_64-apple-darwin": "6378dfd22f58bb553ddb02be28304d739cd730c1f95c15c74955c923a1bc3d6a", - "x86_64-pc-windows-msvc": "086f7fe9156b897bb401273db8359017104168ac36f60f3af4e31ac7acd6634e", - "x86_64-unknown-linux-gnu": "d995d032ca702afd2fc3a689c1f84a6c64972ecd82bba76a61d525f08eb0e195", - }, - "strip_prefix": "python", - }, - "3.10.14": { - "url": "20240726/cpython-{python_version}+20240726-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "164d89f0df2feb689981864ecc1dffb19e6aa3696c8880166de555494fe92607", - "aarch64-unknown-linux-gnu": "39bcd46b4d70e40da177c55259be16d5c2be7a3f7f93f1e3bde47e71b4833f29", - "ppc64le-unknown-linux-gnu": "549d38b9ef59cba9ab2990025255231bfa1cb32b4bc5eac321667640fdee19d1", - "s390x-unknown-linux-gnu": "de4bc878a8666c734f983db971610980870148f333bda8b0c34abfaeae88d7ec", - "x86_64-apple-darwin": "1a1455838cd1e8ed0da14a152a2d559a2fd3a6047ba7013e841db4a35a228c1d", - "x86_64-pc-windows-msvc": "7f68821a8b5445267eca480660364ebd06ec84632b336770c6e39de07ac0f6c3", - "x86_64-unknown-linux-gnu": "32b34cd13d9d745b3db3f3b8398ab2c07de74544829915dbebd8dce39bdc405e", - }, - "strip_prefix": "python", - }, - "3.10.15": { - "url": "20241016/cpython-{python_version}+20241016-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "f64776f455a44c24d50f947c813738cfb7b9ac43732c44891bc831fa7940a33c", - "aarch64-unknown-linux-gnu": "eb58581f85fde83d1f3e8e1f8c6f5a15c7ae4fdbe3b1d1083931f9167fdd8dbc", - "ppc64le-unknown-linux-gnu": "0c45af4e7525e2db59901606db32b2896ac1e9830c6f95551402207f537c2ce4", - "s390x-unknown-linux-gnu": "de205896b070e6f5259ac0f2b3379eead875ea84e6a6ef533b89886fcbb46a4c", - "x86_64-apple-darwin": "90b46dfb1abd98d45663c7a2a8c45d3047a59391d8586d71b459cec7b75f662b", - "x86_64-pc-windows-msvc": "e48952619796c66ec9719867b87be97edca791c2ef7fbf87d42c417c3331609e", - "x86_64-unknown-linux-gnu": "3db2171e03c1a7acdc599fba583c1b92306d3788b375c9323077367af1e9d9de", - "x86_64-unknown-linux-musl": "ed519c47d9620eb916a6f95ec2875396e7b1a9ab993ee40b2f31b837733f318c", - }, - "strip_prefix": "python", - }, - "3.10.16": { - "url": "20250317/cpython-{python_version}+20250317-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "e99f8457d9c79592c036489c5cfa78df76e4762d170665e499833e045d82608f", - "aarch64-unknown-linux-gnu": "76d0f04d2444e77200fdc70d1c57480e29cca78cb7420d713bc1c523709c198d", - "ppc64le-unknown-linux-gnu": "39c9b3486de984fe1d72d90278229c70d6b08bcf69cd55796881b2d75077b603", - "riscv64-unknown-linux-gnu": "ebe949ada9293581c17d9bcdaa8f645f67d95f73eac65def760a71ef9dd6600d", - "s390x-unknown-linux-gnu": "9b2fc0b7f1c75b48e799b6fa14f7e24f5c61f2db82e3c65d13ed25e08f7f0857", - "x86_64-apple-darwin": "e03e62dbe95afa2f56b7344ff3bd061b180a0b690ff77f9a1d7e6601935e05ca", - "x86_64-pc-windows-msvc": "c7e0eb0ff5b36758b7a8cacd42eb223c056b9c4d36eded9bf5b9fe0c0b9aeb08", - "x86_64-unknown-linux-gnu": "b350c7e63956ca8edb856b91316328e0fd003a840cbd63d08253af43b2c63643", - "x86_64-unknown-linux-musl": "6ed64923ee4fbea4c5780f1a5a66651d239191ac10bd23420db4f5e4e0bf79c4", - }, - "strip_prefix": "python", - }, - "3.10.18": { - "url": "20250808/cpython-{python_version}+20250808-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "a94c02b2d597cd6b075a713fe4e9a909cc97ca6a3b2b2ce86eda21be2062d48e", - "aarch64-unknown-linux-gnu": "ef7de3b715d519e246d98ff7856247f7f7b357068705f09c6f300b7e7b76c701", - "ppc64le-unknown-linux-gnu": "f580efed11cc54e1a221c052e8bc88bfbc12844d3ca8949da828351a1232386e", - "riscv64-unknown-linux-gnu": "0d7e460e30203a9225b6f417ae972f66415a1cc0e32b37ebc48d195816282669", - "s390x-unknown-linux-gnu": "d4ada974daadb08a0184c19232ee3b03b3137aa70609760e1a94aaf7b12989ef", - "x86_64-apple-darwin": "da96fe2ba841640215788ddb9f151f03629360e37fcb94d4f76e5095b87df0d4", - "x86_64-pc-windows-msvc": "a648f3c9d136985ccfe57a5507e73d9d0839f7fd09eebd7c247857f2feaecb2a", - "x86_64-unknown-linux-gnu": "0b310a73bb9e7a495dbcad5f685e508ca2e7b36ee8f29301a52285730c425789", - "x86_64-unknown-linux-musl": "9cecf6ea2effbe183faebcf7e1160425a4ee17a68e49f2eefe5e1c59c51fa7ee", - }, - "strip_prefix": "python", - }, - "3.10.19": { - "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "43bda24c2fc073bc308bf631203b917a72640d59b59fdad4ba14503d84727012", - "aarch64-unknown-linux-gnu": "f77a8a8aa77f3f943126fa9215a25309da4bf20398fc8f4b4eec54b5fc7570ef", - "ppc64le-unknown-linux-gnu": "1c55d160fc4c3b93528cd6aaa2bb4ca6018a99e5a45919d33dc761a43a69f860", - "riscv64-unknown-linux-gnu": "21134d35721cdad4c881f35d0957cc19df9a45d194afb38a099faded3c1cfb4d", - "s390x-unknown-linux-gnu": "df0db070f1eb73ab4e371eea32213ddb3500737ea5560a6f0ffd65c82af64ddc", - "x86_64-apple-darwin": "76c12e633c09c2a790f8a958a55df4495527e0718d1875310c836e757c0c7b55", - "x86_64-pc-windows-msvc": "cfa08a4caf2df1b43551b843c052d6a8814e2ea0c97268b021f0423646c244c3", - "x86_64-unknown-linux-gnu": "fb1caac917d7b6497bb6f5950da5f1e48d05c43a498948dd97f85760c4382d9f", - "x86_64-unknown-linux-musl": "ba85013ed5ac7733fc6840168cc33ed19e9959b363dc80227d54f8fd9c92c0f4", - }, - "strip_prefix": "python", - }, - "3.10.20": { - "url": "20260414/cpython-{python_version}+20260414-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "f76cc83c7db16cfc8794bf6e44d834152b57d8bab4e04e823cbc59ed23ec22f8", - "aarch64-unknown-linux-gnu": "64932c8e8bbdf9d6b66ee85934f6f8ad1d18218b51a87ea06cefd3b84554a3e4", - "ppc64le-unknown-linux-gnu": "76b48eb26ef274045772186e63431419294c41baf6d5a372b722d4c9e711082e", - "riscv64-unknown-linux-gnu": "76e1ec72717d17493976fc176ec661f02412666d4f19e50908d8e4303c0511d5", - "s390x-unknown-linux-gnu": "2edf241199d11a3ef79a312737c1bcdb86908352c585ca14b667539080630e85", - "x86_64-apple-darwin": "95a2d794b8981723095190fa94b574ceb4272bb49d83b9e418bb90341e304d09", - "x86_64-pc-windows-msvc": "0d828683d30185ab9f1110ad2194ef384cef0533b8e0da7e03ce837548841788", - "x86_64-unknown-linux-gnu": "303047011b2c9f58504a930fc974d84547477cf69a3f2962f25552e2395c13af", - "x86_64-unknown-linux-musl": "84eb198d318f8b1b8bf59eef5d30d742e13afd97c213fa229578f8fdab0c406f", - }, - "strip_prefix": "python", - }, - "3.11.1": { - "url": "20230116/cpython-{python_version}+20230116-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "4918cdf1cab742a90f85318f88b8122aeaa2d04705803c7b6e78e81a3dd40f80", - "aarch64-unknown-linux-gnu": "debf15783bdcb5530504f533d33fda75a7b905cec5361ae8f33da5ba6599f8b4", - "x86_64-apple-darwin": "20a4203d069dc9b710f70b09e7da2ce6f473d6b1110f9535fb6f4c469ed54733", - "x86_64-pc-windows-msvc": "edc08979cb0666a597466176511529c049a6f0bba8adf70df441708f766de5bf", - "x86_64-unknown-linux-gnu": "02a551fefab3750effd0e156c25446547c238688a32fabde2995c941c03a6423", - }, - "strip_prefix": "python", - }, - "3.11.3": { - "url": "20230507/cpython-{python_version}+20230507-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "09e412506a8d63edbb6901742b54da9aa7faf120b8dbdce56c57b303fc892c86", - "aarch64-unknown-linux-gnu": "8190accbbbbcf7620f1ff6d668e4dd090c639665d11188ce864b62554d40e5ab", - "ppc64le-unknown-linux-gnu": "767d24f3570b35fedb945f5ac66224c8983f2d556ab83c5cfaa5f3666e9c212c", - "x86_64-apple-darwin": "f710b8d60621308149c100d5175fec39274ed0b9c99645484fd93d1716ef4310", - "x86_64-pc-windows-msvc": "24741066da6f35a7ff67bee65ce82eae870d84e1181843e64a7076d1571e95af", - "x86_64-unknown-linux-gnu": "da50b87d1ec42b3cb577dfd22a3655e43a53150f4f98a4bfb40757c9d7839ab5", - }, - "strip_prefix": "python", - }, - "3.11.4": { - "url": "20230726/cpython-{python_version}+20230726-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "cb6d2948384a857321f2aa40fa67744cd9676a330f08b6dad7070bda0b6120a4", - "aarch64-unknown-linux-gnu": "2e84fc53f4e90e11963281c5c871f593abcb24fc796a50337fa516be99af02fb", - "ppc64le-unknown-linux-gnu": "df7b92ed9cec96b3bb658fb586be947722ecd8e420fb23cee13d2e90abcfcf25", - "s390x-unknown-linux-gnu": "e477f0749161f9aa7887964f089d9460a539f6b4a8fdab5166f898210e1a87a4", - "x86_64-apple-darwin": "47e1557d93a42585972772e82661047ca5f608293158acb2778dccf120eabb00", - "x86_64-pc-windows-msvc": "878614c03ea38538ae2f758e36c85d2c0eb1eaaca86cd400ff8c76693ee0b3e1", - "x86_64-unknown-linux-gnu": "e26247302bc8e9083a43ce9e8dd94905b40d464745b1603041f7bc9a93c65d05", - }, - "strip_prefix": "python", - }, - "3.11.5": { - "url": "20230826/cpython-{python_version}+20230826-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "dab64b3580118ad2073babd7c29fd2053b616479df5c107d31fe2af1f45e948b", - "aarch64-unknown-linux-gnu": "bb5c5d1ea0f199fe2d3f0996fff4b48ca6ddc415a3dbd98f50bff7fce48aac80", - "ppc64le-unknown-linux-gnu": "14121b53e9c8c6d0741f911ae00102a35adbcf5c3cdf732687ef7617b7d7304d", - "s390x-unknown-linux-gnu": "fe459da39874443579d6fe88c68777c6d3e331038e1fb92a0451879fb6beb16d", - "x86_64-apple-darwin": "4a4efa7378c72f1dd8ebcce1afb99b24c01b07023aa6b8fea50eaedb50bf2bfc", - "x86_64-pc-windows-msvc": "00f002263efc8aea896bcfaaf906b1f4dab3e5cd3db53e2b69ab9a10ba220b97", - "x86_64-unknown-linux-gnu": "fbed6f7694b2faae5d7c401a856219c945397f772eea5ca50c6eb825cbc9d1e1", - }, - "strip_prefix": "python", - }, - "3.11.6": { - "url": "20231002/cpython-{python_version}+20231002-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "916c35125b5d8323a21526d7a9154ca626453f63d0878e95b9f613a95006c990", - "aarch64-unknown-linux-gnu": "3e26a672df17708c4dc928475a5974c3fb3a34a9b45c65fb4bd1e50504cc84ec", - "ppc64le-unknown-linux-gnu": "7937035f690a624dba4d014ffd20c342e843dd46f89b0b0a1e5726b85deb8eaf", - "s390x-unknown-linux-gnu": "f9f19823dba3209cedc4647b00f46ed0177242917db20fb7fb539970e384531c", - "x86_64-apple-darwin": "178cb1716c2abc25cb56ae915096c1a083e60abeba57af001996e8bc6ce1a371", - "x86_64-pc-windows-msvc": "3933545e6d41462dd6a47e44133ea40995bc6efeed8c2e4cbdf1a699303e95ea", - "x86_64-unknown-linux-gnu": "ee37a7eae6e80148c7e3abc56e48a397c1664f044920463ad0df0fc706eacea8", - }, - "strip_prefix": "python", - }, - "3.11.7": { - "url": "20240107/cpython-{python_version}+20240107-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "b042c966920cf8465385ca3522986b12d745151a72c060991088977ca36d3883", - "aarch64-unknown-linux-gnu": "b102eaf865eb715aa98a8a2ef19037b6cc3ae7dfd4a632802650f29de635aa13", - "ppc64le-unknown-linux-gnu": "b44e1b74afe75c7b19143413632c4386708ae229117f8f950c2094e9681d34c7", - "s390x-unknown-linux-gnu": "49520e3ff494708020f306e30b0964f079170be83e956be4504f850557378a22", - "x86_64-apple-darwin": "a0e615eef1fafdc742da0008425a9030b7ea68a4ae4e73ac557ef27b112836d4", - "x86_64-pc-windows-msvc": "67077e6fa918e4f4fd60ba169820b00be7c390c497bf9bc9cab2c255ea8e6f3e", - "x86_64-unknown-linux-gnu": "4a51ce60007a6facf64e5495f4cf322e311ba9f39a8cd3f3e4c026eae488e140", - }, - "strip_prefix": "python", - }, - "3.11.8": { - "url": "20240224/cpython-{python_version}+20240224-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "389a51139f5abe071a0d70091ca5df3e7a3dfcfcbe3e0ba6ad85fb4c5638421e", - "aarch64-unknown-linux-gnu": "389b9005fb78dd5a6f68df5ea45ab7b30d9a4b3222af96999e94fd20d4ad0c6a", - "ppc64le-unknown-linux-gnu": "eb2b31f8e50309aae493c6a359c32b723a676f07c641f5e8fe4b6aa4dbb50946", - "s390x-unknown-linux-gnu": "844f64f4c16e24965778281da61d1e0e6cd1358a581df1662da814b1eed096b9", - "x86_64-apple-darwin": "097f467b0c36706bfec13f199a2eaf924e668f70c6e2bd1f1366806962f7e86e", - "x86_64-pc-windows-msvc": "b618f1f047349770ee1ef11d1b05899840abd53884b820fd25c7dfe2ec1664d4", - "x86_64-unknown-linux-gnu": "94e13d0e5ad417035b80580f3e893a72e094b0900d5d64e7e34ab08e95439987", - }, - "strip_prefix": "python", - }, - "3.11.9": { - "url": "20240726/cpython-{python_version}+20240726-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "cbdac9462bab9671c8e84650e425d3f43b775752a930a2ef954a0d457d5c00c3", - "aarch64-unknown-linux-gnu": "4d17cf988abe24449d649aad3ef974091ab76807904d41839907061925b4c9e3", - "ppc64le-unknown-linux-gnu": "fc4f3c9ef9bfac2ed0282126ff376e544697ad04a5408d6429d46899d7d3bf21", - "s390x-unknown-linux-gnu": "e69b66e53e926460df044f44846eef3fea642f630e829719e1a4112fc370dc56", - "x86_64-apple-darwin": "dc3174666a30f4c38d04e79a80c3159b4b3aa69597c4676701c8386696811611", - "x86_64-pc-windows-msvc": "f694be48bdfec1dace6d69a19906b6083f4dd7c7c61f1138ba520e433e5598f8", - "x86_64-unknown-linux-gnu": "f6e955dc9ddfcad74e77abe6f439dac48ebca14b101ed7c85a5bf3206ed2c53d", - }, - "strip_prefix": "python", - }, - "3.11.10": { - "url": "20241016/cpython-{python_version}+20241016-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "5a69382da99c4620690643517ca1f1f53772331b347e75f536088c42a4cf6620", - "aarch64-unknown-linux-gnu": "803e49259280af0f5466d32829cd9d65a302b0226e424b3f0b261f9daf6aee8f", - "ppc64le-unknown-linux-gnu": "92b666d103902001322f42badbd68da92adc5cebb826af9c1c906c33166e2f34", - "s390x-unknown-linux-gnu": "6d584317651c1ad4a857cb32d1999707e8bb3046fcb2f156d80381814fa19fde", - "x86_64-apple-darwin": "1e23ffe5bc473e1323ab8f51464da62d77399afb423babf67f8e13c82b69c674", - "x86_64-pc-windows-msvc": "647b66ff4552e70aec3bf634dd470891b4a2b291e8e8715b3bdb162f577d4c55", - "x86_64-unknown-linux-gnu": "8b50a442b04724a24c1eebb65a36a0c0e833d35374dbdf9c9470d8a97b164cd9", - "x86_64-unknown-linux-musl": "d36fc77a8dd76155a7530f6235999a693b9e7c48aa11afeb5610a091cae5aa6f", - }, - "strip_prefix": "python", - }, - "3.11.13": { - "url": "20250808/cpython-{python_version}+20250808-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "d089bfd2c7b98a0942750a195e70d3172beda76d7747097b8afd87028b6e59b6", - "aarch64-unknown-linux-gnu": "bc57105f8a16acd57b71d926143c7f6ecf61729b40c8b4656f1b98bebd47c710", - "ppc64le-unknown-linux-gnu": "16a0165b0744940702b8fff80b8bf973ac914f78cb6fca28d389583f675e84de", - "riscv64-unknown-linux-gnu": "d8e62306be8f41c46bcd62ca68f91a1467f47adff632a35ff413dc1043ed56e8", - "s390x-unknown-linux-gnu": "4e302a4514a73baefdd9b327062bdafeb4115a799deec91c185f6ab45a857241", - "x86_64-apple-darwin": "d946d618f8bba8308b67e460a30612a71e2ccc309f85f6628aaae24e2b816981", - "x86_64-pc-windows-msvc": "ed963aee33d29ad8abfbb5fe63e42f57a2638a4a11a88e11d8bb66e61f20a6e5", - "aarch64-pc-windows-msvc": "a632857c966237e7fd38b44c47c350f6e30d8ec54dcad6c832865ad670f0f22f", - "x86_64-unknown-linux-gnu": "3ad988c702cbb017fef1208d47dea4138a2e85fd0f7f01ec5e1e335e597131b9", - "x86_64-unknown-linux-musl": "3a5810f0696f844289aa06d5c3a1efeab66eee999c25196b7d1954192a2c2100", - }, - "strip_prefix": "python", - }, - "3.11.14": { - "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "6de5572b33c65af1c9b7caf00ec593fb04cffb7e14fa393a98261bb9bc464713", - "aarch64-unknown-linux-gnu": "510edb027527413c4249256194cb8ad2590b52dd93f7123b4cb341aff5d05894", - "ppc64le-unknown-linux-gnu": "4e0bc6a818e0c6a9d7d3ebe1a95591fd84440520577aa837facc96a4b7a80e35", - "riscv64-unknown-linux-gnu": "16519e69297144f81b2421333bc9e0b6466cf3c84749b216b695cfb4c9deb32f", - "s390x-unknown-linux-gnu": "5f9c1b203cdf34c8bff1aef69b63bbf11309bd16ca6e429d8c3651eaa2b3d080", - "x86_64-apple-darwin": "4891cbf34e8652b7bd1054b9502395e4b7e048e2e517c040fbf6c8297cb954d6", - "x86_64-pc-windows-msvc": "5223b83ed9e2aa5e9e17d2ebcf767956e998876339b9cde1980a47e9d4655fb6", - "aarch64-pc-windows-msvc": "38d0d1466561e15965e8d2c20f5e5be649598f55c761ecab553d087fbd217337", - "x86_64-unknown-linux-gnu": "60f0bd473d861cc45d3401d9914e47ccb9fa037f88a91879ed517a62042b8477", - "x86_64-unknown-linux-musl": "25e82d1e85b90a8ab724ee633a1811b1921797f5c25ee69c6595052371b91a87", - }, - "strip_prefix": "python", - }, - "3.11.15": { - "url": "20260414/cpython-{python_version}+20260414-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "a57ffd435652092d16b30e783f9826c55e9c64b0f0a72cbae0a9f39e663137fb", - "aarch64-unknown-linux-gnu": "77836944ae15b74e0b25bdc68a4703a340f2ccb684effc0f45fbd7910e1a1f39", - "ppc64le-unknown-linux-gnu": "30a2107f000dbe304820627cbe2cc257027c20f3241d96e6c7df796b69ac2062", - "riscv64-unknown-linux-gnu": "373b98fbf2d04099139a2f6be57593714382ed790be7e7419e358830c23ddd0f", - "s390x-unknown-linux-gnu": "7838efa839158c80568de35ac78d438f564f4c32272a2fe7d9e14a9b351d1a62", - "x86_64-apple-darwin": "317055d80e553764feeaef432d833dd8385c14b83465a8b3fa7c2b7819cba681", - "x86_64-pc-windows-msvc": "8e69ecf1d9fc194e029aafa608d483bf24ccaa8f56d456d7009f20462d62ad23", - "aarch64-pc-windows-msvc": "a882abe4876985c9dc3d433420548506fb0cc9bb9d9fe336a2d3aaf28922aa45", - "x86_64-unknown-linux-gnu": "8b14030dd3af9ea7f7c51b4c90feb04afd8a8f45435727e67b875270bd08f3bc", - "x86_64-unknown-linux-musl": "ca92d3a68a39fa330498b09714733f347bead7313ba9d9b7fbed837aa4ba7796", - }, - "strip_prefix": "python", - }, - "3.12.0": { - "url": "20231002/cpython-{python_version}+20231002-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "4734a2be2becb813830112c780c9879ac3aff111a0b0cd590e65ec7465774d02", - "aarch64-unknown-linux-gnu": "bccfe67cf5465a3dfb0336f053966e2613a9bc85a6588c2fcf1366ef930c4f88", - "ppc64le-unknown-linux-gnu": "b5dae075467ace32c594c7877fe6ebe0837681f814601d5d90ba4c0dfd87a1f2", - "s390x-unknown-linux-gnu": "5681621349dd85d9726d1b67c84a9686ce78f72e73a6f9e4cc4119911655759e", - "x86_64-apple-darwin": "5a9e88c8aa52b609d556777b52ebde464ae4b4f77e4aac4eb693af57395c9abf", - "x86_64-pc-windows-msvc": "facfaa1fbc8653f95057f3c4a0f8aa833dab0e0b316e24ee8686bc761d4b4f8d", - "x86_64-unknown-linux-gnu": "e51a5293f214053ddb4645b2c9f84542e2ef86870b8655704367bd4b29d39fe9", - }, - "strip_prefix": "python", - }, - "3.12.1": { - "url": "20240107/cpython-{python_version}+20240107-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "f93f8375ca6ac0a35d58ff007043cbd3a88d9609113f1cb59cf7c8d215f064af", - "aarch64-unknown-linux-gnu": "236533ef20e665007a111c2f36efb59c87ae195ad7dca223b6dc03fb07064f0b", - "ppc64le-unknown-linux-gnu": "78051f0d1411ee62bc2af5edfccf6e8400ac4ef82887a2affc19a7ace6a05267", - "s390x-unknown-linux-gnu": "60631211c701f8d2c56e5dd7b154e68868128a019b9db1d53a264f56c0d4aee2", - "x86_64-apple-darwin": "eca96158c1568dedd9a0b3425375637a83764d1fa74446438293089a8bfac1f8", - "x86_64-pc-windows-msvc": "fd5a9e0f41959d0341246d3643f2b8794f638adc0cec8dd5e1b6465198eae08a", - "x86_64-unknown-linux-gnu": "74e330b8212ca22fd4d9a2003b9eec14892155566738febc8e5e572f267b9472", - }, - "strip_prefix": "python", - }, - "3.12.2": { - "url": "20240224/cpython-{python_version}+20240224-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "01c064c00013b0175c7858b159989819ead53f4746d40580b5b0b35b6e80fba6", - "aarch64-unknown-linux-gnu": "e52550379e7c4ac27a87de832d172658bc04150e4e27d4e858e6d8cbb96fd709", - "ppc64le-unknown-linux-gnu": "74bc02c4bbbd26245c37b29b9e12d0a9c1b7ab93477fed8b651c988b6a9a6251", - "s390x-unknown-linux-gnu": "ecd6b0285e5eef94deb784b588b4b425a15a43ae671bf206556659dc141a9825", - "x86_64-apple-darwin": "a53a6670a202c96fec0b8c55ccc780ea3af5307eb89268d5b41a9775b109c094", - "x86_64-pc-windows-msvc": "1e5655a6ccb1a64a78460e4e3ee21036c70246800f176a6c91043a3fe3654a3b", - "x86_64-unknown-linux-gnu": "57a37b57f8243caa4cdac016176189573ad7620f0b6da5941c5e40660f9468ab", - }, - "strip_prefix": "python", - }, - "3.12.3": { - "url": "20240415/cpython-{python_version}+20240415-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "ccc40e5af329ef2af81350db2a88bbd6c17b56676e82d62048c15d548401519e", - "aarch64-unknown-linux-gnu": "ec8126de97945e629cca9aedc80a29c4ae2992c9d69f2655e27ae73906ba187d", - "ppc64le-unknown-linux-gnu": "c5dcf08b8077e617d949bda23027c49712f583120b3ed744f9b143da1d580572", - "s390x-unknown-linux-gnu": "872fc321363b8cdd826fd2cb1adfd1ceb813bc1281f9d410c1c2c4e177e8df86", - "x86_64-apple-darwin": "c37a22fca8f57d4471e3708de6d13097668c5f160067f264bb2b18f524c890c8", - "x86_64-pc-windows-msvc": "f7cfa4ad072feb4578c8afca5ba9a54ad591d665a441dd0d63aa366edbe19279", - "x86_64-unknown-linux-gnu": "a73ba777b5d55ca89edef709e6b8521e3f3d4289581f174c8699adfb608d09d6", - }, - "strip_prefix": "python", - }, - "3.12.4": { - "url": "20240726/cpython-{python_version}+20240726-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "1801025e825c04b3907e4ef6220a13607bc0397628c9485897073110ef7fde15", - "aarch64-unknown-linux-gnu": "a098b18b7e9fea0c66867b76c0124fce9465765017572b2e7b522154c87c78d7", - "ppc64le-unknown-linux-gnu": "04011c4c5b7fe34b0b895edf4ad8748e410686c1d69aaee11d6688d481023bcb", - "s390x-unknown-linux-gnu": "8f8f3e29cf0c2facdbcfee70660939fda7667ac24fee8656d3388fc72f3acc7c", - "x86_64-apple-darwin": "4c325838c1b0ed13698506fcd515be25c73dcbe195f8522cf98f9148a97601ed", - "x86_64-pc-windows-msvc": "74309b0f322716409883d38c621743ea7fa0376eb00927b8ee1e1671d3aff450", - "x86_64-unknown-linux-gnu": "e133dd6fc6a2d0033e2658637cc22e9c95f9d7073b80115037ee1f16417a54ac", - }, - "strip_prefix": "python", - }, - "3.12.7": { - "url": "20241016/cpython-{python_version}+20241016-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "4c18852bf9c1a11b56f21bcf0df1946f7e98ee43e9e4c0c5374b2b3765cf9508", - "aarch64-unknown-linux-gnu": "bba3c6be6153f715f2941da34f3a6a69c2d0035c9c5396bc5bb68c6d2bd1065a", - "ppc64le-unknown-linux-gnu": "0a1d1d92e33a969bd2f40a80af53c97b6c0cc1060d384ceff50ff801593bf9d6", - "s390x-unknown-linux-gnu": "935676a0c960b552f95e9ac2e1e385de5de4b34038ff65ffdc688838f1189c17", - "x86_64-apple-darwin": "60c5271e7edc3c2ab47440b7abf4ed50fbc693880b474f74f05768f5b657045a", - "x86_64-pc-windows-msvc": "f05531bff16fa77b53be0776587b97b466070e768e6d5920894de988bdcd547a", - "x86_64-unknown-linux-gnu": "43576f7db1033dd57b900307f09c2e86f371152ac8a2607133afa51cbfc36064", - "x86_64-unknown-linux-musl": "5ed4a4078db3cbac563af66403aaa156cd6e48831d90382a1820db2b120627b5", - }, - "strip_prefix": "python", - }, - "3.12.8": { - "url": "20241206/cpython-{python_version}+20241206-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "e3c4aa607717b23903ca2650d5c3ee24f89b97543e2db2b0f463bddc7a9e92f3", - "aarch64-unknown-linux-gnu": "ce674b55442b732973afb2932c281bb1ded4ad7e22bcf9b07071165770758c7e", - "ppc64le-unknown-linux-gnu": "b7214790b273de9ed0532420054b72ba1393d62d2fc844ec55ade193771bd90c", - "s390x-unknown-linux-gnu": "73102f5dbd7d1e7e9c2f2c80aedf2893d99a7fa407f6674ec8b2f57ba07daee5", - "x86_64-apple-darwin": "3ba35c706577d755e8e52a4c161a042464577c0e695e2a605362fa469e26de10", - "x86_64-pc-windows-msvc": "767b4be3ddf6b99e5ade519789c1615c191d8cf99d5aff4685cc18b48931f1e6", - "x86_64-unknown-linux-gnu": "b9d6ee5ddac1198e72d53112698773fc8bb597de095592eb849ca794306699ba", - "x86_64-unknown-linux-musl": "6f305888703691dd04cfff85284d23ea0b0146ed7c4415e472f1fb72b3f32cdf", - }, - "strip_prefix": "python", - }, - "3.12.9": { - "url": "20250317/cpython-{python_version}+20250317-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "7c7fd9809da0382a601a79287b5d62d61ce0b15f5a5ee836233727a516e85381", - "aarch64-unknown-linux-gnu": "00c6bf9acef21ac741fea24dc449d0149834d30e9113429e50a95cce4b00bb80", - "ppc64le-unknown-linux-gnu": "25d77599dfd5849f17391d92da0da99079e4e94f19a881f763f5cc62530ef7e1", - "riscv64-unknown-linux-gnu": "e97ab0fdf443b302c56a52b4fd08f513bf3be66aa47263f0f9df3c6e60e05f2e", - "s390x-unknown-linux-gnu": "7492d079ffa8425c8f6c58e43b237c37e3fb7b31e2e14635927bb4d3397ba21e", - "x86_64-apple-darwin": "1ee1b1bb9fbce5c145c4bec9a3c98d7a4fa22543e09a7c1d932bc8599283c2dc", - "x86_64-pc-windows-msvc": "d15361fd202dd74ae9c3eece1abdab7655f1eba90bf6255cad1d7c53d463ed4d", - "x86_64-unknown-linux-gnu": "ef382fb88cbb41a3b0801690bd716b8a1aec07a6c6471010bcc6bd14cd575226", - "x86_64-unknown-linux-musl": "94e3837da1adf9964aab2d6047b33f70167de3096d1f9a2d1fa9340b1bbf537d", - }, - "strip_prefix": "python", - }, - "3.12.11": { - "url": "20250808/cpython-{python_version}+20250808-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "8792c4a84c364ab975feca0c27d3157a5435b7baab325a346ae56b223893b661", - "aarch64-unknown-linux-gnu": "4d7ba5314fab02130d6538f074961ffbf61310cade9180e59026074f9a8939cb", - "aarch64-pc-windows-msvc": "00bf7d7e8bcf5d1e9c4dfca0247d8e035147777cd57ee9d4c64dedca86b0a464", - "ppc64le-unknown-linux-gnu": "2c862eb40a81549d9c11e6bf5a7f07c3406310b14e6a4d16dcdf1c4763ef7090", - "riscv64-unknown-linux-gnu": "0bb729b95fabd49c7b495f7c44a9086e3970ea57daf66365741574bd36a17e81", - "s390x-unknown-linux-gnu": "99e465882d217d24ac90e99fac8f32e6a644d0340ac05ee510fb5cdf53f0cfb8", - "x86_64-apple-darwin": "e0c932709dafb05f00e528a7560ef8ee559ac82b75faca60dd1245bca1c1553f", - "x86_64-pc-windows-msvc": "81214ef71964a40ec269a79067ca490d45298c350583bc3af0e5781451a05c3c", - "x86_64-unknown-linux-gnu": "63d78840bf209af8da8f24e335d910f88387b892ca9187be571d481c071751bb", - "x86_64-unknown-linux-musl": "d633d070780590aa03ac5575cd9d7b9e17682d80f14b400313c009c387cf706b", - }, - "strip_prefix": "python", - }, - "3.12.12": { - "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.tar.gz", - "sha256": { - "aarch64-apple-darwin": "5e110cb821d2eb8246065d3b46faa655180c976c4e17250f7883c634a629bc63", - "aarch64-unknown-linux-gnu": "81b644d166e0bfb918615af8a2363f8fcf26eccdcc60a5334b6a62c088470bac", - "aarch64-pc-windows-msvc": "b190fed7c2b0f6e1010f554a0d1fd191c0754c4c0718e69d9d795ae559613780", - "ppc64le-unknown-linux-gnu": "024f5e5678c9768d45cc24d37a8e9d265aae86c4a4602352dee3d7deba367052", - "riscv64-unknown-linux-gnu": "b13c57fc372c131e667a99b9680f41c0b4da571cf99ed412103c2fe9ad5ed1fb", - "s390x-unknown-linux-gnu": "2bf05bdd56cdf5ea4fd9f2faf151ea4211be96a0d1f4230b85f5dcae620d6400", - "x86_64-apple-darwin": "687052a046d33be49dc95dd671816709067cf6176ed36c93ea61b1fe0b883b0f", - "x86_64-pc-windows-msvc": "cff398b3f520c442a1b085dd347126c10c1b03f01ccc0decd8c897a687e893f1", - "x86_64-unknown-linux-gnu": "80c3882f14e15cef8260ef5257d198e8f4371ca265887431d939e0d561de3253", - "x86_64-unknown-linux-musl": "0a461330b9b89f2ea3088dde10d7a3f96aa65897b7c5ce2404fa3b5c4b8daa14", - }, - "strip_prefix": "python", - }, - "3.12.13": { - "url": "20260414/cpython-{python_version}+20260414-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "8966b2bcd9fa03ba22c080ad15a86bc12e41a00122b16f4b3740e302261124d9", - "aarch64-unknown-linux-gnu": "355d981eafb9b2870af79ddc106ced7266b6f6d2101d8fbcb05620fa386642b9", - "ppc64le-unknown-linux-gnu": "4aef4cffe73c4a65ea486f14d684a9ad3f831a354174d163bb531b5baa70fc49", - "riscv64-unknown-linux-gnu": "c2629d69324155132343913f064be93509bd162531e08a292e50c3973ec8b5db", - "s390x-unknown-linux-gnu": "e5baafd64180f45165d2751b25d1bcc89254eefc7926f3ab341fc61b541d7606", - "x86_64-apple-darwin": "801b03fbe004181d55a02ebd8b4e04d74973e70d716062aebe3b3cf32e9be297", - "x86_64-pc-windows-msvc": "c5a9e011e284c49c48106ca177342f3e3f64e95b4c6652d4a382cc7c9bb1cc46", - "aarch64-pc-windows-msvc": "f55326c894fde76fc0faffe95d2bce60be533c88a8c44c1b88bbbc17bf6a5cd5", - "x86_64-unknown-linux-gnu": "cdcf8724d46e4857f8db5ee9f4252dc2f5da34f7940294ec6b312389dd3f41e0", - "x86_64-unknown-linux-musl": "d10e971238c130fdf25e577c6538a3effa5589d5fcf53665e3c711edd6a6ff2f", - }, - "strip_prefix": "python", - }, - "3.13.0": { - "url": "20241016/cpython-{python_version}+20241016-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "31397953849d275aa2506580f3fa1cb5a85b6a3d392e495f8030e8b6412f5556", - "aarch64-unknown-linux-gnu": "e8378c0162b2e0e4cc1f62b29443a3305d116d09583304dbb0149fecaff6347b", - "ppc64le-unknown-linux-gnu": "fc4b7f27c4e84c78f3c8e6c7f8e4023e4638d11f1b36b6b5ce457b1926cebb53", - "s390x-unknown-linux-gnu": "66b19e6a07717f6cfcd3a8ca953f0a2eaa232291142f3d26a8d17c979ec0f467", - "x86_64-apple-darwin": "cff1b7e7cd26f2d47acac1ad6590e27d29829776f77e8afa067e9419f2f6ce77", - "x86_64-pc-windows-msvc": "b25926e8ce4164cf103bacc4f4d154894ea53e07dd3fdd5ebb16fb1a82a7b1a0", - "x86_64-unknown-linux-gnu": "2c8cb15c6a2caadaa98af51df6fe78a8155b8471cb3dd7b9836038e0d3657fb4", - "x86_64-unknown-linux-musl": "2f61ee3b628a56aceea63b46c7afe2df3e22a61da706606b0c8efda57f953cf4", - "aarch64-apple-darwin-freethreaded": "efc2e71c0e05bc5bedb7a846e05f28dd26491b1744ded35ed82f8b49ccfa684b", - "aarch64-unknown-linux-gnu-freethreaded": "59b50df9826475d24bb7eff781fa3949112b5e9c92adb29e96a09cdf1216d5bd", - "ppc64le-unknown-linux-gnu-freethreaded": "1217efa5f4ce67fcc9f7eb64165b1bd0912b2a21bc25c1a7e2cb174a21a5df7e", - "s390x-unknown-linux-gnu-freethreaded": "6c3e1e4f19d2b018b65a7e3ef4cd4225c5b9adfbc490218628466e636d5c4b8c", - "x86_64-apple-darwin-freethreaded": "2e07dfea62fe2215738551a179c87dbed1cc79d1b3654f4d7559889a6d5ce4eb", - "x86_64-pc-windows-msvc-freethreaded": "bfd89f9acf866463bc4baf01733da5e767d13f5d0112175a4f57ba91f1541310", - "x86_64-unknown-linux-gnu-freethreaded": "a73adeda301ad843cce05f31a2d3e76222b656984535a7b87696a24a098b216c", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.1": { - "url": "20241205/cpython-{python_version}+20241205-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "88b88b609129c12f4b3841845aca13230f61e97ba97bd0fb28ee64b0e442a34f", - "aarch64-unknown-linux-gnu": "fdfa86c2746d2ae700042c461846e6c37f70c249925b58de8cd02eb8d1423d4e", - "ppc64le-unknown-linux-gnu": "27b20b3237c55430ca1304e687d021f88373f906249f9cd272c5ff2803d5e5c3", - "s390x-unknown-linux-gnu": "7d0187e20cb5e36c689eec27e4d3de56d8b7f1c50dc5523550fc47377801521f", - "x86_64-apple-darwin": "47eef6efb8664e2d1d23a7cdaf56262d784f8ace48f3bfca1b183e95a49888d6", - "x86_64-pc-windows-msvc": "f51f0493a5f979ff0b8d8c598a8d74f2a4d86a190c2729c85e0af65c36a9cbbe", - "x86_64-unknown-linux-gnu": "242b2727df6c1e00de6a9f0f0dcb4562e168d27f428c785b0eb41a6aeb34d69a", - "x86_64-unknown-linux-musl": "76b30c6373b9c0aa2ba610e07da02f384aa210ac79643da38c66d3e6171c6ef5", - "aarch64-apple-darwin-freethreaded": "08f05618bdcf8064a7960b25d9ba92155447c9b08e0cf2f46a981e4c6a1bb5a5", - "aarch64-unknown-linux-gnu-freethreaded": "9f2fcb809f9ba6c7c014a8803073a88786701a98971135bce684355062e4bb35", - "ppc64le-unknown-linux-gnu-freethreaded": "15ceea78dff78ca8ccaac8d9c54b808af30daaa126f1f561e920a6896e098634", - "s390x-unknown-linux-gnu-freethreaded": "ed3c6118d1d12603309c930e93421ac7a30a69045ffd43006f63ecf71d72c317", - "x86_64-apple-darwin-freethreaded": "dc780fecd215d2cc9e573abf1e13a175fcfa8f6efd100ef888494a248a16cda8", - "x86_64-pc-windows-msvc-freethreaded": "7537b2ab361c0eabc0eabfca9ffd9862d7f5f6576eda13b97e98aceb5eea4fd3", - "x86_64-unknown-linux-gnu-freethreaded": "9ec1b81213f849d91f5ebe6a16196e85cd6ff7c05ca823ce0ab7ba5b0e9fee84", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.2": { - "url": "20250317/cpython-{python_version}+20250317-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "faa44274a331eb39786362818b21b3a4e74514e8805000b20b0e55c590cecb94", - "aarch64-unknown-linux-gnu": "9c67260446fee6ea706dad577a0b32936c63f449c25d66e4383d5846b2ab2e36", - "ppc64le-unknown-linux-gnu": "345b53d2f86c9dbd7f1320657cb227ff9a42ef63ff21f129abbbc8c82a375147", - "riscv64-unknown-linux-gnu": "172d22b2330737f3a028ea538ffe497c39a066a8d3200b22dd4d177a3332ad85", - "s390x-unknown-linux-gnu": "ec3b16ea8a97e3138acec72bc5ff35949950c62c8994a8ec8e213fd93f0e806b", - "x86_64-apple-darwin": "ee4526e84b5ce5b11141c50060b385320f2773616249a741f90c96d460ce8e8f", - "x86_64-pc-windows-msvc": "84d7b52f3558c8e35c670a4fa14080c75e3ec584adfae49fec8b51008b75b21e", - "x86_64-unknown-linux-gnu": "db011f0cd29cab2291584958f4e2eb001b0e6051848d89b38a2dc23c5c54e512", - "x86_64-unknown-linux-musl": "00bb2d629f7eacbb5c6b44dc04af26d1f1da64cee3425b0d8eb5135a93830296", - "aarch64-apple-darwin-freethreaded": "c98c9c977e6fa05c3813bd49f3553904d89d60fed27e2e36468da7afa1d6d5e2", - "aarch64-unknown-linux-gnu-freethreaded": "b8635e59e3143fd17f19a3dfe8ccc246ee6587c87da359bd1bcab35eefbb5f19", - "ppc64le-unknown-linux-gnu-freethreaded": "6ae8fa44cb2edf4ab49cff1820b53c40c10349c0f39e11b8cd76ce7f3e7e1def", - "riscv64-unknown-linux-gnu-freethreaded": "2af1b8850c52801fb6189e7a17a51e0c93d9e46ddefcca72247b76329c97d02a", - "s390x-unknown-linux-gnu-freethreaded": "c074144cc80c2af32c420b79a9df26e8db405212619990c1fbdd308bd75afe3f", - "x86_64-apple-darwin-freethreaded": "0d73e4348d8d4b5159058609d2303705190405b485dd09ad05d870d7e0f36e0f", - "x86_64-pc-windows-msvc-freethreaded": "c51b4845fda5421e044067c111192f645234081d704313f74ee77fa013a186ea", - "x86_64-unknown-linux-gnu-freethreaded": "1aea5062614c036904b55c1cc2fb4b500b7f6f7a4cacc263f4888889d355eef8", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.4": { - "url": "20250610/cpython-{python_version}+20250610-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "c2ce6601b2668c7bd1f799986af5ddfbff36e88795741864aba6e578cb02ed7f", - "aarch64-unknown-linux-gnu": "3c2596ece08ffe17e11bc1f27aeb4ce1195d2490a83d695d36ef4933d5c5ca53", - "ppc64le-unknown-linux-gnu": "b3cc13ee177b8db1d3e9b2eac413484e3c6a356f97d91dc59de8d3fd8cf79d6b", - "riscv64-unknown-linux-gnu": "d1b989e57a9ce29f6c945eeffe0e9750c222fdd09e99d2f8d6b0d8532a523053", - "s390x-unknown-linux-gnu": "d1d19fb01961ac6476712fdd6c5031f74c83666f6f11aa066207e9a158f7e3d8", - "x86_64-apple-darwin": "79feb6ca68f3921d07af52d9db06cf134e6f36916941ea850ab0bc20f5ff638b", - "x86_64-pc-windows-msvc": "29ac3585cc2dcfd79e3fe380c272d00e9d34351fc456e149403c86d3fea34057", - "x86_64-unknown-linux-gnu": "44e5477333ebca298a7a0a316985c6c3533b8645f92a83f7f73c44033832bf32", - "x86_64-unknown-linux-musl": "a3afbfa94b9ff4d9fc426b47eb3c8446cada535075b8d51b7bdc9d9ab9911fc2", - "aarch64-apple-darwin-freethreaded": "278dccade56b4bbeecb9a613b77012cf5c1433a5e9b8ef99230d5e61f31d9e02", - "aarch64-unknown-linux-gnu-freethreaded": "b1c1bd6ab9ef95b464d92a6a911cef1a8d9f0b0f6a192f694ef18ed15d882edf", - "ppc64le-unknown-linux-gnu-freethreaded": "ed66ae213a62b286b9b7338b816ccd2815f5248b7a28a185dc8159fe004149ae", - "riscv64-unknown-linux-gnu-freethreaded": "913264545215236660e4178bc3e5b57a20a444a8deb5c11680c95afc960b4016", - "s390x-unknown-linux-gnu-freethreaded": "7556a38ab5e507c1ec22bc38f9859982bc956cab7f4de05a2faac114feb306db", - "x86_64-apple-darwin-freethreaded": "64ab7ac8c88002d9ba20a92f72945bfa350268e944a7922500af75d20330574d", - "x86_64-pc-windows-msvc-freethreaded": "9457504547edb2e0156bf76b53c7e4941c7f61c0eff9fd5f4d816d3df51c58e3", - "x86_64-unknown-linux-gnu-freethreaded": "864df6e6819e8f8e855ce30f34410fdc5867d0616e904daeb9a40e5806e970d7", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.6": { - "url": "20250808/cpython-{python_version}+20250808-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "8a1efa6af4e80f08e2c97dda822a3d6c24d6c98e518242f802c6a43ae8401488", - "aarch64-unknown-linux-gnu": "11fa0591ae2211c08a42ae54944260e36ddf88a1d5604ea0c49e2477be4e5388", - "ppc64le-unknown-linux-gnu": "8dcf34ae1a685fe1893b52917ae04f23328edadc4acae28499d43850c2bdd26c", - "riscv64-unknown-linux-gnu": "f8ed75aa6cc2011a046be00b629c3c8295267f34280324feaff34c73e7afce39", - "s390x-unknown-linux-gnu": "7707ee5d19a78bc64ef8a66751ec7f97b64ea06714c7b1b52e8b321c2923ead8", - "x86_64-apple-darwin": "27badce7201321a8363219e438a6205165e5b4884012b1046532203df2ec9379", - "x86_64-pc-windows-msvc": "af5cc733c33b9aa9f1d74c81a59351e9b27215486d8b6cdbc06d97646a58c953", - "aarch64-pc-windows-msvc": "8e1617bd407ec1a874499daab26ae95080d1e0267ae616d34490137a28705827", - "aarch64-pc-windows-msvc-freethreaded": "552cfabcc3b103f4b1c4036d2592d5f0373c9554a2c4d2b6631b04ef7e592067", - "x86_64-unknown-linux-gnu": "f844e8c8b6847628b472f7e97d8893a4e93acd5382a902b465776063668c4d64", - "x86_64-unknown-linux-musl": "70076dea0ff65b3c05aae1a97b4a556bf613cc73db30309e59134f9d318f4f7b", - "aarch64-apple-darwin-freethreaded": "f2143304012e021a603bf1807bf3e4ce163832e43ab9a9829e53cb136497f207", - "aarch64-unknown-linux-gnu-freethreaded": "d84a7d64c284be387386b9f5da273f6d05486eb6bd8f9e86e2575cb59604cb22", - "ppc64le-unknown-linux-gnu-freethreaded": "e76fcaf1bf80a615520dbe7f85ca0bb557fad96d132d836b0ac721e7cc1e2a37", - "riscv64-unknown-linux-gnu-freethreaded": "24e08a39ba4fc77753e61541e52eed39cc871f4a92a80a3c5dd495056bd8eff9", - "s390x-unknown-linux-gnu-freethreaded": "1609b223fd38a4a7a4d20e7173d7d9390fe2258f7dd9a15dc9ef0fa49613735d", - "x86_64-apple-darwin-freethreaded": "4360a1278dd0a96b526d108c8fd23498a9d2028dd7791e510fd51ff5ea3f462a", - "x86_64-pc-windows-msvc-freethreaded": "4e727cdbe4057b16a170f887c0fa4227a825ac59bcda84ae946c77cc932af78c", - "x86_64-unknown-linux-gnu-freethreaded": "e48c13c59cc3c01b79f63c8bccec27d2db6e97f64213b8731e2077b6ed8ed52c", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.9": { - "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "1f3568d17383426d52350c2ef7c93c1a5a043198b860cb05e5d19b35f9c25cef", - "aarch64-unknown-linux-gnu": "0a56d11b0fb1662e67f892b9d5d1717aef06f24dbb8362bc25b8f784e620d44e", - "ppc64le-unknown-linux-gnu": "99492123902bd5e9a6b1a30135061e93a2e6a11d25107a741d5a756e91054448", - "riscv64-unknown-linux-gnu": "b3dce3e4ef508773521e1ee1be989fff6118f8fd1fbbd0491d7ff7dfbc98ef06", - "s390x-unknown-linux-gnu": "f10e34aaa856c1b8a69c2ea4a9a6723d520443d1a957bf66dc55491334ca0c1e", - "x86_64-apple-darwin": "48c0f3ca5d31e90658ef99138dc21865bb62f388ab97a1ce72cac176da194ab0", - "x86_64-pc-windows-msvc": "874593f641f31ea101440c70f81768c35d4d7d6df111fde63094db67465ef787", - "aarch64-pc-windows-msvc": "20db43873d3c4c2175d866806545e4ad4ec6bb72ca95e60082a4df6c24567e8c", - "aarch64-pc-windows-msvc-freethreaded": "743ff69935ef28834621647dab30f032dfcd80315732917531eea333210941c7", - "x86_64-unknown-linux-gnu": "6f05b91ee8c7e6dd0f9c60b95bb29130e2d623961de6578b643e80ddd83f96b6", - "x86_64-unknown-linux-musl": "ad987197034185e628715da504a50613af213dc21ba6d5ccaeab3db2c464aa6c", - "aarch64-apple-darwin-freethreaded": "eae1272a72ccce601590a10a9ca2a58199b5fcdf022aa603a527e3e2a04de9bc", - "aarch64-unknown-linux-gnu-freethreaded": "a6e72f9de5d9b46cf6968d6a492f2401a919f9b959f8da2d87f43484b80169ee", - "ppc64le-unknown-linux-gnu-freethreaded": "0ed5c65437f875c58ba1bee2b8d261d18698d3d0347a2e66f8902fce022a2cda", - "riscv64-unknown-linux-gnu-freethreaded": "584e481d9b5225ffaf02f158fb26d2818207e65fc3c6dc21a6d500277f739220", - "s390x-unknown-linux-gnu-freethreaded": "7fa7fb912ca989ceac026a332d56a2c7d6d16ab0e94d89e690de5aade26103e2", - "x86_64-apple-darwin-freethreaded": "e2bf5fa6a3ef443ade362e08b0a19bbc172f7bfe34dabe933ccaad31d53af5da", - "x86_64-pc-windows-msvc-freethreaded": "318a9a1e43dd52054327de3bccc0c5b7afde7b7f2a398ccb4d38e03d28b05386", - "x86_64-unknown-linux-gnu-freethreaded": "dcc29b069d0588fbd4ea29c6df840c8d1207d2a3bce8cd5cd57d1b85373b6048", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.10": { - "url": "20251202/cpython-{python_version}+20251202-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "37afe4e77ab62ac50f197b1cb1f3bc02c82735c6be893da0996afcde5dc41048", - "aarch64-unknown-linux-gnu": "c68280591cda1c9515a04809fa6926020177e8e5892300206e0496ea1d10290e", - "ppc64le-unknown-linux-gnu": "1507e5528bd88131dc742a2941176aceea1838bc09860c21f179285b7865133b", - "riscv64-unknown-linux-gnu": "70169e916860b2e5b34c37c302d699eb2b8f24f28090968881942a37aeb7ed08", - "s390x-unknown-linux-gnu": "c5448863b64aacae62f3a213a6e6cf94ec63f96ee4d518491cd62fd3c81d952f", - "x86_64-apple-darwin": "a02761a4f189f71c0512e88df7ca2843696d61da659e47f8a5c8a9bd2c0d16f4", - "x86_64-pc-windows-msvc": "8b00014c7c35f9ad4cb1c565f067500bacc4125c8bc30e4389ee0be9fd6ffa3d", - "aarch64-pc-windows-msvc": "9060d644bd32ac0e0af970d0b21e207e6ff416b7c4dc26ffc4f9b043fb45b463", - "aarch64-pc-windows-msvc-freethreaded": "cdb7141327bdc244715b25752593e2c9eeb3cc2764f37dfe81cfbc92db9d6d57", - "x86_64-unknown-linux-gnu": "0cac1495fff920219904b1d573aaec0df54d549c226cb45f5c60cb6d2c72727a", - "x86_64-unknown-linux-musl": "04108190972ac98e13098abd972ec3f4f8b0880f83c0bb68249ce1a6164fa041", - "aarch64-apple-darwin-freethreaded": "3c9fdd76447c1549a0d3bc2a70c63f1daec997ab034206ac0260a03237166dbb", - "aarch64-unknown-linux-gnu-freethreaded": "6d277221fa4b172e00b29c7158ca9661917bc8db9a0084b1a0ff5c3a0ba8b648", - "ppc64le-unknown-linux-gnu-freethreaded": "d265d8d1c51e25ed70279540223589f79cf99ad00b50d28b6150c2658c973885", - "riscv64-unknown-linux-gnu-freethreaded": "ec411b4a2d167c3be0a9aeb3905e045d62c8e3c3db0caeade5d47d5f60b98dd0", - "s390x-unknown-linux-gnu-freethreaded": "4fc6443948bf5b729481ea02cc5c68e80cd0da42631f6936587a2b8fd45bc62c", - "x86_64-apple-darwin-freethreaded": "6ce608684df0f90350c7a1742e9685a7782d9b26ec99d1bd9d55c8cf9a405040", - "x86_64-pc-windows-msvc-freethreaded": "6a8b0372ded655e0d55318089fbce3122a446e69bcd120c79aaadfe9b017299c", - "x86_64-unknown-linux-gnu-freethreaded": "e39127fbe8d2ae7d86099f18b4da0918f9b60ce73ed491774d6dcfaa42b5c9ae", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.11": { - "url": "20251209/cpython-{python_version}+20251209-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "295a9f7bc899ea1cc08baf60bbf511bdd1e4a29b2dd7e5f59b48f18bfa6bf585", - "aarch64-unknown-linux-gnu": "ea1e678e6e82301bb32bf3917732125949b6e46d541504465972024a3f165343", - "ppc64le-unknown-linux-gnu": "7660e53aad9d35ee256913c6d98427f81f078699962035c5fa8b5c3138695109", - "riscv64-unknown-linux-gnu": "763fa1548e6a432e9402916e690c74ea30f26dcd2e131893dd506f72b87c27c9", - "s390x-unknown-linux-gnu": "ffb6af51fbfabfc6fbc4e7379bdec70c2f51e972b1d2f45c053493b9da3a1bbe", - "x86_64-apple-darwin": "dac4a0a0a9b71f6b02a8b0886547fa22814474239bffb948e3e77185406ea136", - "x86_64-pc-windows-msvc": "87822417007045a28a7eccc47fe67b8c61265b99b10dbbfa24d231a3622b1c27", - "aarch64-pc-windows-msvc": "ba646d0c3b7dd7bdfb770d9b2ebd6cd2df02a37fda90c9c79a7cf59c7df6f165", - "aarch64-pc-windows-msvc-freethreaded": "6daf6d092c7294cfe68c4c7bf2698ac134235489c874b3bf796c7972b9dbba30", - "x86_64-unknown-linux-gnu": "1ffa06d714a44aea14c0c54c30656413e5955a6c92074b4b3cb4351dcc28b63b", - "x86_64-unknown-linux-musl": "969fe24017380b987c4e3ce15e9edf82a4618c1e61672b2cc9b021a1c98eae78", - "aarch64-apple-darwin-freethreaded": "4213058b7fcd875596c12b58cd46a399358b0a87ecde4b349cbdd00cf87ed79a", - "aarch64-unknown-linux-gnu-freethreaded": "290ca3bd0007db9e551f90b08dfcb6c1b2d62c33b2fc3e9a43e77d385d94f569", - "ppc64le-unknown-linux-gnu-freethreaded": "09d4b50f8abb443f7e3af858c920aa61c2430b0954df465e861caa7078e55e69", - "riscv64-unknown-linux-gnu-freethreaded": "5406f2a7cacafbd2aac3ce2de066a0929aab55423824276c36e04cb83babc36c", - "s390x-unknown-linux-gnu-freethreaded": "3984b67c4292892eaccdd1c094c7ec788884c4c9b3534ab6995f6be96d5ed51d", - "x86_64-apple-darwin-freethreaded": "d6f489464045d6895ae68b0a04a9e16477e74fe3185a75f3a9a0af8ccd25eade", - "x86_64-pc-windows-msvc-freethreaded": "bb9a29a7ba8f179273b79971da6aaa7be592d78c606a63f99eff3e4c12fb0fae", - "x86_64-unknown-linux-gnu-freethreaded": "33f89c957d986d525529b8a980103735776f4d20cf52f55960a057c760188ac3", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.13.12": { - "url": "20260325/cpython-{python_version}+20260325-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "688da81bcaa6ed91792397c7d5433b13a4f02f021f940637c3972639bc516dca", - "aarch64-unknown-linux-gnu": "31c6e61eed48ca4e156d0e473025a792338641109e8277a63518ded438390c96", - "ppc64le-unknown-linux-gnu": "654939bc40d5f76f08eb17335bb19e9efa11eb48a0818eda2293a3f7c3570ae7", - "riscv64-unknown-linux-gnu": "fc7e1fb553c47b831ed7fa529575145207f000f967513f7b9ea809cce006ed79", - "s390x-unknown-linux-gnu": "7d7919358e88fcc672b061be8c2316c3a604c7074200515d7104166ed611f7f9", - "x86_64-apple-darwin": "7411e47939783708381017a90944a69641ac84d43f74fb6e2d52576c599a2717", - "x86_64-pc-windows-msvc": "5b4093f92d9bffcb0d92aea050f3d77d5a4fc8e918b31cea000ee4b3ca751f1d", - "aarch64-pc-windows-msvc": "d2c8b00044cd2e4c5fc7e697e63d5e481ed44b87c2def0beb42991d59f65d930", - "aarch64-pc-windows-msvc-freethreaded": "d2c8b00044cd2e4c5fc7e697e63d5e481ed44b87c2def0beb42991d59f65d930", - "x86_64-unknown-linux-gnu": "ebb1051ca2822b9803f46a5f10b6d51d153189ef1b1f1e142f733c0cbeaf86eb", - "x86_64-unknown-linux-musl": "b2e9400731c7f18069ec2804ba87a404385fe440f93b7dcb59004b9f56651202", - "aarch64-apple-darwin-freethreaded": "688da81bcaa6ed91792397c7d5433b13a4f02f021f940637c3972639bc516dca", - "aarch64-unknown-linux-gnu-freethreaded": "31c6e61eed48ca4e156d0e473025a792338641109e8277a63518ded438390c96", - "ppc64le-unknown-linux-gnu-freethreaded": "654939bc40d5f76f08eb17335bb19e9efa11eb48a0818eda2293a3f7c3570ae7", - "riscv64-unknown-linux-gnu-freethreaded": "fc7e1fb553c47b831ed7fa529575145207f000f967513f7b9ea809cce006ed79", - "s390x-unknown-linux-gnu-freethreaded": "7d7919358e88fcc672b061be8c2316c3a604c7074200515d7104166ed611f7f9", - "x86_64-apple-darwin-freethreaded": "7411e47939783708381017a90944a69641ac84d43f74fb6e2d52576c599a2717", - "x86_64-pc-windows-msvc-freethreaded": "5b4093f92d9bffcb0d92aea050f3d77d5a4fc8e918b31cea000ee4b3ca751f1d", - "x86_64-unknown-linux-gnu-freethreaded": "ebb1051ca2822b9803f46a5f10b6d51d153189ef1b1f1e142f733c0cbeaf86eb", - }, - "strip_prefix": "python", - }, - "3.13.13": { - "url": "20260414/cpython-{python_version}+20260414-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "c652dad552122cd2e76968ec41c803f8222038169b11310dba0c85928265f5c1", - "aarch64-unknown-linux-gnu": "6a65f68043d7fadcd580415493d2929d1fd686013f9ae44ddbd3a81307ab256d", - "ppc64le-unknown-linux-gnu": "aef73894107300264222b19e357baf5bad616b1c4bf5daa5c3b97cfee8f5ed7b", - "riscv64-unknown-linux-gnu": "f47c09f8e7f2fb0bc4afe52422705af4016c8d3ec1cf004b67bb56a86caa62cb", - "s390x-unknown-linux-gnu": "4d205af9654e1f33cefd23ff798af470e565f3ac0eba18d2f98f18a2abd07166", - "x86_64-apple-darwin": "540337412d2c4220e99280f741dbf45c1e3da3a39edaaab20c6ba1d53e1692ef", - "x86_64-pc-windows-msvc": "ee0cb26453d6e025d36502d765c1639c34830355e46ab3ad31c0360bc4cd9b79", - "aarch64-pc-windows-msvc": "586ba71c75f341e1d111399b7f719ae784dc11e8672e93e017388f28684226d0", - "aarch64-pc-windows-msvc-freethreaded": "586ba71c75f341e1d111399b7f719ae784dc11e8672e93e017388f28684226d0", - "x86_64-unknown-linux-gnu": "e5ec3b2c5693215d153c434ac018e75511b2c4f96d2bce30468a477cb3a89d5e", - "x86_64-unknown-linux-musl": "24ac6bf80dd2991c8be348f777c96c6eb69b71e78d8fa28c09beb3ddca015a47", - "aarch64-apple-darwin-freethreaded": "c652dad552122cd2e76968ec41c803f8222038169b11310dba0c85928265f5c1", - "aarch64-unknown-linux-gnu-freethreaded": "6a65f68043d7fadcd580415493d2929d1fd686013f9ae44ddbd3a81307ab256d", - "ppc64le-unknown-linux-gnu-freethreaded": "aef73894107300264222b19e357baf5bad616b1c4bf5daa5c3b97cfee8f5ed7b", - "riscv64-unknown-linux-gnu-freethreaded": "f47c09f8e7f2fb0bc4afe52422705af4016c8d3ec1cf004b67bb56a86caa62cb", - "s390x-unknown-linux-gnu-freethreaded": "4d205af9654e1f33cefd23ff798af470e565f3ac0eba18d2f98f18a2abd07166", - "x86_64-apple-darwin-freethreaded": "540337412d2c4220e99280f741dbf45c1e3da3a39edaaab20c6ba1d53e1692ef", - "x86_64-pc-windows-msvc-freethreaded": "ee0cb26453d6e025d36502d765c1639c34830355e46ab3ad31c0360bc4cd9b79", - "x86_64-unknown-linux-gnu-freethreaded": "e5ec3b2c5693215d153c434ac018e75511b2c4f96d2bce30468a477cb3a89d5e", - }, - "strip_prefix": "python", - }, - "3.14.0": { - "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "b4bcd3c6c24cab32ae99e1b05c89312b783b4d69431d702e5012fe1fdcad4087", - "aarch64-unknown-linux-gnu": "128a9cbfb9645d5237ec01704d9d1d2ac5f084464cc43c37a4cd96aa9c3b1ad5", - "ppc64le-unknown-linux-gnu": "e16ca51f018e99a609faf953bd3a3aea31f45ee84262d1a517fb3abd98f1f4af", - "riscv64-unknown-linux-gnu": "fca340d8fb7a05cd90e216ce601b25d492ed8c1a3b6a6d77703e0f15ab3711a7", - "s390x-unknown-linux-gnu": "c5803644970eee931bb0581b3b64511d1a8612f67bc98951a7f7ab5581a9ed04", - "x86_64-apple-darwin": "4e71a3ce973be377ef18637826648bb936e2f9490f64a9e4f33a49bcc431d344", - "x86_64-pc-windows-msvc": "39acfcb3857d83eab054a3de11756ffc16b3d49c31393b9800dd2704d1f07fdf", - "aarch64-pc-windows-msvc": "599a8b7e12439cd95a201dbdfe95cf363146b1ff91f379555dafd86b170caab9", - "x86_64-unknown-linux-gnu": "3dec1ab70758a3467ac3313bbcdabf7a9b3016db5c072c4537e3cf0a9e6290f6", - "x86_64-unknown-linux-musl": "d0a2a6d3b1bb00dce2105377fda8aa79675d187f8d6d7010a42f651af25018dc", - "aarch64-apple-darwin-freethreaded": "d9c7b430b25bd3837dbb03f945dbe6b7bc526c5940ca96f5db7cdc42f6b2b801", - "aarch64-unknown-linux-gnu-freethreaded": "f383ef50d1da6ca511212e5ae601923b56636b87351fd5fc847e0ea0a19fa9b3", - "ppc64le-unknown-linux-gnu-freethreaded": "cb0e4ff781b856a47f0f461ceb41c78c7eeff65effd0957857ec4702ef1e1bd3", - "riscv64-unknown-linux-gnu-freethreaded": "929223470d11a55cd75f880ac3bd4969e42407e2cdf08d4e7e38ba721cf4abec", - "s390x-unknown-linux-gnu-freethreaded": "613fb1f7b249f798b52af957d181305244e936c8e5c94c84688fcdf93fe14253", - "x86_64-apple-darwin-freethreaded": "b3196f6b57bbb3dc2ee07f348f1d51117ffa376979eceafbf50c15f0f7980bf8", - "x86_64-pc-windows-msvc-freethreaded": "b81de5fc9e783ea6dfcf1098c28a278c874999c71afbb0309f6a8b4276c769d0", - "aarch64-pc-windows-msvc-freethreaded": "40266e60f655e49cd1d5303295255909a4b593b08b88be6e6a55b2c9fe6ed13d", - "x86_64-unknown-linux-gnu-freethreaded": "f4acbef0fbfaf7ab31ac63986da1d93dfa1c5cb797de1dcdc1a988aa18670120", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.14.1": { - "url": "20251202/cpython-{python_version}+20251202-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "cdf1ba0789f529fa34bb5b5619c5da9757ac1067d6b8dd0ee8b78e50078fc561", - "aarch64-unknown-linux-gnu": "5dde7dba0b8ef34c0d5cb8a721254b1e11028bfc09ff06664879c245fe8df73f", - "ppc64le-unknown-linux-gnu": "d2774701d53e2ac06f8c8c8e52dfa4ff346890de9b417c9a7664195443a4c766", - "riscv64-unknown-linux-gnu": "af840506efbcd5026d9140c0a0230e45e46bb1f339a65c10a22875930b2c0159", - "s390x-unknown-linux-gnu": "43f8f79bf4c66689d2019f193671d1df3e5e5dbb293382036285e8ce55fc55bb", - "x86_64-apple-darwin": "f25ce050e1d370f9c05c9623b769ffa4b269a6ae17e611b435fd2b8b09972a88", - "x86_64-pc-windows-msvc": "cb478a5a37eb93ce4d3c27ae64d211d6a5a42475ae53f666a8d1570e71fcf409", - "aarch64-pc-windows-msvc": "19129cf8b4d68c4e64c25bae43bca139d871267b59cf7f02b9dcf25f0bf59497", - "x86_64-unknown-linux-gnu": "a72f313bad49846e5e9671af2be7476033a877c80831cf47f431400ccb520090", - "x86_64-unknown-linux-musl": "15d50b15713097c38c67b1a06a0498ad102377f9b3999e98e4eefd6bf91bd82d", - "aarch64-apple-darwin-freethreaded": "61f38e947449cf00f32f0838e813358f6bf61025d0797531e5b8b8b175c617f0", - "aarch64-unknown-linux-gnu-freethreaded": "1a88a1fe21eb443d280999464b1a397605a7ca950d8ab73813ca6868835439a2", - "ppc64le-unknown-linux-gnu-freethreaded": "7207b736ed2569f307649ffd4b615a5346631bc244730b8702babee377cef528", - "riscv64-unknown-linux-gnu-freethreaded": "d1356ccd279920edc31bf0350674d966beb9522f9503846ed7855dbb109ccc14", - "s390x-unknown-linux-gnu-freethreaded": "477758eabc06dbc7e5e5d16e97c4672478acd409f420dd2e1b84d3452c0668d1", - "x86_64-apple-darwin-freethreaded": "c2cb2a9b44285fbc13c3c9b7eea813db6ed8d94909406b059db7afd39b32e786", - "x86_64-pc-windows-msvc-freethreaded": "8ef7048315cac6d26bdbef18512a87b1a24fffa21cec86e32f9a9425f2af9bf6", - "aarch64-pc-windows-msvc-freethreaded": "ddb10b645de2b1f6f2832a80b115a9cd34a4a760249983027efe46618a8efc48", - "x86_64-unknown-linux-gnu-freethreaded": "c5d5b89aab7de683e465e36de2477a131435076badda775ef6e9ea21109c1c32", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.14.2": { - "url": "20251209/cpython-{python_version}+20251209-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "2f74bd26bd16487aca357c879d11f7b16c0521328e5148a1930ab6357bcb89fe", - "aarch64-unknown-linux-gnu": "869af31b2963194e8a2ecfadc36027c4c1c86a10f4960baec36dadb41b2acf02", - "ppc64le-unknown-linux-gnu": "86129976403fb5d64cf576329f94148f28cf6f82834e94df81ff31e9d5f404e0", - "riscv64-unknown-linux-gnu": "318dceecf119ea903aef1fb03a552cc592ecd61c08da891b68f5755e21e13511", - "s390x-unknown-linux-gnu": "53875c849a14194344ead1d9cd1e128cadd42a4b83c35eeb212417909ef05a6a", - "x86_64-apple-darwin": "58fa3e17d13ab956fd11055fb774c98ecfddcdf3b588e5f2369bdbc14ef9d76a", - "x86_64-pc-windows-msvc": "0d660bba9f58cb552e7e99e1f96a9c67b41618c9b8d29f9f3515fe2b5ad1966e", - "aarch64-pc-windows-msvc": "0be0d2557d73efa7f6f3f99679f05252d57fe2aad2d81cac3cad410a9b1eacbd", - "x86_64-unknown-linux-gnu": "121c3249bef497adf601df76a4d89aed6053fc5ec2f8c0ec656b86f0142e8ddd", - "x86_64-unknown-linux-musl": "71639cc5d1fb79840467531c5b53ca77170a58edd3f7e2d29330dd736e477469", - "aarch64-apple-darwin-freethreaded": "d6d17b8ef28326552cdeb2a7541c8a0cb711b378df9b93ebdb461dca065edfea", - "aarch64-unknown-linux-gnu-freethreaded": "adfcb90f3a7e1b3fbc6a99f9c8c8dce1f2e26ea72b724bbe4e9fa39e81e2b0db", - "ppc64le-unknown-linux-gnu-freethreaded": "2b1ce0c5a5f5e5add7e4f934f5bd35ac41660895a30b3098db7f7303d6952a4f", - "riscv64-unknown-linux-gnu-freethreaded": "4efb610fa07a6ee2639d14d78fc3b6ecb47431c14e1e4bda03c7f7dd60a5c1e5", - "s390x-unknown-linux-gnu-freethreaded": "e62f3bb3e66dac6c459690f9e9cd8cc2f6fe1dcf8bfed452af4c3df24cd7874f", - "x86_64-apple-darwin-freethreaded": "1fd76c79f7fc1753e8d2ed2f71406c0b65776c75f3e95ed99ffde8c95af2adc1", - "x86_64-pc-windows-msvc-freethreaded": "9927951e3997c186d2813ca1a0f4a8f5a2f771463f7f8ad0752fd3d2be2b74e4", - "aarch64-pc-windows-msvc-freethreaded": "43aac5bb4cdba71fc6775d26f47348d573a0b1210911438be71d7d96f4b18b51", - "x86_64-unknown-linux-gnu-freethreaded": "3728872ffd74989a7b4bbf3f0c629ae8fe821cda2bd6544012c1b92b9f5d5a5b", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.14.3": { - "url": "20260325/cpython-{python_version}+20260325-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "80c996c23aab828134821f078a8a77a6f33f3f2c14000f071718c540e20c64d4", - "aarch64-unknown-linux-gnu": "6faf5478f910741c477830f5fd842011208af0f9678faf77106c9421b325bfc1", - "ppc64le-unknown-linux-gnu": "5eafe32e12f33f98c40de920482b013170dcf97d8c7f5dc780271ccf4cded76a", - "riscv64-unknown-linux-gnu": "481d3faef258964e57b7102c63de12b2bb388c7ed07cfe456f33e63b4e061202", - "s390x-unknown-linux-gnu": "d706eae2f4d963187b7c866603aed75d7eb3ea59590b06fb34f5fd7d0fe8e432", - "x86_64-apple-darwin": "847a49fea36c066f8df7a57cd8c4c02d17667e25d30b7930e8f8ba15e72d7efc", - "x86_64-pc-windows-msvc": "8b4e1329c4901ce2c0f1c20ac5d2ffa62fc13f12e26b5d1e5a1000f910f980d4", - "aarch64-pc-windows-msvc": "b35fe7c2fe169574f382cef125e95cbd904ddcb98fc337167356371b6d2e8c60", - "x86_64-unknown-linux-gnu": "18270c5a7b1a572599df5e68b497ba5254811dac43ba6f542245807d821fcb44", - "x86_64-unknown-linux-musl": "726a28734d2878a637b0d16ce07ce24c7d6ca1043d8e6f4a23b1b0a3478eedb9", - "aarch64-apple-darwin-freethreaded": "80c996c23aab828134821f078a8a77a6f33f3f2c14000f071718c540e20c64d4", - "aarch64-unknown-linux-gnu-freethreaded": "6faf5478f910741c477830f5fd842011208af0f9678faf77106c9421b325bfc1", - "ppc64le-unknown-linux-gnu-freethreaded": "5eafe32e12f33f98c40de920482b013170dcf97d8c7f5dc780271ccf4cded76a", - "riscv64-unknown-linux-gnu-freethreaded": "481d3faef258964e57b7102c63de12b2bb388c7ed07cfe456f33e63b4e061202", - "s390x-unknown-linux-gnu-freethreaded": "d706eae2f4d963187b7c866603aed75d7eb3ea59590b06fb34f5fd7d0fe8e432", - "x86_64-apple-darwin-freethreaded": "847a49fea36c066f8df7a57cd8c4c02d17667e25d30b7930e8f8ba15e72d7efc", - "x86_64-pc-windows-msvc-freethreaded": "8b4e1329c4901ce2c0f1c20ac5d2ffa62fc13f12e26b5d1e5a1000f910f980d4", - "aarch64-pc-windows-msvc-freethreaded": "b35fe7c2fe169574f382cef125e95cbd904ddcb98fc337167356371b6d2e8c60", - "x86_64-unknown-linux-gnu-freethreaded": "18270c5a7b1a572599df5e68b497ba5254811dac43ba6f542245807d821fcb44", - }, - "strip_prefix": "python", - }, - "3.14.4": { - "url": "20260414/cpython-{python_version}+20260414-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "8b7865e511b17093e090449bf71eb52933c17d45ad5257ddeacaffbb2c7239df", - "aarch64-unknown-linux-gnu": "5c8db1c21023316adad827a46d917bbbd6a85ae4e39bc3a58febda712c2f963d", - "ppc64le-unknown-linux-gnu": "055977a09de092744bbb22db64144e6afef8592eaac5e2bce4cca33f2592281a", - "riscv64-unknown-linux-gnu": "e959df167c502fb0bbcacc31a997e25c6b0ff6b5e496321b691955aa702d0c09", - "s390x-unknown-linux-gnu": "35f70ad05b2c4045889ee0c3d93f61b012654c1d91e10e671f0e5b4d4a6c6637", - "x86_64-apple-darwin": "9ecb2b942e6698c04af10a63a3d73c0b2e8d8e11ce44933fbffe8651bef4577d", - "x86_64-pc-windows-msvc": "9647bb46d3c236e34c1c11bbb7113444d9711811f0d11c39956168807a955b1a", - "aarch64-pc-windows-msvc": "82613380d582d806e562d7701496c34c87753ab13c37aa0afe2039003651f389", - "x86_64-unknown-linux-gnu": "e17275eaf95ceb5877aa6816e209b7733f41fee401d39c3921b88fb73fc4a4ba", - "x86_64-unknown-linux-musl": "12687a989a2384665577e1ef9864f33d4c074a1e69b38a8bac8d656531aefa3e", - "aarch64-apple-darwin-freethreaded": "8b7865e511b17093e090449bf71eb52933c17d45ad5257ddeacaffbb2c7239df", - "aarch64-unknown-linux-gnu-freethreaded": "5c8db1c21023316adad827a46d917bbbd6a85ae4e39bc3a58febda712c2f963d", - "ppc64le-unknown-linux-gnu-freethreaded": "055977a09de092744bbb22db64144e6afef8592eaac5e2bce4cca33f2592281a", - "riscv64-unknown-linux-gnu-freethreaded": "e959df167c502fb0bbcacc31a997e25c6b0ff6b5e496321b691955aa702d0c09", - "s390x-unknown-linux-gnu-freethreaded": "35f70ad05b2c4045889ee0c3d93f61b012654c1d91e10e671f0e5b4d4a6c6637", - "x86_64-apple-darwin-freethreaded": "9ecb2b942e6698c04af10a63a3d73c0b2e8d8e11ce44933fbffe8651bef4577d", - "x86_64-pc-windows-msvc-freethreaded": "9647bb46d3c236e34c1c11bbb7113444d9711811f0d11c39956168807a955b1a", - "aarch64-pc-windows-msvc-freethreaded": "82613380d582d806e562d7701496c34c87753ab13c37aa0afe2039003651f389", - "x86_64-unknown-linux-gnu-freethreaded": "e17275eaf95ceb5877aa6816e209b7733f41fee401d39c3921b88fb73fc4a4ba", - }, - "strip_prefix": "python", - }, - "3.15.0a1": { - "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "3acf7aa3559b746498b18929456c5cacb84bae4e09249834cbc818970d71de87", - "aarch64-unknown-linux-gnu": "d55c2aeece827e6bec83fd18515ee281d9ea0efaa3e2d20130db8f1c7cbb71c6", - "ppc64le-unknown-linux-gnu": "c28beda791c499b16f06256339522f0002a3e9acba003e6b8374755d7be1def2", - "riscv64-unknown-linux-gnu": "36619f576b8154e4b56643c5c4a85c352f152df2989c4e602cbbe9c2b7ded870", - "s390x-unknown-linux-gnu": "5ea47be2a3a563ddd87ff510dae26b7aa7f3855ca00c5f1056ff8114c067c4e4", - "x86_64-apple-darwin": "0ab19d3ac25f99da438b088751e5ec2421f9f6aa4292fd2dc0f8e49eb3e16bdf", - "x86_64-pc-windows-msvc": "5f5d6bec2b381cfc771c49972d2a6f7b7e7ab6a1651d8fb6ef3983f3571722b3", - "aarch64-pc-windows-msvc": "1508bcd7195008479ed156aad3afbb3a3793097ed530690f0304a8107f0e53e8", - "x86_64-unknown-linux-gnu": "1f356288c2b2713619cb7a4e453d33bf8882f812af2987e21e01e7ae382fefba", - "x86_64-unknown-linux-musl": "caf5311f333eef082dd69a669ca65aceba09a08fc1e78aad602ad649106f294c", - "aarch64-apple-darwin-freethreaded": "12f1b16be4017181ad67904caf9e59e525b9b5d62f49105017d837e27b832959", - "aarch64-unknown-linux-gnu-freethreaded": "981fe8dfc6e7e1d0ffefa945a18d5c4c759bbe21722acf3a5cc7e62f16aa5f3c", - "ppc64le-unknown-linux-gnu-freethreaded": "088400dec25139f38eeecb48f090ff2ce06a96a1dd79fa8f1dfec1cd1786f5ef", - "riscv64-unknown-linux-gnu-freethreaded": "938061a0a31a06672526885de36037ddefd8c4acdb09424691b7000a8c8f8d01", - "s390x-unknown-linux-gnu-freethreaded": "2003e7e40bb44b3db7bca81087bfb738fe6af40e5db61cda8e23b59bf55d409e", - "x86_64-apple-darwin-freethreaded": "64fc29e6c7a2f02a18645d968f1b3fc1d00d12a5ef3fcbb0d077fa8c62c08904", - "x86_64-pc-windows-msvc-freethreaded": "34abc5603e1b4131f753d29b7deac865b9277912b851cbed5a149cf3e6745d3d", - "aarch64-pc-windows-msvc-freethreaded": "54ca78dae455ece6fefbd7f5f287cc55d5ce197caf51921f6d871d15069d9489", - "x86_64-unknown-linux-gnu-freethreaded": "0e0272186d9f5169394dbc4d4d72a3f4a5762a04c2e5ac2ab1e23aa41fc8538a", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.15.0a2": { - "url": "20251209/cpython-{python_version}+20251209-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "5851f3744fbd39e3e323844cf4f68d7763fb25546aa5ffbb71b1b5ab69c56616", - "aarch64-unknown-linux-gnu": "17ba65d669be3052524e03b4d1426c072ef38df2a9065ff4525d1f4d1bc9f82c", - "ppc64le-unknown-linux-gnu": "5585bd7c5eefe28b9bf544d902cad9a2f81f33c618f2a1d3c006cbfcdec77abc", - "riscv64-unknown-linux-gnu": "bb7252edaffd422bd1c044a4764dfcf83a5d7159942f445abbef524e54ea79a0", - "s390x-unknown-linux-gnu": "03a90ffa9f92d4cf4caeefb9d15f0b39c05c1e60ade6688f32165f957db4f8f3", - "x86_64-apple-darwin": "cee576de4919cd422dbc31eb85d3c145ee82acec84f651daaf32dc669b5149c9", - "x86_64-pc-windows-msvc": "e538475ee249eacf63bfdae0e70af73e9c47360e6dd3d6825e7a35107e177de5", - "aarch64-pc-windows-msvc": "39bc2fcac13aeba7d650f76badf63350a81c86167a62174cb092eab7a749f4a5", - "x86_64-unknown-linux-gnu": "58addaabfab2de422180d32543fb3878ffc984c8a2e4005ff658a5cd83b31fc7", - "x86_64-unknown-linux-musl": "dcf844400dc2e7f5f3604e994532e4d49db45f4deefe9afdf6809ca1bc6532ee", - "aarch64-apple-darwin-freethreaded": "5b34488580df13df051a2e84e43cfca2ab28fdd7a61052f35988eb8b481b894a", - "aarch64-unknown-linux-gnu-freethreaded": "0c2c83236f6e28c103e2660a82be94b2459ee8cfdd90f5dd82f0d503ca2aec09", - "ppc64le-unknown-linux-gnu-freethreaded": "216842df2377fd032f279ded7fd23d7bdbd92d4c1fa7619523bc0dbdef5bd212", - "riscv64-unknown-linux-gnu-freethreaded": "2a8b56f318d2e21b01b54909554c53d81871b9bb05d23ea7808dde9acec4dc7e", - "s390x-unknown-linux-gnu-freethreaded": "06c4ca3983aad20723f68786e3663ab49fee1bf09326f341649205ed79d34fc6", - "x86_64-apple-darwin-freethreaded": "4d8102b70ea9fe726ee3ae9ad9e9bc4cbe0b6ed18f7989c81aef81de578f0163", - "x86_64-pc-windows-msvc-freethreaded": "6ff71bac78d650ce621fe6db49f06290e48bcceb61f69cccc7728584f70b6346", - "aarch64-pc-windows-msvc-freethreaded": "3d99152b4e29b947fb1cfc8d035d1d511e50aeed72886ff4a5fd0a3694bd0b51", - "x86_64-unknown-linux-gnu-freethreaded": "70f552e213734c0e260a57603bee504dd7ed0e78a10558b591e724ea8730fef5", - }, - "strip_prefix": { - "aarch64-apple-darwin": "python", - "aarch64-unknown-linux-gnu": "python", - "ppc64le-unknown-linux-gnu": "python", - "s390x-unknown-linux-gnu": "python", - "riscv64-unknown-linux-gnu": "python", - "x86_64-apple-darwin": "python", - "x86_64-pc-windows-msvc": "python", - "aarch64-pc-windows-msvc": "python", - "x86_64-unknown-linux-gnu": "python", - "x86_64-unknown-linux-musl": "python", - "aarch64-apple-darwin-freethreaded": "python/install", - "aarch64-unknown-linux-gnu-freethreaded": "python/install", - "ppc64le-unknown-linux-gnu-freethreaded": "python/install", - "riscv64-unknown-linux-gnu-freethreaded": "python/install", - "s390x-unknown-linux-gnu-freethreaded": "python/install", - "x86_64-apple-darwin-freethreaded": "python/install", - "x86_64-pc-windows-msvc-freethreaded": "python/install", - "aarch64-pc-windows-msvc-freethreaded": "python/install", - "x86_64-unknown-linux-gnu-freethreaded": "python/install", - }, - }, - "3.15.0a8": { - "url": "20260414/cpython-{python_version}+20260414-{platform}-{build}.{ext}", - "sha256": { - "aarch64-apple-darwin": "780d46b3da0e58e15c620d9e7dfd29b54c8359c195f625858f85df9c2c7ecc32", - "aarch64-unknown-linux-gnu": "8f6dda4d8ff44976f1aa6a94674a09a503dc50b015297e1b62c8cdc591c90f4f", - "ppc64le-unknown-linux-gnu": "09f076c63fadbf675143674aa3b23229482b9a44840b8b1808a216def2a9af15", - "riscv64-unknown-linux-gnu": "9d41ce752e8b731872f0f5c9c48199e63c789d24ce3ae9e91d6c8008f36e7c51", - "s390x-unknown-linux-gnu": "1de2593c40cce2d8ea883f8c8580223bfa1478cbd9d0191ba3640aed083c2202", - "x86_64-apple-darwin": "a7744d34148969a2ec010da6f0a46ddeceda7c02e5cdfa2b4e1811487381491a", - "x86_64-pc-windows-msvc": "3ded476f676fdf260d56a5e49aa083d5ffd218fc3390e4480ed42bee1acfb3fb", - "aarch64-pc-windows-msvc": "10fb470e900e65df4e37f8deaf1726397c914861ffc37b43ae3743a7eee88377", - "x86_64-unknown-linux-gnu": "c93f4b15287ac48d7e3a475b245cb59cc51079382747e3e6213d6406c158969d", - "x86_64-unknown-linux-musl": "9fbd6f243a424d4ae973e72aa0075122a7cfe05ac8f6cfde986e7b00d0dbc0bf", - "aarch64-apple-darwin-freethreaded": "780d46b3da0e58e15c620d9e7dfd29b54c8359c195f625858f85df9c2c7ecc32", - "aarch64-unknown-linux-gnu-freethreaded": "8f6dda4d8ff44976f1aa6a94674a09a503dc50b015297e1b62c8cdc591c90f4f", - "ppc64le-unknown-linux-gnu-freethreaded": "09f076c63fadbf675143674aa3b23229482b9a44840b8b1808a216def2a9af15", - "riscv64-unknown-linux-gnu-freethreaded": "9d41ce752e8b731872f0f5c9c48199e63c789d24ce3ae9e91d6c8008f36e7c51", - "s390x-unknown-linux-gnu-freethreaded": "1de2593c40cce2d8ea883f8c8580223bfa1478cbd9d0191ba3640aed083c2202", - "x86_64-apple-darwin-freethreaded": "a7744d34148969a2ec010da6f0a46ddeceda7c02e5cdfa2b4e1811487381491a", - "x86_64-pc-windows-msvc-freethreaded": "3ded476f676fdf260d56a5e49aa083d5ffd218fc3390e4480ed42bee1acfb3fb", - "aarch64-pc-windows-msvc-freethreaded": "10fb470e900e65df4e37f8deaf1726397c914861ffc37b43ae3743a7eee88377", - "x86_64-unknown-linux-gnu-freethreaded": "c93f4b15287ac48d7e3a475b245cb59cc51079382747e3e6213d6406c158969d", - }, - "strip_prefix": "python", - }, -} +DEFAULT_RELEASE_BASE_URL = _GITHUB_PREFIX +DEFAULT_RELEASE_BASE_URLS = [ + _GITHUB_PREFIX, + _ASTRAL_PREFIX, + _LEGACY_GITHUB_PREFIX, +] # buildifier: disable=unsorted-dict-items MINOR_MAPPING = { @@ -1426,26 +207,41 @@ def _generate_platforms(): PLATFORMS = _generate_platforms() -def get_release_info(platform, python_version, base_url = DEFAULT_RELEASE_BASE_URL, tool_versions = TOOL_VERSIONS): +def get_release_info(platform, python_version, base_urls = DEFAULT_RELEASE_BASE_URLS, tool_versions = None): """Resolve the release URL for the requested interpreter version Args: platform: The platform string for the interpreter python_version: The version of the interpreter to get - base_url: The URL to prepend to the 'url' attr in the tool_versions dict + base_urls: The list of URLs to prepend to the 'url' attr in the tool_versions dict tool_versions: A dict listing the interpreter versions, their SHAs and URL Returns: A tuple of (filename, url, archive strip prefix, patches, patch_strip) """ + if tool_versions == None: + tool_versions = TOOL_VERSIONS + + if type(base_urls) == type(""): + base_urls = [base_urls] + elif base_urls == None: + base_urls = DEFAULT_RELEASE_BASE_URLS - base_urls = [base_url] - if base_url == DEFAULT_RELEASE_BASE_URL or base_url.startswith(_GITHUB_PREFIX): - suffix = base_url[len(_GITHUB_PREFIX):] - base_urls.append(_ASTRAL_PREFIX + suffix) - elif base_url.startswith(_LEGACY_GITHUB_PREFIX): - suffix = base_url[len(_LEGACY_GITHUB_PREFIX):] - base_urls.append(_ASTRAL_PREFIX + suffix) + expanded_base_urls = [] + for b_url in base_urls: + if b_url not in expanded_base_urls: + expanded_base_urls.append(b_url) + if b_url.startswith(_GITHUB_PREFIX): + suffix = b_url[len(_GITHUB_PREFIX):] + a_url = _ASTRAL_PREFIX + suffix + if a_url not in expanded_base_urls: + expanded_base_urls.append(a_url) + elif b_url.startswith(_LEGACY_GITHUB_PREFIX): + suffix = b_url[len(_LEGACY_GITHUB_PREFIX):] + a_url = _ASTRAL_PREFIX + suffix + if a_url not in expanded_base_urls: + expanded_base_urls.append(a_url) + base_urls = expanded_base_urls url = tool_versions[python_version]["url"] @@ -1530,3 +326,67 @@ def gen_python_config_settings(name = ""): flag_values = PLATFORMS[platform].flag_values, constraint_values = PLATFORMS[platform].compatible_with, ) + +def _manifest_entry_sort_key(entry): + flavor_rank = {"full": 3, "install_only": 1, "install_only_stripped": 2}.get(entry.archive_flavor, 4) + microarch = entry.microarch + if not microarch: + microarch_rank = 0 + elif microarch.startswith("v") and microarch[1:].isdigit(): + microarch_rank = int(microarch[1:]) + else: + microarch_rank = 999 + return (flavor_rank, microarch_rank) + +def _tool_versions_from_manifest_entries(entries, base_url = DEFAULT_RELEASE_BASE_URL): + """Converts parsed manifest entries into the TOOL_VERSIONS dictionary format. + + Args: + entries: {type}`list[struct]` the parsed manifest entries. + base_url: {type}`str` fallback base URL for standalone distributions. + + Returns: + {type}`dict[str, dict]` the tool versions map. + """ + available_versions = {} + entries = sorted( + entries, + key = _manifest_entry_sort_key, + ) + + for entry in entries: + location = entry.location + sha256 = entry.sha256 + py_version = entry.python_version + + matched_platform = "{}-{}-{}".format(entry.arch, entry.vendor, entry.os) + if entry.libc: + matched_platform += "-" + entry.libc + if entry.freethreaded: + matched_platform += "-freethreaded" + + if matched_platform not in PLATFORMS: + continue + + archive_flavor = entry.archive_flavor + if archive_flavor not in ["install_only", "install_only_stripped", "full"]: + continue + + v_dict = available_versions.setdefault(py_version, {}) + if matched_platform in v_dict.get("sha256", {}): + continue + + if "://" in location: + urls = [location] + else: + urls = ["{}/{}".format(base_url, location)] + + strip_prefix = "python/install" if archive_flavor == "full" else "python" + + v_dict.setdefault("sha256", {})[matched_platform] = sha256 + v_dict.setdefault("url", {})[matched_platform] = urls + v_dict.setdefault("strip_prefix", {})[matched_platform] = strip_prefix + + return available_versions + +TOOL_VERSIONS = _tool_versions_from_manifest_entries(parse_runtime_manifest(MANIFEST_TEXT)) diff --git a/replicate_ci b/replicate_ci new file mode 100755 index 0000000000..dd9b67e93e --- /dev/null +++ b/replicate_ci @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 + +import argparse +import os +import shlex +import subprocess +import sys + +import yaml + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Replicate and emulate BazelCI job configurations from presubmit.yml." + ) + parser.add_argument( + "job", + help="The key or name of the CI job to emulate (e.g., ubuntu_workspace).", + ) + return parser.parse_args() + + +def run_cmd(cmd, cwd=None, env=None, shell=False): + if shell: + cmd_str = cmd if isinstance(cmd, str) else " ".join(cmd) + else: + cmd_str = shlex.join(cmd) if isinstance(cmd, list) else str(cmd) + + print(f"\n🚀 Executing: {cmd_str}") + if cwd and cwd != os.getcwd(): + print(f"📁 Directory: {cwd}") + if env and "USE_BAZEL_VERSION" in env: + print(f"🔧 Bazel Version: {env['USE_BAZEL_VERSION']}") + + res = subprocess.run(cmd, cwd=cwd, env=env, shell=shell) + if res.returncode != 0: + print( + f"\n❌ Command failed with return code {res.returncode}: {cmd_str}", + file=sys.stderr, + ) + return False + return True + + +def resolve_bazel_version(task_bazel): + if not task_bazel or task_bazel.startswith("${{"): + return None + return task_bazel + + +def execute_ci_job(job_key, task, repo_root): + job_name = task.get("name", job_key) + print(f"\n{'=' * 80}\n🎯 Replicating CI Job: {job_key} ('{job_name}')\n{'=' * 80}") + + # Setup working directory + cwd = repo_root + if "working_directory" in task: + cwd = os.path.join(repo_root, task["working_directory"]) + if not os.path.exists(cwd): + print( + f"❌ Error: working_directory '{task['working_directory']}' does not exist at '{cwd}'", + file=sys.stderr, + ) + return False + + # Setup environment + env = os.environ.copy() + bzl_version = resolve_bazel_version(task.get("bazel")) + if bzl_version: + env["USE_BAZEL_VERSION"] = bzl_version + + # Execute pre-commands + is_windows = sys.platform.startswith("win") + pre_cmds = task.get("batch_commands" if is_windows else "shell_commands", []) + for pre_cmd in pre_cmds: + if not run_cmd(pre_cmd, cwd=cwd, env=env, shell=True): + return False + + # Execute Build Targets + build_targets = [t for t in task.get("build_targets", []) if t != "--"] + if build_targets: + build_flags = task.get("build_flags", []) + cmd = ["bazel", "build"] + build_flags + ["--"] + build_targets + if not run_cmd(cmd, cwd=cwd, env=env): + return False + + # Execute Test Targets + test_targets = [t for t in task.get("test_targets", []) if t != "--"] + if test_targets: + test_flags = task.get("test_flags", []) + if "--build_tests_only" not in test_flags: + test_flags = ["--build_tests_only"] + test_flags + cmd = ["bazel", "test"] + test_flags + ["--"] + test_targets + if not run_cmd(cmd, cwd=cwd, env=env): + return False + + # Execute Coverage Targets + coverage_targets = [t for t in task.get("coverage_targets", []) if t != "--"] + if coverage_targets: + coverage_flags = task.get("test_flags", []) + cmd = ["bazel", "coverage"] + coverage_flags + ["--"] + coverage_targets + if not run_cmd(cmd, cwd=cwd, env=env): + return False + + print(f"\n🎉 Successfully replicated CI Job: {job_key}") + return True + + +def main(): + args = parse_args() + + repo_root = os.path.abspath(os.path.dirname(__file__)) + presubmit_path = os.path.join(repo_root, ".bazelci/presubmit.yml") + if not os.path.exists(presubmit_path): + print( + f"❌ Error: Presubmit file not found at '{presubmit_path}'", + file=sys.stderr, + ) + sys.exit(1) + + with open(presubmit_path) as f: + presubmit = yaml.safe_load(f) + + tasks = presubmit.get("tasks", {}) + if not tasks: + print( + f"❌ Error: No tasks found in '{presubmit_path}'", + file=sys.stderr, + ) + sys.exit(1) + + # If no job specified, print available jobs and exit + if not args.job: + print("❌ Error: No CI job specified. Provide a job key.\n", file=sys.stderr) + print("📋 Available CI Job Keys:", file=sys.stderr) + for key in sorted(tasks.keys()): + name = tasks[key].get("name", key) + print(f" • {key} ({name})", file=sys.stderr) + sys.exit(1) + + # Match by key or by name + job_key = None + if args.job in tasks: + job_key = args.job + else: + for key, config in tasks.items(): + if config.get("name") == args.job: + job_key = key + break + + if not job_key: + print( + f"❌ Error: CI job '{args.job}' not found in '{presubmit_path}'\n", + file=sys.stderr, + ) + print("📋 Available CI Job Keys:", file=sys.stderr) + for key in sorted(tasks.keys()): + name = tasks[key].get("name", key) + print(f" • {key} ({name})", file=sys.stderr) + sys.exit(1) + + success = execute_ci_job(job_key, tasks[job_key], repo_root) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/sphinxdocs/.bazelrc b/sphinxdocs/.bazelrc index acff835394..65c996c678 100644 --- a/sphinxdocs/.bazelrc +++ b/sphinxdocs/.bazelrc @@ -16,6 +16,8 @@ build --enable_runfiles # Local disk cache greatly speeds up builds if the regular cache is lost common --disk_cache=~/.cache/bazel/bazel-disk-cache common --experimental_downloader_config=downloader_config.cfg +common --http_timeout_scaling=10.0 +common --experimental_repository_downloader_retries=10 common --incompatible_python_disallow_native_rules common --incompatible_no_implicit_file_export diff --git a/sphinxdocs/downloader_config.cfg b/sphinxdocs/downloader_config.cfg index a978fb89b9..3fa6264eda 100644 --- a/sphinxdocs/downloader_config.cfg +++ b/sphinxdocs/downloader_config.cfg @@ -5,6 +5,9 @@ rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-sk rewrite ^github\.com/bazelbuild/platforms/(.*) github.com/bazelbuild/platforms/$1 rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) github.com/bazelbuild/rules_kotlin/$1 rewrite ^github\.com/bazelbuild/rules_shell/(.*) github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) github.com/bazelbuild/stardoc/$1 + # Fall back to mirror (secondary) # Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 @@ -14,3 +17,5 @@ rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/ rewrite ^github\.com/bazelbuild/platforms/(.*) mirror.bazel.build/github.com/bazelbuild/platforms/$1 rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) mirror.bazel.build/github.com/bazelbuild/rules_kotlin/$1 rewrite ^github\.com/bazelbuild/rules_shell/(.*) mirror.bazel.build/github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) mirror.bazel.build/github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) mirror.bazel.build/github.com/bazelbuild/stardoc/$1 diff --git a/tests/docs/BUILD.bazel b/tests/docs/BUILD.bazel new file mode 100644 index 0000000000..bdc99a290c --- /dev/null +++ b/tests/docs/BUILD.bazel @@ -0,0 +1,10 @@ +load("@bazel_skylib//rules:build_test.bzl", "build_test") + +licenses(["notice"]) + +build_test( + name = "docs_build_test", + targets = [ + "//docs:docs", + ], +) diff --git a/tests/get_release_info/get_release_info_tests.bzl b/tests/get_release_info/get_release_info_tests.bzl index c810489c92..0b1b60adc3 100644 --- a/tests/get_release_info/get_release_info_tests.bzl +++ b/tests/get_release_info/get_release_info_tests.bzl @@ -63,6 +63,7 @@ def _test_astral_mirror(env): expected_urls = [ "https://github.com/astral-sh/python-build-standalone/releases/download/20230826/cpython-3.11.5+20230826-x86_64-unknown-linux-gnu-install_only.tar.gz", "https://releases.astral.sh/github/python-build-standalone/releases/download/20230826/cpython-3.11.5+20230826-x86_64-unknown-linux-gnu-install_only.tar.gz", + "https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5+20230826-x86_64-unknown-linux-gnu-install_only.tar.gz", ] _, urls, _, _, _ = get_release_info( @@ -95,7 +96,7 @@ def _test_astral_mirror_legacy(env): _, urls, _, _, _ = get_release_info( platform = "x86_64-unknown-linux-gnu", python_version = "3.11.5", - base_url = "https://github.com/indygreg/python-build-standalone/releases/download", + base_urls = ["https://github.com/indygreg/python-build-standalone/releases/download"], tool_versions = tool_versions, ) diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index d21fec2d7d..2a0bc7d76b 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -250,7 +250,7 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "Z5ZPR9z4PkJRXSyJ4KQEqM4kwiqWBCn8Ajzxy9YlS/g=", + "bzlTransitiveDigest": "46RcxJnhOapMeaxdcMm3RmVdNp1nPCewOOXoZyIbQ20=", "usagesDigest": "6yXGw7XDyXjOfqBL0SBu1YBEMMYPQzCE3jTzUCkxPgg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", diff --git a/tests/python/python_tests.bzl b/tests/python/python_tests.bzl index cd7383942c..cbef5637fd 100644 --- a/tests/python/python_tests.bzl +++ b/tests/python/python_tests.bzl @@ -19,7 +19,6 @@ load("@rules_testing//lib:test_suite.bzl", "test_suite") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//python/private:python.bzl", "parse_modules") # buildifier: disable=bzl-visibility load("//python/private:repo_utils.bzl", "repo_utils") # buildifier: disable=bzl-visibility -load("//tests/support/mocks:mocks.bzl", "mocks") load("//tests/support/mocks:python_ext.bzl", "python_ext") _tests = [] @@ -36,7 +35,7 @@ def _rules_python_module(is_root = False): def _test_default_from_rules_python_when_rules_python_is_root(env): """Verify that rules_python (as root module) default is applied.""" py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( _rules_python_module(is_root = True), ), logger = repo_utils.logger(verbosity_level = 0, name = "python"), @@ -48,7 +47,7 @@ def _test_default_from_rules_python_when_rules_python_is_root(env): env.expect.that_dict(py.config.minor_mapping).contains_exactly(MINOR_MAPPING) env.expect.that_collection(py.config.kwargs).has_size(0) env.expect.that_collection(py.config.default.keys()).contains_exactly([ - "base_url", + "base_urls", "tool_versions", "platforms", ]) @@ -66,7 +65,7 @@ _tests.append(_test_default_from_rules_python_when_rules_python_is_root) def _test_default_from_rules_python_when_rules_python_is_not_root(env): """Verify that rules_python default applies when rules_python is not the root module.""" py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( _rules_python_module(), ), logger = repo_utils.logger(verbosity_level = 0, name = "python"), @@ -85,7 +84,7 @@ _tests.append(_test_default_from_rules_python_when_rules_python_is_not_root) def _test_default_with_patch_version(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( modules = [ python_ext.module( name = "alpha", @@ -111,7 +110,7 @@ _tests.append(_test_default_with_patch_version) def _test_toolchain_ordering(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, @@ -161,7 +160,7 @@ _tests.append(_test_toolchain_ordering) def _test_default_from_defaults(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_root_module", defaults = [python_ext.defaults(python_version = "3.11")], @@ -192,7 +191,7 @@ _tests.append(_test_default_from_defaults) def _test_default_from_defaults_env(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_root_module", defaults = [ @@ -229,7 +228,7 @@ _tests.append(_test_default_from_defaults_env) def _test_default_from_defaults_file(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_root_module", defaults = [ @@ -265,7 +264,7 @@ _tests.append(_test_default_from_defaults_file) def _test_default_from_single_toolchain(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_root_module", is_root = True, @@ -281,7 +280,7 @@ _tests.append(_test_default_from_single_toolchain) def _test_defaults_overrides_single_toolchain(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_root_module", defaults = [ @@ -301,7 +300,7 @@ _tests.append(_test_defaults_overrides_single_toolchain) def _test_defaults_overrides_toolchains_setting_is_default(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_root_module", defaults = [python_ext.defaults(python_version = "3.13")], @@ -324,7 +323,7 @@ _tests.append(_test_defaults_overrides_toolchains_setting_is_default) def _test_first_occurance_of_the_toolchain_wins(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( modules = [ python_ext.module( name = "my_module", @@ -380,7 +379,7 @@ _tests.append(_test_first_occurance_of_the_toolchain_wins) def _test_auth_overrides(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, @@ -422,7 +421,7 @@ _tests.append(_test_auth_overrides) def _test_add_target_settings(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, @@ -448,7 +447,7 @@ _tests.append(_test_add_target_settings) def _test_add_new_version(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, @@ -460,7 +459,7 @@ def _test_add_new_version(env): "3.13.1", "3.13.99", ], - base_url = "", + base_urls = [], minor_mapping = { "3.13": "3.13.99", }, @@ -534,7 +533,7 @@ _tests.append(_test_add_new_version) def _test_register_all_versions(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, @@ -546,7 +545,7 @@ def _test_register_all_versions(env): "3.13.1", "3.13.99", ], - base_url = "", + base_urls = [], register_all_versions = True, ), ], @@ -605,7 +604,7 @@ _tests.append(_test_register_all_versions) def _test_ignore_unsupported_versions(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, @@ -616,7 +615,7 @@ def _test_ignore_unsupported_versions(env): "3.13.0", "3.13.1", ], - base_url = "", + base_urls = [], minor_mapping = { "3.12": "3.12.4", "3.13": "3.13.1", @@ -683,14 +682,14 @@ _tests.append(_test_ignore_unsupported_versions) def _test_add_patches(env): py = parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, override = [ python_ext.override( available_python_versions = ["3.13.0"], - base_url = "", + base_urls = [], minor_mapping = { "3.13": "3.13.0", }, @@ -762,13 +761,13 @@ _tests.append(_test_add_patches) def _test_fail_two_overrides(env): errors = [] parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, override = [ - python_ext.override(base_url = "foo"), - python_ext.override(base_url = "bar"), + python_ext.override(base_urls = ["foo"]), + python_ext.override(base_urls = ["bar"]), ], toolchain = [python_ext.toolchain(python_version = "3.13")], ), @@ -800,7 +799,7 @@ def _test_single_version_override_errors(env): ]: errors = [] parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, @@ -853,7 +852,7 @@ def _test_single_version_platform_override_errors(env): ]: errors = [] parse_modules( - module_ctx = mocks.mctx( + module_ctx = python_ext.mctx( python_ext.module( name = "my_module", is_root = True, diff --git a/tests/python_bzlmod_ext/BUILD.bazel b/tests/python_bzlmod_ext/BUILD.bazel index 0add4e7690..e266c01b9c 100644 --- a/tests/python_bzlmod_ext/BUILD.bazel +++ b/tests/python_bzlmod_ext/BUILD.bazel @@ -2,6 +2,6 @@ load(":test_helpers.bzl", "register_python_bzlmod_ext_tests") register_python_bzlmod_ext_tests( name = "python_bzlmod_ext_tests", - parse_sha_manifest_name = "parse_sha_manifest_tests", + parse_runtime_manifest_name = "parse_runtime_manifest_tests", runtime_manifests_name = "runtime_manifests_tests", ) diff --git a/tests/python_bzlmod_ext/parse_sha_manifest_tests.bzl b/tests/python_bzlmod_ext/parse_runtime_manifest_tests.bzl similarity index 91% rename from tests/python_bzlmod_ext/parse_sha_manifest_tests.bzl rename to tests/python_bzlmod_ext/parse_runtime_manifest_tests.bzl index 4ddcdcc46e..4493576147 100644 --- a/tests/python_bzlmod_ext/parse_sha_manifest_tests.bzl +++ b/tests/python_bzlmod_ext/parse_runtime_manifest_tests.bzl @@ -4,7 +4,7 @@ load("@bazel_skylib//lib:structs.bzl", "structs") load("@rules_testing//lib:analysis_test.bzl", "analysis_test") load("@rules_testing//lib:test_suite.bzl", "test_suite") load("@rules_testing//lib:util.bzl", rt_util = "util") -load("//python/private:pbs_manifest.bzl", "parse_filename", "parse_sha_manifest") # buildifier: disable=bzl-visibility +load("//python/private:pbs_manifest.bzl", "parse_filename", "parse_runtime_manifest") # buildifier: disable=bzl-visibility _tests = [] @@ -32,8 +32,8 @@ def _test_parse_filename_baseline_impl(env, target): env.expect.that_dict(parsed1).contains_exactly({ "arch": "x86_64", "archive_flavor": "install_only", + "build_flavor": "", "build_version": "20260414", - "flavor": "", "freethreaded": False, "libc": "gnu", "location": "cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz", @@ -48,8 +48,8 @@ def _test_parse_filename_baseline_impl(env, target): env.expect.that_dict(parsed2).contains_exactly({ "arch": "x86_64", "archive_flavor": "full", + "build_flavor": "lto", "build_version": "20260414", - "flavor": "lto", "freethreaded": False, "libc": "musl", "location": "cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst", @@ -64,8 +64,8 @@ def _test_parse_filename_baseline_impl(env, target): env.expect.that_dict(parsed3).contains_exactly({ "arch": "aarch64", "archive_flavor": "full", + "build_flavor": "pgo+lto", "build_version": "20260414", - "flavor": "pgo+lto", "freethreaded": True, "libc": "", "location": "cpython-3.13.13+20260414-aarch64-apple-darwin-freethreaded+pgo+lto-full.tar.zst", @@ -84,8 +84,8 @@ def _test_parse_filename_baseline_impl(env, target): env.expect.that_dict(parsed5).contains_exactly({ "arch": "x86_64", "archive_flavor": "install_only", + "build_flavor": "", "build_version": "20260414", - "flavor": "", "freethreaded": False, "libc": "gnu", "location": "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz", @@ -97,7 +97,7 @@ def _test_parse_filename_baseline_impl(env, target): _tests.append(_test_parse_filename_baseline) -def _test_parse_sha_manifest(name): +def _test_parse_runtime_manifest(name): """Sets up the manifest file parsing test. Args: @@ -110,10 +110,10 @@ def _test_parse_sha_manifest(name): analysis_test( name = name, target = name + "_subject", - impl = _test_parse_sha_manifest_impl, + impl = _test_parse_runtime_manifest_impl, ) -def _test_parse_sha_manifest_impl(env, target): +def _test_parse_runtime_manifest_impl(env, target): _ = target # @unused content = """ 8b14030dd3af9ea7f7c51b4c90feb04afd8a8f45435727e67b875270bd08f3bc cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz @@ -121,14 +121,14 @@ a57ffd435652092d16b30e783f9826c55e9c64b0f0a72cbae0a9f39e663137fb cpython-3 ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f https://example.com/cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst 1111111111111111111111111111111111111111111111111111111111111111 cpython-3.13.13+20260414-aarch64-apple-darwin-freethreaded+pgo+lto-full.tar.zst """ - parsed = parse_sha_manifest(content) + parsed = parse_runtime_manifest(content) env.expect.that_collection(parsed).has_size(4) env.expect.that_dict(structs.to_dict(parsed[0])).contains_exactly({ "arch": "x86_64", "archive_flavor": "install_only", + "build_flavor": "", "build_version": "20260414", - "flavor": "", "freethreaded": False, "libc": "gnu", "location": "cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz", @@ -142,8 +142,8 @@ ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f https://exampl env.expect.that_dict(structs.to_dict(parsed[2])).contains_exactly({ "arch": "x86_64", "archive_flavor": "full", + "build_flavor": "lto", "build_version": "20260414", - "flavor": "lto", "freethreaded": False, "libc": "musl", "location": "https://example.com/cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst", @@ -157,8 +157,8 @@ ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f https://exampl env.expect.that_dict(structs.to_dict(parsed[3])).contains_exactly({ "arch": "aarch64", "archive_flavor": "full", + "build_flavor": "pgo+lto", "build_version": "20260414", - "flavor": "pgo+lto", "freethreaded": True, "libc": "", "location": "cpython-3.13.13+20260414-aarch64-apple-darwin-freethreaded+pgo+lto-full.tar.zst", @@ -169,9 +169,9 @@ ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f https://exampl "vendor": "apple", }) -_tests.append(_test_parse_sha_manifest) +_tests.append(_test_parse_runtime_manifest) -def parse_sha_manifest_test_suite(name): +def parse_runtime_manifest_test_suite(name): """Defines the test suite for manifest parsing. Args: diff --git a/tests/python_bzlmod_ext/runtime_manifests_tests.bzl b/tests/python_bzlmod_ext/runtime_manifests_tests.bzl index c46cab1958..6de0ff93c2 100644 --- a/tests/python_bzlmod_ext/runtime_manifests_tests.bzl +++ b/tests/python_bzlmod_ext/runtime_manifests_tests.bzl @@ -5,7 +5,6 @@ load("@rules_testing//lib:test_suite.bzl", "test_suite") load("@rules_testing//lib:util.bzl", rt_util = "util") load("//python/private:python.bzl", "parse_modules") # buildifier: disable=bzl-visibility load("//python/private:repo_utils.bzl", "repo_utils") # buildifier: disable=bzl-visibility -load("//tests/support/mocks:mocks.bzl", "mocks") # buildifier: disable=bzl-visibility load("//tests/support/mocks:python_ext.bzl", "python_ext") # buildifier: disable=bzl-visibility _tests = [] @@ -52,7 +51,7 @@ def _test_dynamic_manifest_toolchains_impl(env, target): ) # Pre-populate mock_files directly to bypass download output struct key mismatch in mock read lookups. - mock_mctx = mocks.mctx( + mock_mctx = python_ext.mctx( modules = [root_module], mock_files = { "runtime_manifest": """ @@ -110,7 +109,7 @@ def _test_dynamic_manifest_files_impl(env, target): add_runtime_manifest_files = [ Label("//:SHA256SUMS"), ], - base_url = "https://example.com/dl", + base_urls = ["https://example.com/dl"], register_all_versions = True, ), ], @@ -121,7 +120,7 @@ def _test_dynamic_manifest_files_impl(env, target): ], ) - mock_mctx = mocks.mctx( + mock_mctx = python_ext.mctx( modules = [root_module], mock_files = { str(Label("//:SHA256SUMS")): """ diff --git a/tests/python_bzlmod_ext/test_helpers.bzl b/tests/python_bzlmod_ext/test_helpers.bzl index f6c177650a..78ad57c110 100644 --- a/tests/python_bzlmod_ext/test_helpers.bzl +++ b/tests/python_bzlmod_ext/test_helpers.bzl @@ -15,23 +15,23 @@ """Helpers to conditionally register tests depending on Bzlmod enablement.""" load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility -load(":parse_sha_manifest_tests.bzl", "parse_sha_manifest_test_suite") +load(":parse_runtime_manifest_tests.bzl", "parse_runtime_manifest_test_suite") load(":runtime_manifests_tests.bzl", "runtime_manifests_test_suite") -def register_python_bzlmod_ext_tests(name, parse_sha_manifest_name, runtime_manifests_name): +def register_python_bzlmod_ext_tests(name, parse_runtime_manifest_name, runtime_manifests_name): """Registers the Bzlmod extension tests if Bzlmod is enabled, otherwise defines empty test_suites. Args: name: The name of the master test_suite target. - parse_sha_manifest_name: The name of the parse_sha_manifest test target. + parse_runtime_manifest_name: The name of the parse_runtime_manifest test target. runtime_manifests_name: The name of the runtime_manifests test target. """ if BZLMOD_ENABLED: - parse_sha_manifest_test_suite(name = parse_sha_manifest_name) + parse_runtime_manifest_test_suite(name = parse_runtime_manifest_name) runtime_manifests_test_suite(name = runtime_manifests_name) else: native.test_suite( - name = parse_sha_manifest_name, + name = parse_runtime_manifest_name, tests = [], ) native.test_suite( @@ -42,7 +42,7 @@ def register_python_bzlmod_ext_tests(name, parse_sha_manifest_name, runtime_mani native.test_suite( name = name, tests = [ - parse_sha_manifest_name, + parse_runtime_manifest_name, runtime_manifests_name, ], ) diff --git a/tests/support/mocks/mocks.bzl b/tests/support/mocks/mocks.bzl index 48f8f95830..84d39fa6e2 100644 --- a/tests/support/mocks/mocks.bzl +++ b/tests/support/mocks/mocks.bzl @@ -105,9 +105,12 @@ def _module_new(name, *, is_root = False, **tags): def _mctx_read(self, x, watch = None): _ = watch # @unused path_str = x._path if hasattr(x, "_path") else str(x) - if path_str not in self.mock_files: - fail("File not found in mock_files: " + path_str) - return self.mock_files[path_str] + if path_str in self.mock_files: + return self.mock_files[path_str] + for k, v in self.mock_files.items(): + if k in path_str or k in path_str.replace(":", "/"): + return v + fail("File not found in mock_files: " + path_str) def _mctx_path(self, x): return _path_new(str(x), self.mock_files) diff --git a/tests/support/mocks/python_ext.bzl b/tests/support/mocks/python_ext.bzl index f7b5b0ee02..dc3ac41f8d 100644 --- a/tests/support/mocks/python_ext.bzl +++ b/tests/support/mocks/python_ext.bzl @@ -30,7 +30,7 @@ def _override(**kwargs): "add_runtime_manifest_urls": [], "add_target_settings": [], "available_python_versions": [], - "base_url": "https://github.com/astral-sh/python-build-standalone/releases/download", + "base_urls": ["https://github.com/astral-sh/python-build-standalone/releases/download"], "ignore_root_user_error": True, "minor_mapping": {}, "register_all_versions": False, @@ -93,8 +93,45 @@ def _toolchain(**kwargs): attrs.update(kwargs) return mocks.tag(**attrs) +_DEFAULT_RUNTIMES_MANIFEST = """ +87275619c2706affa4d1090d2ca3dad354b6d69f8b85dbfafe38785870751b9a 20251031/cpython-3.9.25+20251031-x86_64-unknown-linux-gnu-install_only.tar.gz +6112d46355857680b81849764a6cf9f38cc4cd0d1cf29d432bc12fe5aeedf9d0 20260414/cpython-3.10.20+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +1111111111111111111111111111111111111111111111111111111111111111 20241016/cpython-3.10.15+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz +2222222222222222222222222222222222222222222222222222222222222222 20240224/cpython-3.10.13+20240224-x86_64-unknown-linux-gnu-install_only.tar.gz +0000000000000000000000000000000000000000000000000000000000000000 20260414/cpython-3.11.15+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +3333333333333333333333333333333333333333333333333333333333333333 20241016/cpython-3.11.10+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz +4444444444444444444444444444444444444444444444444444444444444444 20230116/cpython-3.11.2+20230116-x86_64-unknown-linux-gnu-install_only.tar.gz +5555555555555555555555555555555555555555555555555555555555555555 20230116/cpython-3.11.1+20230116-x86_64-unknown-linux-gnu-install_only.tar.gz +6666666666666666666666666666666666666666666666666666666666666666 20260414/cpython-3.12.13+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +7777777777777777777777777777777777777777777777777777777777777777 20240726/cpython-3.12.4+20240726-x86_64-unknown-linux-gnu-install_only.tar.gz +8888888888888888888888888888888888888888888888888888888888888888 20260414/cpython-3.13.13+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +9999999999999999999999999999999999999999999999999999999999999999 20241016/cpython-3.13.0+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 20251031/cpython-3.14.0+20251031-x86_64-unknown-linux-gnu-install_only.tar.gz +cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc 20260414/cpython-3.15.0a8+20260414-x86_64-unknown-linux-gnu-install_only.tar.gz +dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd 20241205/cpython-3.13.1+20241205-x86_64-unknown-linux-gnu-install_only.tar.gz +""" + +def _mctx(*args, **kwargs): + """Creates a mock module_ctx pre-populated with the default runtimes manifest. + + Args: + *args: Positional arguments to pass to mocks.mctx. + **kwargs: Keyword arguments to pass to mocks.mctx. + + Returns: + A mock module_ctx struct. + """ + mock_files = { + "python/private/runtimes_manifest.txt": _DEFAULT_RUNTIMES_MANIFEST, + } + mock_files.update(kwargs.pop("mock_files", {})) + kwargs["mock_files"] = mock_files + return mocks.mctx(*args, **kwargs) + python_ext = struct( defaults = _defaults, + mctx = _mctx, module = _module, override = _override, single_version_override = _single_version_override, diff --git a/tests/toolchains/BUILD.bazel b/tests/toolchains/BUILD.bazel index f32ab6f056..b336a06269 100644 --- a/tests/toolchains/BUILD.bazel +++ b/tests/toolchains/BUILD.bazel @@ -13,6 +13,7 @@ # limitations under the License. load("@bazel_skylib//rules:build_test.bzl", "build_test") +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//tests/support:py_reconfig.bzl", "py_reconfig_test") load(":defs.bzl", "define_toolchain_tests") @@ -38,3 +39,18 @@ build_test( "@python_3_11//:python_headers", ], ) + +# Verify that runtimes_manifest_workspace.bzl exactly matches runtimes_manifest.txt +genrule( + name = "gen_expected_runtimes_manifest_workspace", + srcs = ["//python/private:runtimes_manifest.txt"], + outs = ["expected_runtimes_manifest_workspace.bzl"], + cmd = "$(execpath //python/private:sync_runtimes_manifest_workspace) $(location //python/private:runtimes_manifest.txt) $@", + tools = ["//python/private:sync_runtimes_manifest_workspace"], +) + +diff_test( + name = "runtimes_manifest_workspace_sync_test", + file1 = "//python/private:runtimes_manifest_workspace.bzl", + file2 = ":expected_runtimes_manifest_workspace.bzl", +) diff --git a/tests/toolchains/transitions/transitions_tests.bzl b/tests/toolchains/transitions/transitions_tests.bzl index 0cd79b373b..81ce1e68cc 100644 --- a/tests/toolchains/transitions/transitions_tests.bzl +++ b/tests/toolchains/transitions/transitions_tests.bzl @@ -14,11 +14,10 @@ "" -load("@pythons_hub//:versions.bzl", "DEFAULT_PYTHON_VERSION", "MINOR_MAPPING") +load("@pythons_hub//:versions.bzl", "DEFAULT_PYTHON_VERSION", "MINOR_MAPPING", "PYTHON_VERSIONS") load("@rules_testing//lib:analysis_test.bzl", "analysis_test") load("@rules_testing//lib:test_suite.bzl", "test_suite") load("@rules_testing//lib:util.bzl", rt_util = "util") -load("//python:versions.bzl", "TOOL_VERSIONS") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility load("//python/private:full_version.bzl", "full_version") # buildifier: disable=bzl-visibility @@ -142,7 +141,7 @@ def _test_full_version(name): name = name, tests = { v.replace(".", "_"): (v, v) - for v in TOOL_VERSIONS + for v in PYTHON_VERSIONS }, ) diff --git a/tools/private/sync_downloader_configs.py b/tools/private/sync_downloader_configs.py new file mode 100755 index 0000000000..7e8527b989 --- /dev/null +++ b/tools/private/sync_downloader_configs.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +"""Synchronizes downloader_config.cfg across subworkspaces.""" + +import sys +from pathlib import Path + + +def main(): + repo_root = Path(__file__).resolve().parent.parent.parent + canonical = repo_root / "downloader_config.cfg" + + subworkspaces = [ + repo_root / "gazelle" / "downloader_config.cfg", + repo_root / "sphinxdocs" / "downloader_config.cfg", + ] + + with open(canonical, "r", encoding="utf-8") as f: + canonical_content = f.read() + + changed = False + for sub in subworkspaces: + if sub.exists(): + with open(sub, "r", encoding="utf-8") as f: + old_content = f.read() + if old_content != canonical_content: + with open(sub, "w", encoding="utf-8") as f: + f.write(canonical_content) + print(f"Updated {sub.relative_to(repo_root)}") + changed = True + + if changed: + sys.exit(1) + + +if __name__ == "__main__": + main() From 44ec76f189dc433c8a0291c5d60e62fada605eb7 Mon Sep 17 00:00:00 2001 From: Udaya Prakash Date: Tue, 16 Jun 2026 03:06:05 +0200 Subject: [PATCH 009/179] docs: update changelog for 2.0.3 release (#3820) This PR updates the changelog on `main` to document the `2.0.3` release and moves the cherry-picked fix out of the `Unreleased` section. Companion to the release PR #3819 on the `release/2.0` branch. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa832e8fc..f431cd995e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,11 +106,6 @@ END_UNRELEASED_TEMPLATE * Fix the forwarding of `target_compatible_with` from `compile_pip_requirements` towards the underlying `*.update` target. ([#3787](https://github.com/bazel-contrib/rules_python/pull/3787)) -* (pypi) Assume that all of the packages are available on a particular hub if - there is only a single PyPI compatible index to be used. This saves us an expensive - PyPI download and supports PyPI mirror implementations that do not support the root - index functionality. Fixes - ([#3769](https://github.com/bazel-contrib/rules_python/pull/3769)). * (uv) allow user overwrite the build environment using `--action_env` to allow setting authentication for the index URL. ([#3405](https://github.com/bazel-contrib/rules_python/issues/3405)) @@ -139,6 +134,18 @@ END_UNRELEASED_TEMPLATE [#2054](https://github.com/bazel-contrib/rules_python/issues/2054). * (coverage) Add support for python 3.14 and bump `coverage.py` to 7.10.7. +{#v2-0-3} +## [2.0.3] - 2026-06-15 + +[2.0.3]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.3 + +{#v2-0-3-fixed} +### Fixed +* (pypi) Assume that all of the packages are available on a particular hub if + there is only a single PyPI compatible index to be used. This saves us an expensive + PyPI download and supports PyPI mirror implementations that do not support the root + index functionality. Fixes [#3769](https://github.com/bazel-contrib/rules_python/pull/3769). + {#v2-0-2} ## [2.0.2] - 2026-05-14 From 6ce7840252cfe2b4ba4484007d0c1c1650d1196d Mon Sep 17 00:00:00 2001 From: Pierre Gergondet Date: Tue, 16 Jun 2026 23:08:41 +0900 Subject: [PATCH 010/179] fix(coverage): handle nested coverage collection (#3823) We discovered that when a py_test invokes py_binary, coverage information collected by those binaries is lost and only the coverage of the test itself appears. It turns out that each binary writes to the same coverage file and only the last one is included in the report. This PR makes the lcov_path unique, ensuring the coverage is collected for every binary that might be launched in such a setup. I have setup https://github.com/gergondet-woven/repro-rules_python-coverage-issue to show the issue. In this repo, a py_binary is launched through a py_test and we would expect coverage information generated by the binary to surface in the coverage report but it doesn't with the current release: Before the patch: ``` # Generate coverage information bazel coverage --combined_report=lcov //... # Generate the report genhtml --ignore-errors category --branch-coverage --output ~/genhtml "$(bazel info output_path)/_coverage/_coverage_report.dat" # (snip) Overall coverage rate: lines......: 30.0% (3 of 10 lines) # Looking into the report, neither coverage for the library function # called by the binary nor the binary code itself is collected ``` After applying the patch: ``` Overall coverage rate: lines......: 100.0% (11 of 11 lines) ``` However, I am not sure how/if this can be properly tested within rules_python itself. --- CHANGELOG.md | 2 ++ python/private/stage2_bootstrap_template.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f431cd995e..7efdd66312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,8 @@ END_UNRELEASED_TEMPLATE ([#1975](https://github.com/bazel-contrib/rules_python/issues/1975)) * (uv) automatically pass the `--project` parameter based on the source files. ([#3087](https://github.com/bazel-contrib/rules_python/issues/3087)) +* (coverage) handle nested coverage collection + ([#3823](https://github.com/bazel-contrib/rules_python/pull/3823)) {#v0-0-0-added} ### Added diff --git a/python/private/stage2_bootstrap_template.py b/python/private/stage2_bootstrap_template.py index c4326bc026..a66b6428b0 100644 --- a/python/private/stage2_bootstrap_template.py +++ b/python/private/stage2_bootstrap_template.py @@ -406,7 +406,7 @@ def _maybe_collect_coverage(enable): yield finally: cov.stop() - lcov_path = os.path.join(coverage_dir, "pylcov.dat") + lcov_path = os.path.join(coverage_dir, "pylcov_{}.dat".format(unique_id)) print_verbose_coverage("generating lcov from:", lcov_path) cov.lcov_report( outfile=lcov_path, From 102b5014c33397293a50cd82cce9108d6de9837d Mon Sep 17 00:00:00 2001 From: John Sun <79071267+jsun-splunk@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:03:18 +1000 Subject: [PATCH 011/179] feat: expose interpreter files-to-run on PyRuntimeInfo (#3795) Rules that execute a py_runtime interpreter in an action need the interpreter executable together with its runfiles metadata. The existing PyRuntimeInfo fields identify the interpreter file and runtime files, but do not preserve the target's FilesToRunProvider for executable interpreter targets. Add PyRuntimeInfo.interpreter_files_to_run for runtimes created from an executable interpreter target, and validate that direct provider construction keeps the FilesToRunProvider executable aligned with the interpreter field. Direct file interpreters and platform runtimes continue to leave this field unset, preserving existing py_runtime behavior. Document the new public provider field and add focused analysis-test coverage for executable, file-only, platform, and invalid constructor cases. One example is [rules_pycross](https://github.com/jvolkman/rules_pycross) wheel building with an in-build executable Python runtime. pycross needs to run the selected Python interpreter in an action, but PyRuntimeInfo previously exposed only the interpreter File and runtime files, not the interpreter target's FilesToRunProvider. For executable in-build runtimes, the FilesToRunProvider is the Bazel-native handle that carries both the executable and the runfiles metadata needed to stage it as an action tool. Exposing it lets pycross consume the runtime interpreter directly for wheel-build actions instead of reconstructing or approximating the interpreter's runtime closure from separate fields. Co-authored-by: Richard Levasseur --- CHANGELOG.md | 2 + python/private/py_runtime_info.bzl | 24 +++ python/private/py_runtime_rule.bzl | 36 +++- tests/py_runtime/py_runtime_tests.bzl | 71 +++++++- .../py_runtime_info/py_runtime_info_tests.bzl | 159 ++++++++++++++++++ tests/support/py_runtime_info_subject.bzl | 12 ++ 6 files changed, 298 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7efdd66312..05daf8c85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,8 @@ END_UNRELEASED_TEMPLATE {#v0-0-0-added} ### Added +* (toolchain) Added {obj}`PyRuntimeInfo.interpreter_files_to_run` so action + consumers can execute an in-build runtime interpreter with its runfiles. * (toolchains) Support dynamically fetching and registering Python runtimes from a python-build-standalone manifest file using `python.override(add_runtime_manifest_urls = ..., runtime_manifest_sha = ...)`. diff --git a/python/private/py_runtime_info.bzl b/python/private/py_runtime_info.bzl index d94f469fc0..64fef399e2 100644 --- a/python/private/py_runtime_info.bzl +++ b/python/private/py_runtime_info.bzl @@ -55,6 +55,7 @@ def _PyRuntimeInfo_init( interpreter_path = None, interpreter = None, files = None, + interpreter_files_to_run = None, coverage_tool = None, coverage_files = None, pyc_tag = None, @@ -74,6 +75,15 @@ def _PyRuntimeInfo_init( if interpreter_path and files != None: fail("cannot specify 'files' if 'interpreter_path' is given") + if interpreter_path and interpreter_files_to_run: + fail("cannot specify 'interpreter_files_to_run' if 'interpreter_path' is given") + + if interpreter_files_to_run: + if not interpreter_files_to_run.executable: + fail("'interpreter_files_to_run' must have an executable") + if interpreter_files_to_run.executable != interpreter: + fail("'interpreter_files_to_run.executable' must match 'interpreter'") + if (coverage_tool and not coverage_files) or (not coverage_tool and coverage_files): fail( "coverage_tool and coverage_files must both be set or neither must be set, " + @@ -112,6 +122,7 @@ def _PyRuntimeInfo_init( "files": files, "implementation_name": implementation_name, "interpreter": interpreter, + "interpreter_files_to_run": interpreter_files_to_run, "interpreter_path": interpreter_path, "interpreter_version_info": interpreter_version_info_struct_from_dict(interpreter_version_info), "pyc_tag": pyc_tag, @@ -239,6 +250,19 @@ The Python implementation name (`sys.implementation.name`) If this is an in-build runtime, this field is a `File` representing the interpreter. Otherwise, this is `None`. Note that an in-build runtime can use either a prebuilt, checked-in interpreter or an interpreter built from source. +""", + "interpreter_files_to_run": """ +:type: None | FilesToRunProvider + +The `FilesToRunProvider` for the interpreter target when this runtime was +created from an executable target. This includes the interpreter executable and +the runfiles metadata needed to use it as an action tool. Rules that execute the +interpreter in an action should use this field so Bazel can stage the +interpreter together with its runfiles. This is `None` for platform runtimes +using `interpreter_path` and for file-only interpreter targets. + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, "interpreter_path": """ :type: str | None diff --git a/python/private/py_runtime_rule.bzl b/python/private/py_runtime_rule.bzl index dfc915f463..48637389bf 100644 --- a/python/private/py_runtime_rule.bzl +++ b/python/private/py_runtime_rule.bzl @@ -39,6 +39,7 @@ def _py_runtime_impl(ctx): runfiles = ctx.runfiles() hermetic = bool(interpreter) + interpreter_files_to_run = None if not hermetic: if runtime_files: fail("if 'interpreter_path' is given then 'files' must be empty") @@ -46,9 +47,35 @@ def _py_runtime_impl(ctx): fail("interpreter_path must be an absolute path") else: interpreter_di = interpreter[DefaultInfo] + interpreter_file = None - if interpreter_di.files_to_run and interpreter_di.files_to_run.executable: + if _is_singleton_depset(interpreter_di.files): + interpreter_file = interpreter_di.files.to_list()[0] + + is_executable_source_file = ( + interpreter_file and + interpreter_file.is_source and + interpreter_di.files_to_run and + interpreter_di.files_to_run.executable == interpreter_file + ) + + if is_executable_source_file: + # Source files Bazel treats as executable, e.g. direct file labels + # and filegroups: preserve historical runtime-file expansion, but + # do not expose a FilesToRunProvider. + interpreter = interpreter_file + runfiles = runfiles.merge(interpreter_di.default_runfiles) + + runtime_files = depset(transitive = [ + interpreter_di.files, + interpreter_di.default_runfiles.files, + runtime_files, + ]) + elif interpreter_di.files_to_run and interpreter_di.files_to_run.executable: + # Executable rule target: use the executable and preserve the full + # FilesToRunProvider so action consumers can stage its runfiles. interpreter = interpreter_di.files_to_run.executable + interpreter_files_to_run = interpreter_di.files_to_run runfiles = runfiles.merge(interpreter_di.default_runfiles) runtime_files = depset(transitive = [ @@ -56,8 +83,10 @@ def _py_runtime_impl(ctx): interpreter_di.default_runfiles.files, runtime_files, ]) - elif _is_singleton_depset(interpreter_di.files): - interpreter = interpreter_di.files.to_list()[0] + elif interpreter_file: + # Non-executable rule with exactly one output: preserve the + # historical file-only interpreter behavior. + interpreter = interpreter_file else: fail("interpreter must be an executable target or must produce exactly one file.") @@ -124,6 +153,7 @@ def _py_runtime_impl(ctx): py_runtime_info_kwargs.update(dict( implementation_name = ctx.attr.implementation_name, interpreter_version_info = interpreter_version_info, + interpreter_files_to_run = interpreter_files_to_run, pyc_tag = pyc_tag, stage2_bootstrap_template = ctx.file.stage2_bootstrap_template, zip_main_template = ctx.file.zip_main_template, diff --git a/tests/py_runtime/py_runtime_tests.bzl b/tests/py_runtime/py_runtime_tests.bzl index b8aa1f3fa6..ac80c8556d 100644 --- a/tests/py_runtime/py_runtime_tests.bzl +++ b/tests/py_runtime/py_runtime_tests.bzl @@ -42,6 +42,20 @@ _simple_binary = rule( executable = True, ) +def _source_file_wrapper_impl(ctx): + return [DefaultInfo( + files = depset([ctx.file.src]), + runfiles = ctx.runfiles(files = ctx.files.data), + )] + +_source_file_wrapper = rule( + implementation = _source_file_wrapper_impl, + attrs = { + "data": attr.label_list(allow_files = True), + "src": attr.label(allow_single_file = True, mandatory = True), + }, +) + def _test_bootstrap_template(name): rt_util.helper_target( py_runtime, @@ -195,11 +209,54 @@ def _test_in_build_interpreter(name): def _test_in_build_interpreter_impl(env, target): info = env.expect.that_target(target).provider(PyRuntimeInfo, factory = py_runtime_info_subject) info.python_version().equals("PY3") - info.files().contains_predicate(matching.file_basename_equals("file1.txt")) + info.files().contains_exactly([ + "{package}/fake_interpreter", + "{package}/file1.txt", + ]) info.interpreter().path().contains("fake_interpreter") + env.expect.that_bool(info.actual.interpreter_files_to_run == None).equals(True) _tests.append(_test_in_build_interpreter) +def _test_non_executable_source_file_interpreter_keeps_file_only_behavior(name): + rt_util.helper_target( + _source_file_wrapper, + name = name + "_wrapped_interpreter", + src = "fake_interpreter", + data = ["runfile.txt"], + ) + + rt_util.helper_target( + py_runtime, + name = name + "_subject", + interpreter = name + "_wrapped_interpreter", + python_version = "PY3", + files = ["file1.txt"], + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_non_executable_source_file_interpreter_keeps_file_only_behavior_impl, + ) + +def _test_non_executable_source_file_interpreter_keeps_file_only_behavior_impl(env, target): + target = env.expect.that_target(target) + py_runtime_info = target.provider( + PyRuntimeInfo, + factory = py_runtime_info_subject, + ) + py_runtime_info.interpreter().short_path_equals("{package}/fake_interpreter") + env.expect.that_bool(py_runtime_info.actual.interpreter_files_to_run == None).equals(True) + py_runtime_info.files().contains_exactly([ + "{package}/file1.txt", + ]) + + target.default_outputs().contains_exactly([ + "{package}/file1.txt", + ]) + +_tests.append(_test_non_executable_source_file_interpreter_keeps_file_only_behavior) + def _test_interpreter_binary_with_multiple_outputs(name): rt_util.helper_target( _simple_binary, @@ -227,6 +284,9 @@ def _test_interpreter_binary_with_multiple_outputs_impl(env, target): factory = py_runtime_info_subject, ) py_runtime_info.interpreter().short_path_equals("{package}/{test_name}_built_interpreter") + py_runtime_info.interpreter_files_to_run().executable().short_path_equals( + "{package}/{test_name}_built_interpreter", + ) py_runtime_info.files().contains_exactly([ "{package}/extra_default_output.txt", "{package}/runfile.txt", @@ -272,6 +332,9 @@ def _test_interpreter_binary_with_single_output_and_runfiles_impl(env, target): factory = py_runtime_info_subject, ) py_runtime_info.interpreter().short_path_equals("{package}/{test_name}_built_interpreter") + py_runtime_info.interpreter_files_to_run().executable().short_path_equals( + "{package}/{test_name}_built_interpreter", + ) py_runtime_info.files().contains_exactly([ "{package}/runfile.txt", "{package}/{test_name}_built_interpreter", @@ -327,10 +390,12 @@ def _test_system_interpreter(name): ) def _test_system_interpreter_impl(env, target): - env.expect.that_target(target).provider( + info = env.expect.that_target(target).provider( PyRuntimeInfo, factory = py_runtime_info_subject, - ).interpreter_path().equals("/system/python") + ) + info.interpreter_path().equals("/system/python") + env.expect.that_bool(info.actual.interpreter_files_to_run == None).equals(True) _tests.append(_test_system_interpreter) diff --git a/tests/py_runtime_info/py_runtime_info_tests.bzl b/tests/py_runtime_info/py_runtime_info_tests.bzl index a44fb60c2f..6b9ecac605 100644 --- a/tests/py_runtime_info/py_runtime_info_tests.bzl +++ b/tests/py_runtime_info/py_runtime_info_tests.bzl @@ -15,7 +15,9 @@ load("@rules_testing//lib:analysis_test.bzl", "analysis_test") load("@rules_testing//lib:test_suite.bzl", "test_suite") +load("@rules_testing//lib:truth.bzl", "matching") load("//python:py_runtime_info.bzl", "PyRuntimeInfo") +load("//tests/support:py_runtime_info_subject.bzl", "py_runtime_info_subject") def _create_py_runtime_info_without_interpreter_version_info_impl(ctx): return [PyRuntimeInfo( @@ -35,6 +37,57 @@ _create_py_runtime_info_without_interpreter_version_info = rule( }, ) +def _simple_binary_impl(ctx): + executable = ctx.actions.declare_file(ctx.label.name) + ctx.actions.write(executable, "", is_executable = True) + return [DefaultInfo( + executable = executable, + files = depset([executable]), + )] + +_simple_binary = rule( + implementation = _simple_binary_impl, + executable = True, +) + +def _file_target_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".txt") + ctx.actions.write(output, "") + return [DefaultInfo(files = depset([output]))] + +_file_target = rule( + implementation = _file_target_impl, +) + +def _create_py_runtime_info_with_interpreter_files_to_run_impl(ctx): + files_to_run = ctx.attr.files_to_run[DefaultInfo].files_to_run + kwargs = dict( + bootstrap_template = ctx.file.bootstrap_template, + interpreter_files_to_run = files_to_run, + python_version = "PY3", + ) + if ctx.attr.use_interpreter_path: + kwargs["interpreter_path"] = "/python" + else: + kwargs["files"] = depset() + kwargs["interpreter"] = ctx.executable.interpreter + + return [PyRuntimeInfo(**kwargs)] + +_create_py_runtime_info_with_interpreter_files_to_run = rule( + implementation = _create_py_runtime_info_with_interpreter_files_to_run_impl, + attrs = { + "bootstrap_template": attr.label(allow_single_file = True, default = "bootstrap.txt"), + "files_to_run": attr.label(mandatory = True), + "interpreter": attr.label( + cfg = "target", + executable = True, + mandatory = True, + ), + "use_interpreter_path": attr.bool(), + }, +) + _tests = [] def _test_can_create_py_runtime_info_without_interpreter_version_info(name): @@ -53,6 +106,112 @@ def _test_can_create_py_runtime_info_without_interpreter_version_info_impl(env, _tests.append(_test_can_create_py_runtime_info_without_interpreter_version_info) +def _test_interpreter_files_to_run_with_interpreter(name): + _simple_binary( + name = name + "_interpreter", + ) + _create_py_runtime_info_with_interpreter_files_to_run( + name = name + "_subject", + files_to_run = name + "_interpreter", + interpreter = name + "_interpreter", + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_interpreter_files_to_run_with_interpreter_impl, + ) + +def _test_interpreter_files_to_run_with_interpreter_impl(env, target): + info = env.expect.that_target(target).provider( + PyRuntimeInfo, + factory = py_runtime_info_subject, + ) + info.interpreter().short_path_equals("{package}/{test_name}_interpreter") + info.interpreter_files_to_run().executable().short_path_equals( + "{package}/{test_name}_interpreter", + ) + +_tests.append(_test_interpreter_files_to_run_with_interpreter) + +def _test_interpreter_files_to_run_disallows_interpreter_path(name): + _simple_binary( + name = name + "_interpreter", + ) + _create_py_runtime_info_with_interpreter_files_to_run( + name = name + "_subject", + files_to_run = name + "_interpreter", + interpreter = name + "_interpreter", + tags = ["manual"], + use_interpreter_path = True, + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_interpreter_files_to_run_disallows_interpreter_path_impl, + expect_failure = True, + ) + +def _test_interpreter_files_to_run_disallows_interpreter_path_impl(env, target): + env.expect.that_target(target).failures().contains_predicate( + matching.str_matches("*interpreter_files_to_run*interpreter_path*"), + ) + +_tests.append(_test_interpreter_files_to_run_disallows_interpreter_path) + +def _test_interpreter_files_to_run_requires_executable(name): + _simple_binary( + name = name + "_interpreter", + ) + _file_target( + name = name + "_files_to_run", + ) + _create_py_runtime_info_with_interpreter_files_to_run( + name = name + "_subject", + files_to_run = name + "_files_to_run", + interpreter = name + "_interpreter", + tags = ["manual"], + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_interpreter_files_to_run_requires_executable_impl, + expect_failure = True, + ) + +def _test_interpreter_files_to_run_requires_executable_impl(env, target): + env.expect.that_target(target).failures().contains_predicate( + matching.str_matches("*interpreter_files_to_run*executable*"), + ) + +_tests.append(_test_interpreter_files_to_run_requires_executable) + +def _test_interpreter_files_to_run_requires_matching_interpreter(name): + _simple_binary( + name = name + "_interpreter", + ) + _simple_binary( + name = name + "_other_interpreter", + ) + _create_py_runtime_info_with_interpreter_files_to_run( + name = name + "_subject", + files_to_run = name + "_other_interpreter", + interpreter = name + "_interpreter", + tags = ["manual"], + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_interpreter_files_to_run_requires_matching_interpreter_impl, + expect_failure = True, + ) + +def _test_interpreter_files_to_run_requires_matching_interpreter_impl(env, target): + env.expect.that_target(target).failures().contains_predicate( + matching.str_matches("*interpreter_files_to_run.executable*interpreter*"), + ) + +_tests.append(_test_interpreter_files_to_run_requires_matching_interpreter) + def py_runtime_info_test_suite(name): test_suite( name = name, diff --git a/tests/support/py_runtime_info_subject.bzl b/tests/support/py_runtime_info_subject.bzl index 541d4d9e18..0c6c672743 100644 --- a/tests/support/py_runtime_info_subject.bzl +++ b/tests/support/py_runtime_info_subject.bzl @@ -37,6 +37,9 @@ def py_runtime_info_subject(info, *, meta): coverage_tool = lambda *a, **k: _py_runtime_info_subject_coverage_tool(self, *a, **k), files = lambda *a, **k: _py_runtime_info_subject_files(self, *a, **k), interpreter = lambda *a, **k: _py_runtime_info_subject_interpreter(self, *a, **k), + interpreter_files_to_run = lambda *a, **k: ( + _py_runtime_info_subject_interpreter_files_to_run(self, *a, **k) + ), interpreter_path = lambda *a, **k: _py_runtime_info_subject_interpreter_path(self, *a, **k), interpreter_version_info = lambda *a, **k: _py_runtime_info_subject_interpreter_version_info(self, *a, **k), python_version = lambda *a, **k: _py_runtime_info_subject_python_version(self, *a, **k), @@ -84,6 +87,15 @@ def _py_runtime_info_subject_interpreter(self): meta = self.meta.derive("interpreter()"), ) +def _py_runtime_info_subject_interpreter_files_to_run(self): + return subjects.struct( + self.actual.interpreter_files_to_run, + attrs = dict( + executable = subjects.file, + ), + meta = self.meta.derive("interpreter_files_to_run()"), + ) + def _py_runtime_info_subject_interpreter_path(self): return subjects.str( self.actual.interpreter_path, From 5c32fa99b8b0ab15364d2f5d6ba65b8336e9ac62 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 17 Jun 2026 19:56:46 -0700 Subject: [PATCH 012/179] chore: prepare 2.1 release (#3829) Update changelog and version markers --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 ++++++++-------- .../rules_python/python/config_settings/index.md | 2 +- docs/environment-variables.md | 2 +- docs/toolchains.md | 2 +- gazelle/docs/directives.md | 4 ++-- python/private/py_executable_info.bzl | 2 +- python/private/py_runtime_info.bzl | 2 +- python/private/python.bzl | 8 ++++---- python/uv/private/lock.bzl | 4 ++-- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05daf8c85f..5b9ef27d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,19 +47,19 @@ BEGIN_UNRELEASED_TEMPLATE END_UNRELEASED_TEMPLATE --> -{#v0-0-0} -## Unreleased +{#v2-1-0} +## [2.1.0] - 2026-06-17 -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[2.1.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.1.0 -{#v0-0-0-removed} +{#v2-1-0-removed} ### Removed * (build_data) Removed CONFIG_MODE from build data ([#3793](https://github.com/bazel-contrib/rules_python/issues/3793)). * (coverage) Support for python 3.8 has been dropped from the bundled `coverage.py` wheel set, since coverage.py 7.6.2 dropped it. -{#v0-0-0-changed} +{#v2-1-0-changed} ### Changed * (bzlmod) How default runtimes are registered has changed to use a manifest of SHAs and URLs. `TOOL_VERSIONS` in `python/versions.bzl` is now empty under @@ -75,7 +75,7 @@ END_UNRELEASED_TEMPLATE platform, a warning is now printed instead of silently producing an empty coverage report. -{#v0-0-0-fixed} +{#v2-1-0-fixed} ### Fixed * (gazelle) `py_library` and `py_test` targets with missing source files can now be removed by Gazelle ([#3375](https://github.com/bazel-contrib/rules_python/issues/3375)). @@ -117,7 +117,7 @@ END_UNRELEASED_TEMPLATE * (coverage) handle nested coverage collection ([#3823](https://github.com/bazel-contrib/rules_python/pull/3823)) -{#v0-0-0-added} +{#v2-1-0-added} ### Added * (toolchain) Added {obj}`PyRuntimeInfo.interpreter_files_to_run` so action consumers can execute an in-build runtime interpreter with its runfiles. @@ -2475,4 +2475,4 @@ Breaking changes: * (pip) Create all_data_requirements alias * Expose Python C headers through the toolchain. -[0.24.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.24.0 +[0.24.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.24.0 \ No newline at end of file diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md index d7bc296296..cd3cbc9829 100644 --- a/docs/api/rules_python/python/config_settings/index.md +++ b/docs/api/rules_python/python/config_settings/index.md @@ -375,7 +375,7 @@ is created. ## Removed Flags -:::{versionremoved} VERSION_NEXT_FEATURE +:::{versionremoved} 2.1.0 The following flags were removed: * `pip_whl` diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 35792415cf..6c3444cba3 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -151,7 +151,7 @@ When `1`, debug information about coverage behavior is printed to stderr. ## Removed Environment Variables -:::{versionremoved} VERSION_NEXT_FEATURE +:::{versionremoved} 2.1.0 The following environment variables were removed: * `RULES_PYTHON_ENABLE_PYSTAR`: Used to enable the Starlark implementation of diff --git a/docs/toolchains.md b/docs/toolchains.md index 80884baedf..98534aee63 100644 --- a/docs/toolchains.md +++ b/docs/toolchains.md @@ -373,7 +373,7 @@ Notes: that match supported platforms. - Only runtimes matching known platforms in `rules_python` will be registered. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 Added support for registering runtimes from a manifest using `add_runtime_manifest_files`, `add_runtime_manifest_urls`, and `runtime_manifest_sha` in `python.override`. diff --git a/gazelle/docs/directives.md b/gazelle/docs/directives.md index dfa439358d..ce8c03b9cd 100644 --- a/gazelle/docs/directives.md +++ b/gazelle/docs/directives.md @@ -681,7 +681,7 @@ that are relative to the current package. {gh-pr}`3014` ::: -:::{versionchanged} VERSION_NEXT_FEATURE +:::{versionchanged} 2.1.0 The default was changed from `false` to `true`. {gh-pr}`3753` ::: @@ -725,7 +725,7 @@ not write `pyi_deps`. {gh-pr}`3356` ::: -:::{versionchanged} VERSION_NEXT_FEATURE +:::{versionchanged} 2.1.0 The default was changed from `false` to `true`. {gh-pr}`3753` ::: diff --git a/python/private/py_executable_info.bzl b/python/private/py_executable_info.bzl index 9e9baea421..7693fa950f 100644 --- a/python/private/py_executable_info.bzl +++ b/python/private/py_executable_info.bzl @@ -88,7 +88,7 @@ Only used with Windows for files that would have used `declare_symlink()` to create relative symlinks. These may overlap with paths in runfiles; it's up to the consumer to determine how to handle such overlaps. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 ::: """, "venv_interpreter_runfiles": """ diff --git a/python/private/py_runtime_info.bzl b/python/private/py_runtime_info.bzl index 64fef399e2..daf01e6c7e 100644 --- a/python/private/py_runtime_info.bzl +++ b/python/private/py_runtime_info.bzl @@ -261,7 +261,7 @@ interpreter in an action should use this field so Bazel can stage the interpreter together with its runfiles. This is `None` for platform runtimes using `interpreter_path` and for file-only interpreter targets. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 ::: """, "interpreter_path": """ diff --git a/python/private/python.bzl b/python/private/python.bzl index ac8d1111fb..23bae5d341 100644 --- a/python/private/python.bzl +++ b/python/private/python.bzl @@ -1231,7 +1231,7 @@ Example: [Manifest file format documentation](https://rules-python.readthedocs.io/en/latest/toolchains.html#manifest-file-format) ::: -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 ::: """, ), @@ -1250,7 +1250,7 @@ Note that `/latest/` can be used in place of a specific release date (e.g., `202 [Manifest file format documentation](https://rules-python.readthedocs.io/en/latest/toolchains.html#manifest-file-format) ::: -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 ::: """, ), @@ -1274,7 +1274,7 @@ These settings are appended to the `target_settings` of all toolchains registered by the extension, including any that already have settings from `python.single_version_platform_override`. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 ::: """, ), @@ -1328,7 +1328,7 @@ The values in this mapping override the default values and do not replace them. doc = """ SHA256 hash for the add_runtime_manifest_urls. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 ::: """, ), diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index 23f2eed467..7b2aa36098 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -306,7 +306,7 @@ shortest directory path is selected. This makes `uv` read `[tool.uv]` settings (e.g. `no-build-isolation`, `exclude-dependencies`) from that `pyproject.toml`. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.1.0 ::: """, ), @@ -529,7 +529,7 @@ def lock( `exclude-dependencies` from that `pyproject.toml`. If no `pyproject.toml` is in `srcs` and no `project` is given, the Bazel package directory is used as fallback. - {versionadded}VERSION_NEXT_FEATURE + {versionadded} 2.1.0 python_version: {type}`str | None` the python_version to transition to when locking the requirements. Defaults to the default python version configured by the {obj}`python` module extension. From 98b769ea97cc818e94860b4e1f3b3daac19cfaa9 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 18 Jun 2026 18:51:51 -0700 Subject: [PATCH 013/179] chore: generate release changelog from news entries (#3827) Currently, we update the change log directly when user-visible changes are made. This works, but makes backporting patches into release branches prone to unnecessary conflicts. These conflicts can sometimes be quite confusing, making it easy to accidentally merge incorrect content. Instead, what we can do is keep track of an upcoming release's change log entries in separate files under the `news/` directory. When creating a release, all the news entries are assembled into a section in the change log and the news entries deleted. When backporting a change, after cherry picking, the news entries are merged into the release's change log section. --- CHANGELOG.md | 23 +- CONTRIBUTING.md | 28 +- RELEASING.md | 23 +- news/.gitkeep | 1 + news/BUILD.bazel | 6 + tests/news/BUILD.bazel | 8 + tests/news/news_test.py | 68 ++++ tests/tools/private/release/release_test.py | 392 +++++++++++++++++++- tools/private/release/release.py | 359 +++++++++++++++++- 9 files changed, 848 insertions(+), 60 deletions(-) create mode 100644 news/.gitkeep create mode 100644 news/BUILD.bazel create mode 100644 tests/news/BUILD.bazel create mode 100644 tests/news/news_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9ef27d65..e1d371293a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,24 +28,16 @@ BEGIN_UNRELEASED_TEMPLATE [0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 -{#v0-0-0-removed} -### Removed -* Nothing removed. - -{#v0-0-0-changed} -### Changed -* Nothing changed. - -{#v0-0-0-fixed} -### Fixed -* Nothing fixed. - -{#v0-0-0-added} -### Added -* Nothing added. +Unreleased changes are tracked as individual files in the [news/](./news) directory. END_UNRELEASED_TEMPLATE --> +{#v0-0-0} +## Unreleased + +[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 + +Unreleased changes are tracked as individual files in the [news/](./news) directory. {#v2-1-0} ## [2.1.0] - 2026-06-17 @@ -138,6 +130,7 @@ END_UNRELEASED_TEMPLATE [#2054](https://github.com/bazel-contrib/rules_python/issues/2054). * (coverage) Add support for python 3.14 and bump `coverage.py` to 7.10.7. + {#v2-0-3} ## [2.0.3] - 2026-06-15 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 917110978b..73ab6d6220 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,11 +171,31 @@ validation function. ### Documenting changes -Changes are documented in two places: CHANGELOG.md and API docs. +Changes are documented in two places: news entries and API docs. -CHANGELOG.md contains a brief, human friendly, description. This text is -intended for easy skimming so that, when people upgrade, they can quickly get a -sense of what's relevant to them. +Instead of modifying `CHANGELOG.md` directly, you should create a news entry file in the `news/` directory. These files are automatically assembled into `CHANGELOG.md` at release time. + +#### Creating a news entry + +Create a `.md` file in the `news/` directory. The filename must follow the format `..md`: + +* ``: A unique identifier, typically the GitHub Pull Request number or Issue number (e.g., `1234`). +* ``: The category of the change, which must be one of: + * `added`: For new features or behavior added in a backwards-compatible manner. + * `changed`: For changes in existing behavior. + * `fixed`: For bug fixes. + * `removed`: For removed features or behavior. + +The content of the file should be a brief, human-friendly description of the change. Do not include a leading bullet point (e.g. `*` or `-`), as this is automatically added during assembly. If your change is specific to a subsystem, prefix it with the subsystem in parentheses, e.g., `(gazelle) Fixed handling of...`. + +Example: `news/1234.fixed.md` +```markdown +(gazelle) Fixed handling of auto-included `__init__.py` files when generating `py_binary` targets. +``` + +Do not edit `CHANGELOG.md` directly for unreleased changes. + +#### API documentation API documentation are the doc strings for functions, fields, attributes, etc. When user-visible or notable behavior is added, changed, or removed, the diff --git a/RELEASING.md b/RELEASING.md index e4cf738f3d..a259c782c6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,13 +13,23 @@ These are the steps for a regularly scheduled release from HEAD. ### Steps 1. Update the changelog and replace the version placeholders by running the - release tool. The next version number will by automatically determined - based on the presence of `VERSION_NEXT_*` placeholders and git tags. + release tool. The next version number will be automatically determined + based on the presence of `VERSION_NEXT_*` placeholders and git tags. The + tool will read all news entry files in the `news/` directory, assemble + them into the changelog, and delete the processed news files. ```shell bazel run //tools/private/release ``` + If you want to append news entries to an already existing release section in + the changelog (for example, to update a drafted release or a release + branch), you can specify the version explicitly: + + ```shell + bazel run //tools/private/release -- X.Y.Z + ``` + 1. Send these changes for review and get them merged. 1. Create a branch for the new release, named `release/X.Y` ``` @@ -72,11 +82,10 @@ gh workflow run release.yml --ref -f publish_to_pypi=false API changes and new features bump the minor, and those with only bug fixes and other minor changes bump the patch digit. -The release tool will automatically determine the next version number. To find -if there were any features added or incompatible changes made, review -[CHANGELOG.md](CHANGELOG.md) and the commit history. This can be done using -github by going to the url: -`https://github.com/bazel-contrib/rules_python/compare/...main`. +The release tool will automatically determine the next version number based on +the `VERSION_NEXT_*` placeholders in the codebase. To see what changes are +being accumulated for the next release, review the pending news entries in the +`news/` directory. ## Patch release with cherry picks diff --git a/news/.gitkeep b/news/.gitkeep new file mode 100644 index 0000000000..35e8e662aa --- /dev/null +++ b/news/.gitkeep @@ -0,0 +1 @@ +# Keep this directory even if empty so git tracks it. diff --git a/news/BUILD.bazel b/news/BUILD.bazel new file mode 100644 index 0000000000..79b48675ca --- /dev/null +++ b/news/BUILD.bazel @@ -0,0 +1,6 @@ +package(default_visibility = ["//:__subpackages__"]) + +filegroup( + name = "news_files", + srcs = glob(["**"]), +) diff --git a/tests/news/BUILD.bazel b/tests/news/BUILD.bazel new file mode 100644 index 0000000000..31abff1a8a --- /dev/null +++ b/tests/news/BUILD.bazel @@ -0,0 +1,8 @@ +load("//python:py_test.bzl", "py_test") + +py_test( + name = "news_test", + srcs = ["news_test.py"], + data = ["//news:news_files"], + deps = ["//python/runfiles"], +) diff --git a/tests/news/news_test.py b/tests/news/news_test.py new file mode 100644 index 0000000000..a8ed7a2849 --- /dev/null +++ b/tests/news/news_test.py @@ -0,0 +1,68 @@ +import pathlib +import unittest + +from python.runfiles import runfiles + + +def _get_news_dir(): + rf = runfiles.Create() + path = rf.Rlocation("rules_python/news") + if path: + return pathlib.Path(path) + return None + + +class NewsTest(unittest.TestCase): + def test_all_news_files_are_valid(self): + news_dir = _get_news_dir() + self.assertIsNotNone(news_dir, "Could not locate news directory in runfiles") + self.assertTrue(news_dir.exists(), "News directory does not exist in runfiles") + + allowed_categories = {"added", "changed", "fixed", "removed"} + + for p in news_dir.iterdir(): + if not p.is_file(): + continue + # Ignore BUILD files and .gitkeep if they are in the directory + if p.name in ("BUILD", "BUILD.bazel", ".gitkeep"): + continue + + filename = p.name + + # Collapse extension and filename check into a single assertRegex + self.assertRegex( + filename, + r"^[^.]+\.[^.]+\.md$", + f"News filename {filename} must follow ..md pattern", + ) + + parts = filename.split(".") + category = parts[1].lower() + + # Category must be valid + self.assertIn( + category, + allowed_categories, + f"News file {filename} has invalid category '{category}'. " + f"Must be one of {allowed_categories}", + ) + + # Must be readable as UTF-8 + try: + content = p.read_text(encoding="utf-8").strip() + except (IOError, UnicodeDecodeError) as e: + self.fail(f"Failed to read news file {filename} as UTF-8: {e}") + + # Content must not be empty + self.assertTrue(len(content) > 0, f"News file {filename} must not be empty") + + # Content must NOT start with bullet points (* or -) + self.assertFalse( + content.startswith("* ") or content.startswith("- "), + f"News file {filename} must not start with bullet points (* or -). " + "The release tool adds them automatically.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 9f4f3b249b..3335a5f65f 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -16,21 +16,7 @@ [0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 -{#v0-0-0-changed} -### Changed -* Nothing changed. - -{#v0-0-0-fixed} -### Fixed -* Nothing fixed. - -{#v0-0-0-added} -### Added -* Nothing added. - -{#v0-0-0-removed} -### Removed -* Nothing removed. +Unreleased changes are tracked as individual files in the [news/](./news) directory. END_UNRELEASED_TEMPLATE --> @@ -101,6 +87,319 @@ def test_update_changelog(self): self.assertIn("{#v1-23-4-added}", new_content) self.assertIn("{#v1-23-4-removed}", new_content) + def test_update_changelog_with_news(self): + # Arrange + changelog = f""" +# Changelog + +{_UNRELEASED_TEMPLATE} + +{{#v0-0-0}} +## Unreleased + +[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 + +{{#v0-0-0-removed}} +### Removed +* Nothing removed. + +{{#v0-0-0-changed}} +### Changed +* Nothing changed. + +{{#v0-0-0-fixed}} +### Fixed +* Nothing fixed. + +{{#v0-0-0-added}} +### Added +* Nothing added. + +{{#v2-0-2}} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{{#v2-0-2-added}} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create news files + (news_dir / "123.fixed.md").write_text("Fixed a bug in the compiler") + # Test that it handles prefixing "* " if not present + (news_dir / "456.added.md").write_text("* Added a new feature for Python 3.13") + # Empty file should be ignored + (news_dir / "789.changed.md").write_text("") + # Invalid name should be ignored + (news_dir / "invalid_name.md").write_text("Should be ignored") + + # Act + releaser.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + # 1. News files matching the pattern should be deleted (even empty ones) + self.assertFalse((news_dir / "123.fixed.md").exists()) + self.assertFalse((news_dir / "456.added.md").exists()) + self.assertFalse((news_dir / "789.changed.md").exists()) + # Invalid name does not match pattern -> NOT deleted + self.assertTrue((news_dir / "invalid_name.md").exists()) + + new_content = changelog_path.read_text() + + # 2. Unreleased template comment should still be there + self.assertIn( + _UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n" + ) + + # 3. A fresh active Unreleased section should be present + self.assertIn("{#v0-0-0}", new_content) + self.assertIn("## Unreleased", new_content) + self.assertIn( + "Unreleased changes are tracked as individual files in the [news/](./news) directory.", + new_content, + ) + + # 4. The new release section should be present + self.assertIn("{#v3-0-0}", new_content) + self.assertIn("## [3.0.0] - 2026-06-16", new_content) + self.assertIn( + "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", + new_content, + ) + + # 5. Correct categories and content + self.assertIn( + "{#v3-0-0-fixed}\n### Fixed\n* Fixed a bug in the compiler", new_content + ) + self.assertIn( + "{#v3-0-0-added}\n### Added\n* Added a new feature for Python 3.13", + new_content, + ) + + # 6. Omitted categories should NOT be present in the new release + self.assertNotIn("{#v3-0-0-removed}", new_content) + self.assertNotIn("{#v3-0-0-changed}", new_content) + + # 7. Old release should still be there + self.assertIn("{#v2-0-2}", new_content) + self.assertIn("## [2.0.2] - 2026-05-14", new_content) + + def test_update_changelog_sorting(self): + # Arrange + changelog = f""" +# Changelog + +{_UNRELEASED_TEMPLATE} + +{{#v0-0-0}} +## Unreleased + +[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 + +Unreleased changes are tracked as individual files in the [news/](./news) directory. + +{{#v2-0-2}} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{{#v2-0-2-added}} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create news files with different sub-categories and some without + (news_dir / "1.fixed.md").write_text("* (zebra) Zebra fix") + (news_dir / "2.fixed.md").write_text("* (apple) Apple fix") + (news_dir / "3.fixed.md").write_text("No subcategory B") + (news_dir / "4.fixed.md").write_text("* (apple) Another apple fix") + (news_dir / "5.fixed.md").write_text("No subcategory A") + + # Act + releaser.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # Expected order in Fixed section: + # 1. No subcategory A + # 2. No subcategory B + # 3. (apple) Another apple fix + # 4. (apple) Apple fix + # 5. (zebra) Zebra fix + + expected_fixed_section = ( + "### Fixed\n" + "* No subcategory A\n" + "* No subcategory B\n" + "* (apple) Another apple fix\n" + "* (apple) Apple fix\n" + "* (zebra) Zebra fix\n" + ) + + self.assertIn(expected_fixed_section, new_content) + + def test_update_changelog_read_failure(self): + # Arrange + original_read_text = pathlib.Path.read_text + + with patch("pathlib.Path.read_text", autospec=True) as mock_read_text: + + def side_effect(path_self, *args, **kwargs): + if "bad_file.fixed.md" in str(path_self): + raise IOError("Simulated read error") + return original_read_text(path_self, *args, **kwargs) + + mock_read_text.side_effect = side_effect + + changelog = f""" +# Changelog + +{_UNRELEASED_TEMPLATE} + +{{#v0-0-0}} +## Unreleased + +[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 + +Unreleased changes are tracked as individual files in the [news/](./news) directory. + +{{#v2-0-2}} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{{#v2-0-2-added}} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create the bad file (must exist so it is found by iterdir) + bad_file = news_dir / "bad_file.fixed.md" + bad_file.write_text("some content that won't be read") + + # Create a good file too + good_file = news_dir / "good_file.fixed.md" + good_file.write_text("* (sub) Good fix") + + # Act & Assert + # It should raise IOError + with self.assertRaises(IOError): + releaser.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Both files should still exist (no deletion on failure!) + self.assertTrue(bad_file.exists()) + self.assertTrue(good_file.exists()) + + # Changelog should not be modified + new_content = changelog_path.read_text() + self.assertEqual(changelog, new_content) + + def test_update_changelog_merge_existing(self): + # Arrange + changelog = f""" +# Changelog + +{_UNRELEASED_TEMPLATE} + +{{#v0-0-0}} +## Unreleased + +[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 + +Unreleased changes are tracked as individual files in the [news/](./news) directory. + +{{#v2-0-3}} +## [2.0.3] - 2026-06-15 + +[2.0.3]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.3 + +{{#v2-0-3-fixed}} +### Fixed +* (pypi) Old fix + multi-line detail + * nested bullet item +* (pypi) Z old fix +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create news files to merge + # 1. New fix in same category (should merge and sort) + (news_dir / "1.fixed.md").write_text("(pypi) New fix") + # 2. New entry in new category (should create category) + (news_dir / "2.added.md").write_text("(toolchains) New feature") + + # Act + releaser.update_changelog( + "2.0.3", + "2026-06-15", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + # News files should be deleted + self.assertFalse((news_dir / "1.fixed.md").exists()) + self.assertFalse((news_dir / "2.added.md").exists()) + + new_content = changelog_path.read_text() + + # Expected merged and sorted Fixed section: + # 1. (pypi) New fix (New < Old) + # 2. (pypi) Old fix (with its multi-line detail!) + # 3. (pypi) Z old fix + expected_fixed_section = ( + "### Fixed\n" + "* (pypi) New fix\n" + "* (pypi) Old fix\n" + " multi-line detail\n" + " * nested bullet item\n" + "* (pypi) Z old fix\n" + ) + self.assertIn(expected_fixed_section, new_content) + + # Expected created Added section: + expected_added_section = "### Added\n* (toolchains) New feature\n" + self.assertIn(expected_added_section, new_content) + + # Active Unreleased section should NOT be touched (should still be empty/pointing to news) + self.assertIn("Unreleased changes are tracked as individual files", new_content) + def test_replace_version_next(self): # Arrange mock_file_content = """ @@ -265,6 +564,69 @@ def test_both_markers(self): self.assertEqual(next_version, "1.3.0") + @patch("tools.private.release.release._get_current_branch") + @patch("tools.private.release.release._get_git_tags") + def test_determine_next_version_on_release_branch_with_existing_tags( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.37" + mock_get_tags.return_value = ["0.37.0", "0.37.1", "0.36.0"] + + next_version = releaser.determine_next_version() + + self.assertEqual(next_version, "0.37.2") + + @patch("tools.private.release.release._get_current_branch") + @patch("tools.private.release.release._get_git_tags") + def test_determine_next_version_on_release_branch_no_tags( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.38" + mock_get_tags.return_value = ["0.37.0"] # No 0.38.x tags + + next_version = releaser.determine_next_version() + + self.assertEqual(next_version, "0.38.0") + + @patch("tools.private.release.release._get_current_branch") + @patch("tools.private.release.release._get_git_tags") + def test_determine_next_version_on_release_branch_with_active_rc( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.37" + # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet + mock_get_tags.return_value = ["0.37.0-rc0", "0.37.0-rc1", "0.36.0"] + + next_version = releaser.determine_next_version() + + # Should target 0.37.0, not 0.37.1 + self.assertEqual(next_version, "0.37.0") + + @patch("tools.private.release.release._get_current_branch") + @patch("tools.private.release.release._get_git_tags") + def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.37" + # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet) + mock_get_tags.return_value = ["0.37.0", "0.37.1-rc0", "0.36.0"] + + next_version = releaser.determine_next_version() + + # Should target 0.37.1, not 0.37.2 + self.assertEqual(next_version, "0.37.1") + + @patch("tools.private.release.release._get_current_branch") + def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): + mock_get_branch.return_value = "main" + # Should fallback to default behavior (which uses mock_get_latest_version from setUp) + self.mock_get_latest_version.return_value = "1.2.3" + (self.tmpdir / "mock_file.bzl").write_text("no markers here") + + next_version = releaser.determine_next_version() + + self.assertEqual(next_version, "1.2.4") + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 6fce0ff3b0..4f956bd56c 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -91,8 +91,68 @@ def should_increment_minor(): return False -def determine_next_version(): - """Determines the next version based on git tags and placeholders.""" +def _get_current_branch(): + """Returns the current git branch name, or None if not in a git repo.""" + try: + return ( + subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.DEVNULL, + ) + .decode("utf-8") + .strip() + ) + except subprocess.CalledProcessError: + return None + + +def determine_next_version(branch_name=None): + """Determines the next version based on git tags and the current branch.""" + if branch_name is None: + branch_name = _get_current_branch() + + if branch_name: + release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name) + if release_match: + branch_major = int(release_match.group(1)) + branch_minor = int(release_match.group(2)) + print( + f"Detected release branch: {branch_name} (targeting" + f" {branch_major}.{branch_minor}.x)" + ) + + # Find all stable tags matching this major.minor prefix. + # Crucially, we ignore release candidates (RCs) here. If an RC is active + # (e.g. 0.37.0-rc0 exists but 0.37.0 stable does not), we want to continue + # targeting 0.37.0, NOT increment to 0.37.1. + tags = _get_git_tags() + matching_patches = [] + for tag in tags: + tag = tag.strip() + m = re.match(rf"^{branch_major}\.{branch_minor}\.(\d+)$", tag) + if m: + matching_patches.append(int(m.group(1))) + + if matching_patches: + latest_patch = max(matching_patches) + next_version = f"{branch_major}.{branch_minor}.{latest_patch + 1}" + print( + f"Latest tag on this branch is" + f" {branch_major}.{branch_minor}.{latest_patch}. Next" + f" version: {next_version}" + ) + return next_version + else: + # No stable tags exist yet for this release branch (preparing X.Y.0, + # even if X.Y.0-rcN tags already exist) + next_version = f"{branch_major}.{branch_minor}.0" + print( + f"No stable tags found for {branch_major}.{branch_minor}.x." + f" Next version: {next_version}" + ) + return next_version + + # Fallback to default behavior (for main branch or other development branches) latest_version = get_latest_version() major, minor, patch = [int(n) for n in latest_version.split(".")] @@ -102,31 +162,292 @@ def determine_next_version(): return f"{major}.{minor}.{patch + 1}" -def update_changelog(version, release_date, changelog_path="CHANGELOG.md"): - """Performs the version replacements in CHANGELOG.md.""" +def _get_sub_category(content): + """Extracts the sub-category in parentheses from the entry content.""" + match = re.match(r"^(?:\*|-)\s*\(([^)]+)\)", content) + if match: + return match.group(1).lower() + return "" + + +def _get_news_files(news_dir): + """Returns a list of news files matching the ..md pattern.""" + news_path = pathlib.Path(news_dir) + if not news_path.exists(): + return [] + + valid_files = [] + for p in news_path.iterdir(): + if not p.is_file(): + continue + if p.suffix != ".md": + continue + parts = p.name.split(".") + if len(parts) < 3: + continue + valid_files.append(p) + + return valid_files + +def _parse_new_files(news_files): + """Parses news files and groups them by category.""" + entries = {} + for p in news_files: + parts = p.name.split(".") + category = parts[1].lower() + + content = p.read_text(encoding="utf-8").strip() + + if not content: + continue + + # Format as list item if not already + if not (content.startswith("* ") or content.startswith("- ")): + content = f"* {content}" + + if category not in entries: + entries[category] = [] + entries[category].append(content) + + return entries + + +def generate_release_block(version, release_date, news_entries): + """Generates the markdown block for the release.""" header_version = version.replace(".", "-") + lines = [ + f"{{#v{header_version}}}", + f"## [{version}] - {release_date}", + "", + f"[{version}]: https://github.com/bazel-contrib/rules_python/releases/tag/{version}", + "", + ] + + # Standard categories in preferred order + category_order = ["removed", "changed", "fixed", "added"] + # Add any other categories found + for cat in news_entries: + if cat not in category_order: + category_order.append(cat) + + for cat in category_order: + if cat in news_entries and news_entries[cat]: + lines.append(f"{{#v{header_version}-{cat}}}") + lines.append(f"### {cat.capitalize()}") + + # Sort entries by sub-category, then by content + sorted_entries = sorted( + news_entries[cat], key=lambda e: (_get_sub_category(e), e) + ) + + for entry in sorted_entries: + lines.append(entry) + lines.append("") + return "\n".join(lines) + + +def _add_news_to_changelog(changelog_path, version, entries, release_date): + """Adds or merges news entries into CHANGELOG.md.""" changelog_path_obj = pathlib.Path(changelog_path) - lines = changelog_path_obj.read_text().splitlines() + changelog_content = changelog_path_obj.read_text(encoding="utf-8") + + header_version = version.replace(".", "-") + version_anchor = f"{{#v{header_version}}}" + version_exists = version_anchor in changelog_content + + if version_exists: + if not entries: + print( + f"Version {version} already exists and no news entries found" + " to merge. Doing nothing." + ) + return + + print(f"Version {version} already exists in changelog. Merging news entries...") + # Extract the existing version block + # Match from the version anchor to the next version anchor (or end of file) + pattern = ( + r"(?P\{#v" + + re.escape(header_version) + + r"\})(?P.*?)(?=\n\s*\{#v(?!0-0-0)\d+-\d+-\d+\}|\Z)" + ) + match = re.search(pattern, changelog_content, re.DOTALL) + if not match: + raise RuntimeError( + f"Could not find content for existing version {version} in CHANGELOG.md" + ) + + content_block = match.group("content") + + # Split content_block into header and categories + category_anchor_pattern = ( + r"\{#v" + re.escape(header_version) + r"-(?P[a-z]+)\}" + ) + match_cat = re.search(category_anchor_pattern, content_block) + if match_cat: + header_end_idx = match_cat.start() + header_str = content_block[:header_end_idx] + categories_str = content_block[header_end_idx:] + else: + header_str = content_block + categories_str = "" + + # Parse existing categories + existing_entries = {} + if categories_str: + cat_matches = list(re.finditer(category_anchor_pattern, categories_str)) + for i, m in enumerate(cat_matches): + cat = m.group("cat") + start_idx = m.end() + end_idx = ( + cat_matches[i + 1].start() + if i + 1 < len(cat_matches) + else len(categories_str) + ) + cat_content = categories_str[start_idx:end_idx].strip() + + lines = cat_content.splitlines() + cat_entries = [] + current_entry = [] + for line in lines: + if not line.strip() or line.strip().startswith("### "): + continue + if line.startswith("* ") or line.startswith("- "): + if current_entry: + cat_entries.append("\n".join(current_entry)) + current_entry = [line] + else: + if current_entry: + current_entry.append(line) + if current_entry: + cat_entries.append("\n".join(current_entry)) + existing_entries[cat] = cat_entries + + # Merge news entries + merged_entries = dict(existing_entries) + for cat, cat_entries in entries.items(): + if cat not in merged_entries: + merged_entries[cat] = [] + merged_entries[cat].extend(cat_entries) + + # Reconstruct categories + reconstructed_lines = [] + category_order = ["removed", "changed", "fixed", "added"] + for cat in merged_entries: + if cat not in category_order: + category_order.append(cat) + + for cat in category_order: + if cat in merged_entries and merged_entries[cat]: + reconstructed_lines.append(f"{{#v{header_version}-{cat}}}") + reconstructed_lines.append(f"### {cat.capitalize()}") + + sorted_entries = sorted( + merged_entries[cat], key=lambda e: (_get_sub_category(e), e) + ) + + for entry in sorted_entries: + reconstructed_lines.append(entry) + reconstructed_lines.append("") + + new_categories_str = "\n".join(reconstructed_lines) + new_release_block = ( + header_str.rstrip() + "\n\n" + new_categories_str.strip() + "\n" + ) + + # Replace in changelog + new_content = re.sub( + pattern, + r"\g\n" + new_release_block.strip() + "\n", + changelog_content, + flags=re.DOTALL, + ) + changelog_path_obj.write_text(new_content, encoding="utf-8") + + else: + if entries: + print( + f"Version {version} does not exist in changelog. Creating new" + " release section from news entries..." + ) + # Extract template + template_match = re.search( + r"BEGIN_UNRELEASED_TEMPLATE\s*\n(.*?)\n\s*END_UNRELEASED_TEMPLATE", + changelog_content, + re.DOTALL, + ) + if not template_match: + raise RuntimeError( + "Could not find BEGIN_UNRELEASED_TEMPLATE in CHANGELOG.md" + ) + + unreleased_template = template_match.group(1).strip() + new_release_block = generate_release_block(version, release_date, entries) + + replacement = f"{unreleased_template}\n\n{new_release_block}\n" + + # Replace the active Unreleased section + pattern = r"(END_UNRELEASED_TEMPLATE\s*\n-->\s*\n)(.*?)(\n\s*\{#v(?!0-0-0)\d+-\d+-\d+\})" + + if not re.search(pattern, changelog_content, re.DOTALL): + raise RuntimeError( + "Could not find active Unreleased section to replace in" + " CHANGELOG.md" + ) + + new_content = re.sub( + pattern, + r"\g<1>" + replacement + r"\g<3>", + changelog_content, + flags=re.DOTALL, + ) + changelog_path_obj.write_text(new_content, encoding="utf-8") + else: + # Fallback to old behavior + print( + f"No news entries found and version {version} does not exist." + " Falling back to manual changelog update..." + ) + header_version = version.replace(".", "-") + lines = changelog_content.splitlines() + + new_lines = [] + after_template = False + before_already_released = True + for line in lines: + if "END_UNRELEASED_TEMPLATE" in line: + after_template = True + if re.match("#v[1-9]-", line): + before_already_released = False + + if after_template and before_already_released: + line = line.replace( + "## Unreleased", f"## [{version}] - {release_date}" + ) + line = line.replace("v0-0-0", f"v{header_version}") + line = line.replace("0.0.0", version) - new_lines = [] - after_template = False - before_already_released = True - for line in lines: - if "END_UNRELEASED_TEMPLATE" in line: - after_template = True - if re.match("#v[1-9]-", line): - before_already_released = False + new_lines.append(line) - if after_template and before_already_released: - line = line.replace("## Unreleased", f"## [{version}] - {release_date}") - line = line.replace("v0-0-0", f"v{header_version}") - line = line.replace("0.0.0", version) + changelog_path_obj.write_text("\n".join(new_lines), encoding="utf-8") + + +def update_changelog( + version, release_date, changelog_path="CHANGELOG.md", news_dir="news" +): + """Performs the version replacements in CHANGELOG.md.""" + news_files = _get_news_files(news_dir) + entries = _parse_new_files(news_files) - new_lines.append(line) + _add_news_to_changelog(changelog_path, version, entries, release_date) - changelog_path_obj.write_text("\n".join(new_lines)) + # Delete news files after successful update + for p in news_files: + p.unlink() + if news_files: + print(f"Removed {len(news_files)} processed news files.") def replace_version_next(version): From 105f6544efaac390ce2f2294d127139299c53781 Mon Sep 17 00:00:00 2001 From: Maximilian Birkner <99721567+maxbirkner@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:57:36 +0200 Subject: [PATCH 014/179] fix(coverage): skip lcov report when no data was collected (#3832) Under `bazel coverage`, coverage.py raises `NoDataError` from `lcov_report()` when no instrumented Python code was executed, instead of writing an (empty) report. This error currently propagates out of the coverage bootstrap and fails an otherwise passing test. This is common and benign in a few setups: - a `py_binary` that only spawns another process and runs no Python itself - a test whose instrumented sources happen not to execute any Python **Before:** `bazel coverage //a:test` fails with `coverage.exceptions.NoDataError` even though `bazel test //a:test` passes. **After:** the `NoDataError` is caught and the empty report is skipped, so the test result is unchanged by whether coverage data happened to be collected. This mirrors the reporter's suggestion in #2762 to "ignore the return code of `coverage lcov`", applied in-process. As noted on the original report and in #3823, there is no in-repo harness for exercising real coverage collection, so this change does not add an automated test. Fixes #2762 --------- Co-authored-by: Richard Levasseur --- news/3832.fixed.md | 1 + python/private/stage2_bootstrap_template.py | 29 ++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 news/3832.fixed.md diff --git a/news/3832.fixed.md b/news/3832.fixed.md new file mode 100644 index 0000000000..f1dd32df06 --- /dev/null +++ b/news/3832.fixed.md @@ -0,0 +1 @@ +(coverage) Skip lcov report when no data was collected. diff --git a/python/private/stage2_bootstrap_template.py b/python/private/stage2_bootstrap_template.py index a66b6428b0..b11fc76093 100644 --- a/python/private/stage2_bootstrap_template.py +++ b/python/private/stage2_bootstrap_template.py @@ -357,6 +357,7 @@ def _maybe_collect_coverage(enable): print_verbose_coverage("Sources:\n" + "\n".join(unique_dirs)) import coverage + from coverage.exceptions import NoDataError coverage_dir = os.environ["COVERAGE_DIR"] unique_id = uuid.uuid4() @@ -408,15 +409,25 @@ def _maybe_collect_coverage(enable): cov.stop() lcov_path = os.path.join(coverage_dir, "pylcov_{}.dat".format(unique_id)) print_verbose_coverage("generating lcov from:", lcov_path) - cov.lcov_report( - outfile=lcov_path, - # Ignore errors because sometimes instrumented files aren't - # readable afterwards. e.g. if they come from /dev/fd or if - # they were transient code-under-test in /tmp - ignore_errors=True, - ) - if os.path.isfile(lcov_path): - unresolve_symlinks(lcov_path) + try: + cov.lcov_report( + outfile=lcov_path, + # Ignore errors because sometimes instrumented files aren't + # readable afterwards. e.g. if they come from /dev/fd or if + # they were transient code-under-test in /tmp + ignore_errors=True, + ) + except NoDataError: + # coverage.py raises NoDataError if no instrumented Python code ran + # (e.g. tests not running Python, or subprocess-only binaries). + # Skip the report to avoid failing otherwise passing tests. + # See https://github.com/bazel-contrib/rules_python/issues/2762. + print_verbose_coverage( + "no coverage data collected; skipping lcov report:", lcov_path + ) + else: + if os.path.isfile(lcov_path): + unresolve_symlinks(lcov_path) finally: try: os.unlink(rcfile_name) From fe24f8c3ea7b4d0a3c42d65fd10157eaeaaaaa9b Mon Sep 17 00:00:00 2001 From: Kris foster Date: Fri, 19 Jun 2026 18:07:51 +0100 Subject: [PATCH 015/179] fix(pypi): respect empty envsubst expansion in experimental_index_url (#3828) Resolve `pip.parse(experimental_index_url = ...)` through `envsubst` before checking whether the experimental index-url code path should be enabled. Previously, an unsubstituted template like `$RULES_PYTHON_PIP_INDEX_URL` is a non-empty string and therefore truthy, so `_set_get_index_urls` enabled the experimental index-url mode even when the env var was unset or expanded to empty. With this change, the value is expanded first (using `pip_attr.envsubst` and `module_ctx.os.environ.get`) and the truthiness check runs against the resolved string, matching the behavior users expect when they gate the index URL on an env var. --------- Co-authored-by: Richard Levasseur --- news/3828.fixed.md | 2 + python/private/pypi/BUILD.bazel | 1 + python/private/pypi/hub_builder.bzl | 16 +++++-- tests/pypi/hub_builder/hub_builder_tests.bzl | 50 ++++++++++++++++---- 4 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 news/3828.fixed.md diff --git a/news/3828.fixed.md b/news/3828.fixed.md new file mode 100644 index 0000000000..64de2cc925 --- /dev/null +++ b/news/3828.fixed.md @@ -0,0 +1,2 @@ +(pypi) Fixed `experimental_index_url` checking truthiness before envsubst +expansion. diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 7569eccac4..a9ec49ca79 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -197,6 +197,7 @@ bzl_library( ":requirements_files_by_platform_bzl", ":whl_config_setting_bzl", ":whl_repo_name_bzl", + "//python/private:envsubst_bzl", "//python/private:full_version_bzl", "//python/private:normalize_name_bzl", "//python/private:text_util_bzl", diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl index d3be266bdf..6435e1656d 100644 --- a/python/private/pypi/hub_builder.bzl +++ b/python/private/pypi/hub_builder.bzl @@ -1,5 +1,6 @@ """A hub repository builder for incrementally building the hub configuration.""" +load("//python/private:envsubst.bzl", "envsubst") load("//python/private:full_version.bzl", "full_version") load("//python/private:normalize_name.bzl", "normalize_name") load("//python/private:repo_utils.bzl", "repo_utils") @@ -177,7 +178,7 @@ def _pip_parse(self, module_ctx, pip_attr): )) return - _set_get_index_urls(self, pip_attr) + _set_get_index_urls(self, module_ctx, pip_attr) self._platforms[python_version] = _platforms( module_ctx, python_version = full_python_version, @@ -345,8 +346,17 @@ def _add_whl_library(self, *, python_version, whl, repo): ### end of setters, below we have various functions to implement the public methods -def _set_get_index_urls(self, pip_attr): - default_index_url = pip_attr.experimental_index_url or self._config.index_url +def _set_get_index_urls(self, mctx, pip_attr): + # Resolve the index URL through envsubst so the ``$VAR`` / ``${VAR:-default}`` + # form is honored when deciding whether the experimental index-url mode is + # active. Without this, an unsubstituted template like ``$RULES_PYTHON_PIP_INDEX_URL`` + # is treated as truthy and the mode is forced on, even when the env var + # would expand to the empty string. + default_index_url = envsubst( + pip_attr.experimental_index_url, + pip_attr.envsubst, + mctx.getenv, + ) or self._config.index_url default_extra_index_urls = pip_attr.experimental_extra_index_urls or [] if not default_index_url: diff --git a/tests/pypi/hub_builder/hub_builder_tests.bzl b/tests/pypi/hub_builder/hub_builder_tests.bzl index d3ea704c27..854bbe856f 100644 --- a/tests/pypi/hub_builder/hub_builder_tests.bzl +++ b/tests/pypi/hub_builder/hub_builder_tests.bzl @@ -729,6 +729,31 @@ simple==0.0.1 --hash=sha256:deadb00f ], expect_url = "pypi.org/simple/", ), + # Regression: an unsubstituted ``$VAR`` template with the env var + # unset must expand to "" and fall back to the config default, + # rather than activating the experimental index-url path. + struct( + requirements_txt = "simple==0.0.1 --hash=sha256:deadb00f", + experimental_index_url = "$RULES_PYTHON_PIP_INDEX_URL", + experimental_extra_index_urls = [], + envsubst = ["RULES_PYTHON_PIP_INDEX_URL"], + environ = {}, + expect_index_url = "https://pypi.org/simple", + expect_extra_index_urls = [], + expect_url = "pypi.org/simple/", + ), + # When the env var is set, the resolved value drives the + # experimental index-url path. + struct( + requirements_txt = "simple==0.0.1 --hash=sha256:deadb00f", + experimental_index_url = "${RULES_PYTHON_PIP_INDEX_URL:-}", + experimental_extra_index_urls = [], + envsubst = ["RULES_PYTHON_PIP_INDEX_URL"], + environ = {"RULES_PYTHON_PIP_INDEX_URL": "https://from-env.example.com/simple"}, + expect_index_url = "https://from-env.example.com/simple", + expect_extra_index_urls = [], + expect_url = "from-env.example.com/simple/", + ), ]: got_kwargs = {} @@ -756,6 +781,7 @@ simple==0.0.1 --hash=sha256:deadb00f ) builder.pip_parse( _mock_mctx( + environ = getattr(test, "environ", {}), mock_files = { "requirements.txt": test.requirements_txt, }, @@ -765,6 +791,7 @@ simple==0.0.1 --hash=sha256:deadb00f python_version = "3.15", experimental_index_url = test.experimental_index_url, experimental_extra_index_urls = test.experimental_extra_index_urls, + envsubst = getattr(test, "envsubst", []), requirements_lock = "requirements.txt", target_platforms = [ "linux_x86_64", @@ -785,23 +812,26 @@ simple==0.0.1 --hash=sha256:deadb00f ], }, }) + want_whl_library = { + "config_load": "@pypi//:config.bzl", + "dep_template": "@pypi//{name}:{target}", + "filename": "simple-0.0.1-py3-none-any.whl", + "index_url": test.expect_index_url, + "requirement": "simple==0.0.1", + "sha256": "deadb00f", + "urls": [test.expect_url], + } + if getattr(test, "envsubst", []): + want_whl_library["envsubst"] = test.envsubst pypi.whl_libraries().contains_exactly({ - "pypi_315_simple_py3_none_any_deadb00f": { - "config_load": "@pypi//:config.bzl", - "dep_template": "@pypi//{name}:{target}", - "filename": "simple-0.0.1-py3-none-any.whl", - "index_url": test.expect_index_url, - "requirement": "simple==0.0.1", - "sha256": "deadb00f", - "urls": [test.expect_url], - }, + "pypi_315_simple_py3_none_any_deadb00f": want_whl_library, }) pypi.extra_aliases().contains_exactly({}) env.expect.that_dict(got_kwargs).contains_exactly({ "attr": struct( auth_patterns = {}, - envsubst = {}, + envsubst = getattr(test, "envsubst", []), extra_index_urls = test.expect_extra_index_urls, index_url = test.expect_index_url, index_url_overrides = {}, From a6541b5f6b2c9a26b735299148cbc6d70f153c42 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:47:42 +0900 Subject: [PATCH 016/179] refactor(pypi): don't use Python to patch wheels (#3808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Refactored patch_whl.bzl — Extracted fix_record_content() as a public pure function (no rctx dependency) so it can be unit tested directly. 2. Unit tests (patch_whl_tests.bzl) — 5 new rules_testing tests for fix_record_content: - Adds missing files to RECORD - Returns None when nothing is missing - Preserves quoting style of existing entries - Skips excluded files (INSTALLER, RECORD*, REQUESTED) - Skips .whl files 3. Integration test (patch_whl_patch_test.py) — End-to-end test that: - Creates a wheel from testdata/pkg/ via whl_from_dir_repo - Applies a patch via whl_library(whl_patches=...) - Verifies pkg.PATCHED is True (was False) and pkg.DATA is unchanged The integration test follows the established pattern: test data in tests/pypi/patch_whl/testdata/, registered in internal_dev_deps.bzl, wired into MODULE.bazel, and built as a py_test. All 205 pypi tests pass. With this we can potentially more easily separate whl and sdist path ways. --------- Co-authored-by: Richard Levasseur --- MODULE.bazel | 1 + python/private/BUILD.bazel | 1 - python/private/internal_dev_deps.bzl | 18 ++ python/private/pypi/BUILD.bazel | 7 - python/private/pypi/hub_builder.bzl | 2 +- python/private/pypi/patch_whl.bzl | 228 +++++++++++++----- python/private/pypi/repack_whl.ps1 | 7 + python/private/pypi/repack_whl.py | 200 --------------- python/private/pypi/whl_library.bzl | 8 +- tests/pypi/hub_builder/hub_builder_tests.bzl | 1 - tests/pypi/patch_whl/BUILD.bazel | 11 + tests/pypi/patch_whl/patch_whl_patch_test.py | 17 ++ tests/pypi/patch_whl/patch_whl_tests.bzl | 100 +++++++- tests/pypi/patch_whl/testdata/BUILD.bazel | 0 .../patch_whl/testdata/patches/BUILD.bazel | 1 + .../testdata/patches/modify_pkg.patch | 6 + tests/pypi/patch_whl/testdata/pkg/BUILD.bazel | 0 .../testdata/pkg/pkg-1.0.dist-info/METADATA | 2 + .../testdata/pkg/pkg-1.0.dist-info/RECORD | 4 + .../testdata/pkg/pkg-1.0.dist-info/WHEEL | 1 + tests/pypi/patch_whl/testdata/pkg/pkg.py | 2 + tests/pypi/repack_whl/BUILD.bazel | 8 - tests/pypi/repack_whl/repack_whl_test.py | 37 --- 23 files changed, 339 insertions(+), 323 deletions(-) create mode 100644 python/private/pypi/repack_whl.ps1 delete mode 100644 python/private/pypi/repack_whl.py create mode 100644 tests/pypi/patch_whl/patch_whl_patch_test.py create mode 100644 tests/pypi/patch_whl/testdata/BUILD.bazel create mode 100644 tests/pypi/patch_whl/testdata/patches/BUILD.bazel create mode 100644 tests/pypi/patch_whl/testdata/patches/modify_pkg.patch create mode 100644 tests/pypi/patch_whl/testdata/pkg/BUILD.bazel create mode 100644 tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/METADATA create mode 100644 tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/RECORD create mode 100644 tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/WHEEL create mode 100644 tests/pypi/patch_whl/testdata/pkg/pkg.py delete mode 100644 tests/pypi/repack_whl/BUILD.bazel delete mode 100644 tests/pypi/repack_whl/repack_whl_test.py diff --git a/MODULE.bazel b/MODULE.bazel index ab1c41bc09..568c2732e5 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -106,6 +106,7 @@ use_repo( "buildkite_config", "implicit_namespace_ns_sub1", "implicit_namespace_ns_sub2", + "patch_whl_pkg", "pkgutil_nspkg1", "pkgutil_nspkg2", "rules_python_runtime_env_tc_info", diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index e4b38c2d1d..133c9c6444 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -774,7 +774,6 @@ bzl_library( exports_files( [ "coverage.patch", - "repack_whl.py", "py_package.bzl", "py_wheel.bzl", "version.bzl", diff --git a/python/private/internal_dev_deps.bzl b/python/private/internal_dev_deps.bzl index b0b06c31b2..5bbee232f9 100644 --- a/python/private/internal_dev_deps.bzl +++ b/python/private/internal_dev_deps.bzl @@ -131,6 +131,24 @@ def _internal_dev_deps_impl(mctx): config_load = "@rules_python//tests/pypi/whl_library/testdata:packages.bzl", ) + # Setup for //tests/pypi/patch_whl/patch_whl_patch_test.py + whl_from_dir_repo( + name = "patch_whl_pkg_whl", + root = "//tests/pypi/patch_whl/testdata/pkg:BUILD.bazel", + output = "pkg-1.0-any-none-any.whl", + ) + whl_library( + name = "patch_whl_pkg", + whl_file = "@patch_whl_pkg_whl//:pkg-1.0-any-none-any.whl", + requirement = "pkg", + whl_patches = { + "//tests/pypi/patch_whl/testdata/patches:modify_pkg.patch": json.encode({ + "patch_strip": 0, + "whls": ["pkg-1.0-any-none-any.whl"], + }), + }, + ) + def _whl_library_from_dir(*, name, output, root, **kwargs): whl_from_dir_repo( name = "{}_whl".format(name), diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index a9ec49ca79..b9a7a18aed 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -13,7 +13,6 @@ # limitations under the License. load("@bazel_skylib//:bzl_library.bzl", "bzl_library") -load("//python:py_library.bzl", "py_library") package(default_visibility = ["//:__subpackages__"]) @@ -416,12 +415,6 @@ bzl_library( ], ) -py_library( - name = "repack_whl", - srcs = ["repack_whl.py"], - deps = ["//tools:wheelmaker"], -) - bzl_library( name = "requirements_files_by_platform_bzl", srcs = ["requirements_files_by_platform.bzl"], diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl index 6435e1656d..34b726d6f6 100644 --- a/python/private/pypi/hub_builder.bzl +++ b/python/private/pypi/hub_builder.bzl @@ -633,7 +633,7 @@ def _whl_repo( # need to pass the extra args there, so only pop this for whls args["extra_pip_args"] = src.extra_pip_args - if "whl_patches" in args or not (enable_pipstar_extract and is_whl): + if not (enable_pipstar_extract and is_whl): if interpreter.path: args["python_interpreter"] = interpreter.path if interpreter.target: diff --git a/python/private/pypi/patch_whl.bzl b/python/private/pypi/patch_whl.bzl index 71b46f6e7c..98a5ad49c8 100644 --- a/python/private/pypi/patch_whl.bzl +++ b/python/private/pypi/patch_whl.bzl @@ -12,27 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""A small utility to patch a file in the repository context and repackage it using a Python interpreter - -Note, because we are patching a wheel file and we need a new RECORD file, this -function will print a diff of the RECORD and will ask the user to include a -RECORD patch in their patches that they maintain. This is to ensure that we can -satisfy the following usecases: -* Patch an invalid RECORD file. -* Patch files within a wheel. - -If we were silently regenerating the RECORD file, we may be vulnerable to supply chain -attacks (it is a very small chance) and keeping the RECORD patches next to the -other patches ensures that the users have overview on exactly what has changed -within the wheel. +"""A small utility to patch a file in the repository context natively. + +This replaces the previous approach that depended on running Python to +repack patched wheels. Instead, after extracting and patching, we fix +the RECORD file natively in Starlark and repack using system zip. """ load("@rules_python_internal//:rules_python_config.bzl", rp_config = "config") load("//python/private:repo_utils.bzl", "repo_utils") load(":parse_whl_name.bzl", "parse_whl_name") -load(":pypi_repo_utils.bzl", "pypi_repo_utils") -_rules_python_root = Label("//:BUILD.bazel") +_DUMMY_SHA256 = "sha256=0" + +_RECORD_EXCLUDES = { + "INSTALLER": None, + "RECORD": None, + "RECORD.jws": None, + "RECORD.p7s": None, + "REQUESTED": None, +} def patched_whl_name(original_whl_name): """Return the new filename to output the patched wheel. @@ -47,14 +46,6 @@ def patched_whl_name(original_whl_name): version = parsed_whl.version suffix = "patched" if "+" in version: - # This already has some local version, so we just append one more - # identifier here. We comply with the spec and mark the file as patched - # by adding a local version identifier at the end. - # - # By doing this we can still install the package using most of the package - # managers - # - # See https://packaging.python.org/en/latest/specifications/version-specifiers/#local-version-identifiers version = "{}.{}".format(version, suffix) else: version = "{}+{}".format(version, suffix) @@ -67,23 +58,22 @@ def patched_whl_name(original_whl_name): platform_tag = parsed_whl.platform_tag, ) -def patch_whl(rctx, *, python_interpreter, whl_path, patches, **kwargs): - """Patch a whl file and repack it to ensure that the RECORD metadata stays correct. +def patch_whl(rctx, *, whl_path, patches): + """Patch a whl file and repack it natively. + + The wheel is extracted, patched, missing RECORD entries are added + with dummy sha256 and size values, and finally the wheel is + repacked using system zip. Args: rctx: repository_ctx - python_interpreter: the python interpreter to use. whl_path: The whl file name to be patched. patches: a label-keyed-int dict that has the patch files as keys and the patch_strip as the value. - **kwargs: extras passed to repo_utils.execute_checked. Returns: value of the repackaging action. """ - - # extract files into the current directory for patching as rctx.patch - # does not support patching in another directory. whl_input = rctx.path(whl_path) repo_utils.extract( @@ -98,43 +88,159 @@ def patch_whl(rctx, *, python_interpreter, whl_path, patches, **kwargs): for patch_file, patch_strip in patches.items(): rctx.patch(patch_file, strip = patch_strip) - record_patch = rctx.path("RECORD.patch") + _fix_record(rctx) + whl_patched = patched_whl_name(whl_input.basename) - pypi_repo_utils.execute_checked( + rctx.delete(whl_input) + _repack_whl(rctx, output = whl_patched) + + return rctx.path(whl_patched) + +def _fix_record(rctx): + """Add missing file entries to RECORD with dummy sha256 and file size.""" + for entry in rctx.path(".").readdir(): + if not entry.basename.endswith(".dist-info"): + continue + + record_path = entry.get_child("RECORD") + if not record_path.exists: + continue + + all_files = _collect_files(rctx) + record_rel = repo_utils.repo_root_relative_path(rctx, record_path) + + new_content = fix_record_content( + record_content = rctx.read(record_path), + all_files = all_files, + record_rel = record_rel, + ) + if new_content != None: + rctx.file(record_path, new_content) + +def fix_record_content(record_content, all_files, record_rel): + """Add missing file entries to RECORD content with dummy sha256 and size. + + Args: + record_content: {type}`str` The existing RECORD file content. + all_files: {type}`dict[str, bool]` All files in the directory, keys + are repo-root-relative paths. + record_rel: {type}`str` The repo-root-relative path to the RECORD file. + + Returns: + {type}`str | None` The new RECORD content if entries were added, + or None if no changes were needed. + """ + has_trailing_newline = record_content.endswith("\n") + lines = record_content.split("\n") + + is_all_quoted = True + has_content = False + existing = {} + for line in lines: + stripped = line.strip() + if not stripped: + continue + has_content = True + if not stripped.startswith('"'): + is_all_quoted = False + parts = stripped.split(",") + if len(parts) >= 1: + fname = parts[0].strip('"') + existing[fname] = True + + if not has_content: + is_all_quoted = False + + existing[record_rel] = True + + added = [] + for fpath in sorted(all_files.keys()): + if fpath in existing: + continue + basename = fpath.split("/")[-1] if "/" in fpath else fpath + if basename in _RECORD_EXCLUDES: + continue + if fpath.endswith(".whl"): + continue + added.append(fpath) + + if not added: + return None + + new_lines = list(lines) + if not has_trailing_newline: + new_lines.append("") + if is_all_quoted: + for fpath in added: + new_lines.append('"{file}",{sha256},0'.format( + file = fpath, + sha256 = _DUMMY_SHA256, + )) + else: + for fpath in added: + new_lines.append("{file},{sha256},0".format( + file = fpath, + sha256 = _DUMMY_SHA256, + )) + new_lines.append("") + + return "\n".join(new_lines) + +def _collect_files(rctx): + """Collect all file paths relative to the repo root (iterative).""" + result = {} + paths = [(rctx.path("."), "")] + for _ in range(10000000): + if not paths: + break + path, prefix = paths.pop() + for entry in path.readdir(): + rel = prefix + "/" + entry.basename if prefix else entry.basename + if entry.is_dir: + paths.append((entry, rel)) + else: + result[rel] = True + return result + +def _repack_whl(rctx, *, output): + """Repack the current directory into a wheel file using system zip.""" + os_name = repo_utils.get_platforms_os_name(rctx) + if os_name == "windows": + _repack_whl_windows(rctx, output = output) + else: + _repack_whl_unix(rctx, output = output) + +def _repack_whl_unix(rctx, *, output): + repo_utils.execute_checked( rctx, - python = python_interpreter, - srcs = [ - Label("//python/private/pypi:repack_whl.py"), - Label("//tools:wheelmaker.py"), - ], + op = "PatchWhl", arguments = [ - "-m", - "python.private.pypi.repack_whl", - "--record-patch", - record_patch, - whl_input, - whl_patched, + "zip", + "-0", + "-X", + str(output), + "-r", + ".", ], - environment = { - "PYTHONPATH": str(rctx.path(_rules_python_root).dirname), - }, - **kwargs ) - if record_patch.exists: - record_patch_contents = rctx.read(record_patch) - warning_msg = """WARNING: the resultant RECORD file of the patch wheel is different +def _repack_whl_windows(rctx, *, output): + powershell_exe = rctx.which("powershell.exe") or rctx.which("powershell") + if not powershell_exe: + fail("powershell not found on PATH") - If you are patching on Windows, you may see this warning because of - a known issue (bazel-contrib/rules_python#1639) with file endings. + script_path = rctx.path(Label("//python/private/pypi:repack_whl.ps1")) - If you would like to silence the warning, you can apply the patch that is stored in - {record_patch}. The contents of the file are below: -{record_patch_contents}""".format( - record_patch = record_patch, - record_patch_contents = record_patch_contents, - ) - print(warning_msg) # buildifier: disable=print - - return rctx.path(whl_patched) + repo_utils.execute_checked( + rctx, + op = "PatchWhl", + arguments = [ + powershell_exe, + "-NoProfile", + "-File", + str(script_path), + "-Output", + str(output), + ], + ) diff --git a/python/private/pypi/repack_whl.ps1 b/python/private/pypi/repack_whl.ps1 new file mode 100644 index 0000000000..2f3b4ada0f --- /dev/null +++ b/python/private/pypi/repack_whl.ps1 @@ -0,0 +1,7 @@ +param( + [string]$Output +) + +$files = Get-ChildItem -Path . -Exclude 'tmp.zip', $Output +Compress-Archive -Path $files -DestinationPath 'tmp.zip' -Force +Move-Item -Path 'tmp.zip' -Destination $Output -Force diff --git a/python/private/pypi/repack_whl.py b/python/private/pypi/repack_whl.py deleted file mode 100644 index 4bf554cdc9..0000000000 --- a/python/private/pypi/repack_whl.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright 2023 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. - -""" -Regenerate a whl file after patching and cleanup the patched contents. - -This script will take contents of the current directory and create a new wheel -out of it and will remove all files that were written to the wheel. -""" - -from __future__ import annotations - -import argparse -import csv -import difflib -import logging -import pathlib -import sys -import tempfile - -from tools.wheelmaker import _WhlFile - -# NOTE: Implement the following matching of what goes into the RECORD -# https://peps.python.org/pep-0491/#the-dist-info-directory -_EXCLUDES = [ - "RECORD", - "INSTALLER", - "RECORD.jws", - "RECORD.p7s", - "REQUESTED", -] - -_DISTINFO = "dist-info" - - -def _has_all_quoted_filenames(record_contents: str) -> bool: - """Check if all filenames in the RECORD are quoted. - - Some wheels (like torch) have all filenames quoted in their RECORD file. - We detect this to preserve the quoting style when repacking. - """ - lines = record_contents.splitlines() - return all(line.startswith('"') for line in lines) - - -def _unidiff_output(expected, actual, record): - """ - Helper function. Returns a string containing the unified diff of two - multiline strings. - """ - - expected = expected.splitlines(1) - actual = actual.splitlines(1) - - diff = difflib.unified_diff( - expected, actual, fromfile=f"a/{record}", tofile=f"b/{record}" - ) - - return "".join(diff) - - -def _files_to_pack(dir: pathlib.Path, want_record: str) -> list[pathlib.Path]: - """Check that the RECORD file entries are correct and print a unified diff on failure.""" - - # First get existing files by using the RECORD file - got_files = [] - got_distinfos = [] - for row in csv.reader(want_record.splitlines()): - rec = row[0] - path = dir / rec - - if not path.exists(): - # skip files that do not exist as they won't be present in the final - # RECORD file. - continue - - if not path.parent.name.endswith(_DISTINFO): - got_files.append(path) - elif path.name not in _EXCLUDES: - got_distinfos.append(path) - - # Then get extra files present in the directory but not in the RECORD file - extra_files = [] - extra_distinfos = [] - for path in dir.rglob("*"): - if path.is_dir(): - continue - - elif path.parent.name.endswith(_DISTINFO): - if path.name in _EXCLUDES: - # NOTE: we implement the following matching of what goes into the RECORD - # https://peps.python.org/pep-0491/#the-dist-info-directory - continue - elif path not in got_distinfos: - extra_distinfos.append(path) - - elif path not in got_files: - extra_files.append(path) - - # sort the extra files for reproducibility - extra_files.sort() - extra_distinfos.sort() - - # This order ensures that the structure of the RECORD file is always the - # same and ensures smaller patchsets to the RECORD file in general - return got_files + extra_files + got_distinfos + extra_distinfos - - -def main(sys_argv): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "whl_path", - type=pathlib.Path, - help="The original wheel file that we have patched.", - ) - parser.add_argument( - "--record-patch", - type=pathlib.Path, - help="The output path that we are going to write the RECORD file patch to.", - ) - parser.add_argument( - "output", - type=pathlib.Path, - help="The output path that we are going to write a new file to.", - ) - args = parser.parse_args(sys_argv) - - cwd = pathlib.Path.cwd() - logging.debug("=" * 80) - logging.debug("Repackaging the wheel") - logging.debug("=" * 80) - - with tempfile.TemporaryDirectory(dir=cwd) as tmpdir: - patched_wheel_dir = cwd / tmpdir - logging.debug(f"Created a tmpdir: {patched_wheel_dir}") - - excludes = [args.whl_path, patched_wheel_dir] - - logging.debug("Moving whl contents to the newly created tmpdir") - for p in cwd.glob("*"): - if p in excludes: - logging.debug(f"Ignoring: {p}") - continue - - rel_path = p.relative_to(cwd) - dst = p.rename(patched_wheel_dir / rel_path) - logging.debug(f"mv {p} -> {dst}") - - distinfo_dir = next(iter(patched_wheel_dir.glob("*dist-info"))) - logging.debug(f"Found dist-info dir: {distinfo_dir}") - record_path = distinfo_dir / "RECORD" - record_contents = record_path.read_text() if record_path.exists() else "" - quote_files = _has_all_quoted_filenames(record_contents) - distribution_prefix = distinfo_dir.with_suffix("").name - - with _WhlFile( - args.output, - mode="w", - distribution_prefix=distribution_prefix, - quote_all_filenames=quote_files, - ) as out: - for p in _files_to_pack(patched_wheel_dir, record_contents): - rel_path = p.relative_to(patched_wheel_dir) - out.add_file(str(rel_path), p) - - logging.debug("Writing RECORD file") - got_record = out.add_recordfile() - - if got_record == record_contents: - logging.info(f"Created a whl file: {args.output}") - return - - record_diff = _unidiff_output( - record_contents, - got_record, - out.distinfo_path("RECORD"), - ) - args.record_patch.write_text(record_diff) - logging.warning( - f"Please apply patch to the RECORD file ({args.record_patch}):\n{record_diff}" - ) - - -if __name__ == "__main__": - logging.basicConfig( - format="%(module)s: %(levelname)s: %(message)s", level=logging.DEBUG - ) - - sys.exit(main(sys.argv[1:])) diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl index 966d25d04d..37cc36492e 100644 --- a/python/private/pypi/whl_library.bzl +++ b/python/private/pypi/whl_library.bzl @@ -359,10 +359,10 @@ def _whl_library_impl(rctx): # build deps from PyPI (e.g. `flit_core`) if they are missing. extra_pip_args.extend(["--find-links", "."]) - # When we already have a wheel and there are no patches, Python isn't used, + # When we already have a wheel, Python isn't used, # so there's no need to setup env vars to run Python, unless we need to # build an sdist or resolve a requirement. - if whl_path and not rctx.attr.whl_patches: + if whl_path: environment = {} args = [] python_interpreter = None @@ -420,12 +420,8 @@ def _whl_library_impl(rctx): if patches: whl_path = patch_whl( rctx, - op = "whl_library.PatchWhl({}, {})".format(rctx.attr.name, rctx.attr.requirement), - python_interpreter = python_interpreter, whl_path = whl_path, patches = patches, - quiet = rctx.attr.quiet, - timeout = rctx.attr.timeout, ) whl_extract(rctx, whl_path = whl_path, logger = logger) diff --git a/tests/pypi/hub_builder/hub_builder_tests.bzl b/tests/pypi/hub_builder/hub_builder_tests.bzl index 854bbe856f..60017593fb 100644 --- a/tests/pypi/hub_builder/hub_builder_tests.bzl +++ b/tests/pypi/hub_builder/hub_builder_tests.bzl @@ -1170,7 +1170,6 @@ git_dep @ git+https://git.server/repo/project@deadbeefdeadbeef "config_load": "@pypi//:config.bzl", "dep_template": "@pypi//{name}:{target}", "filename": "direct_without_sha-0.0.1-py3-none-any.whl", - "python_interpreter_target": "unit_test_interpreter_target", "requirement": "direct_without_sha==0.0.1", "sha256": "", "urls": ["example-direct.org/direct_without_sha-0.0.1-py3-none-any.whl"], diff --git a/tests/pypi/patch_whl/BUILD.bazel b/tests/pypi/patch_whl/BUILD.bazel index d6c4f47b36..cfdc3cc12a 100644 --- a/tests/pypi/patch_whl/BUILD.bazel +++ b/tests/pypi/patch_whl/BUILD.bazel @@ -1,3 +1,14 @@ +load("//python:py_test.bzl", "py_test") +load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") load(":patch_whl_tests.bzl", "patch_whl_test_suite") patch_whl_test_suite(name = "patch_whl_tests") + +py_test( + name = "patch_whl_patch_test", + srcs = ["patch_whl_patch_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, + deps = [ + "@patch_whl_pkg//:pkg", + ], +) diff --git a/tests/pypi/patch_whl/patch_whl_patch_test.py b/tests/pypi/patch_whl/patch_whl_patch_test.py new file mode 100644 index 0000000000..56dfe1aa55 --- /dev/null +++ b/tests/pypi/patch_whl/patch_whl_patch_test.py @@ -0,0 +1,17 @@ +"""Test that wheel patching works end-to-end.""" + +import unittest + +import pkg + + +class PatchWhlTest(unittest.TestCase): + def test_patched(self): + self.assertEqual(pkg.PATCHED, True) + + def test_data_unchanged(self): + self.assertEqual(pkg.DATA, "hello") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pypi/patch_whl/patch_whl_tests.bzl b/tests/pypi/patch_whl/patch_whl_tests.bzl index f93fe459c9..5010f03dc0 100644 --- a/tests/pypi/patch_whl/patch_whl_tests.bzl +++ b/tests/pypi/patch_whl/patch_whl_tests.bzl @@ -15,7 +15,7 @@ "" load("@rules_testing//lib:test_suite.bzl", "test_suite") -load("//python/private/pypi:patch_whl.bzl", "patched_whl_name") # buildifier: disable=bzl-visibility +load("//python/private/pypi:patch_whl.bzl", "fix_record_content", "patched_whl_name") # buildifier: disable=bzl-visibility _tests = [] @@ -31,6 +31,104 @@ def _test_simple_local_version(env): _tests.append(_test_simple_local_version) +def _test_fix_record_adds_missing(env): + record = """\ +foo/__init__.py,sha256=abc,123 +foo-1.0.dist-info/RECORD,, +""" + all_files = { + "foo-1.0.dist-info/METADATA": True, + "foo-1.0.dist-info/RECORD": True, + "foo/__init__.py": True, + "foo/bar.py": True, + } + result = fix_record_content( + record_content = record, + all_files = all_files, + record_rel = "foo-1.0.dist-info/RECORD", + ) + env.expect.that_str(result).contains("foo/bar.py,sha256=0,0") + env.expect.that_str(result).contains("foo-1.0.dist-info/METADATA,sha256=0,0") + +_tests.append(_test_fix_record_adds_missing) + +def _test_fix_record_no_missing(env): + record = """\ +foo/__init__.py,sha256=abc,123 +foo/bar.py,sha256=def,456 +foo-1.0.dist-info/RECORD,, +""" + all_files = { + "foo-1.0.dist-info/RECORD": True, + "foo/__init__.py": True, + "foo/bar.py": True, + } + result = fix_record_content( + record_content = record, + all_files = all_files, + record_rel = "foo-1.0.dist-info/RECORD", + ) + env.expect.that_bool(result == None).equals(True) + +_tests.append(_test_fix_record_no_missing) + +def _test_fix_record_preserves_quoting(env): + record = '''\ +"foo/__init__.py",sha256=abc,123 +"foo-1.0.dist-info/RECORD",, +''' + all_files = { + "foo-1.0.dist-info/RECORD": True, + "foo/__init__.py": True, + "foo/bar.py": True, + } + result = fix_record_content( + record_content = record, + all_files = all_files, + record_rel = "foo-1.0.dist-info/RECORD", + ) + env.expect.that_str(result).contains('"foo/bar.py",sha256=0,0') + +_tests.append(_test_fix_record_preserves_quoting) + +def _test_fix_record_skips_excluded(env): + record = """\ +foo/__init__.py,sha256=abc,123 +foo-1.0.dist-info/RECORD,, +""" + all_files = { + "foo-1.0.dist-info/INSTALLER": True, + "foo-1.0.dist-info/RECORD": True, + "foo/__init__.py": True, + } + result = fix_record_content( + record_content = record, + all_files = all_files, + record_rel = "foo-1.0.dist-info/RECORD", + ) + env.expect.that_bool(result == None).equals(True) + +_tests.append(_test_fix_record_skips_excluded) + +def _test_fix_record_skips_whl_files(env): + record = """\ +foo/__init__.py,sha256=abc,123 +foo-1.0.dist-info/RECORD,, +""" + all_files = { + "foo-1.0.dist-info/RECORD": True, + "foo-1.0.whl": True, + "foo/__init__.py": True, + } + result = fix_record_content( + record_content = record, + all_files = all_files, + record_rel = "foo-1.0.dist-info/RECORD", + ) + env.expect.that_bool(result == None).equals(True) + +_tests.append(_test_fix_record_skips_whl_files) + def patch_whl_test_suite(name): """Create the test suite. diff --git a/tests/pypi/patch_whl/testdata/BUILD.bazel b/tests/pypi/patch_whl/testdata/BUILD.bazel new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/pypi/patch_whl/testdata/patches/BUILD.bazel b/tests/pypi/patch_whl/testdata/patches/BUILD.bazel new file mode 100644 index 0000000000..85f0d05223 --- /dev/null +++ b/tests/pypi/patch_whl/testdata/patches/BUILD.bazel @@ -0,0 +1 @@ +exports_files(["modify_pkg.patch"]) diff --git a/tests/pypi/patch_whl/testdata/patches/modify_pkg.patch b/tests/pypi/patch_whl/testdata/patches/modify_pkg.patch new file mode 100644 index 0000000000..ba8cca54b5 --- /dev/null +++ b/tests/pypi/patch_whl/testdata/patches/modify_pkg.patch @@ -0,0 +1,6 @@ +--- pkg.py ++++ pkg.py +@@ -1,2 +1,2 @@ +-PATCHED = False ++PATCHED = True + DATA = "hello" diff --git a/tests/pypi/patch_whl/testdata/pkg/BUILD.bazel b/tests/pypi/patch_whl/testdata/pkg/BUILD.bazel new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/METADATA b/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/METADATA new file mode 100644 index 0000000000..a102dd73d4 --- /dev/null +++ b/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/METADATA @@ -0,0 +1,2 @@ +Name: pkg +Version: 1.0 diff --git a/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/RECORD b/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/RECORD new file mode 100644 index 0000000000..dba333a1c1 --- /dev/null +++ b/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/RECORD @@ -0,0 +1,4 @@ +pkg.py,sha256=abc,123 +pkg-1.0.dist-info/METADATA,sha256=def,456 +pkg-1.0.dist-info/WHEEL,sha256=ghi,789 +pkg-1.0.dist-info/RECORD,, diff --git a/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/WHEEL b/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/WHEEL new file mode 100644 index 0000000000..a64521a1cc --- /dev/null +++ b/tests/pypi/patch_whl/testdata/pkg/pkg-1.0.dist-info/WHEEL @@ -0,0 +1 @@ +Wheel-Version: 1.0 diff --git a/tests/pypi/patch_whl/testdata/pkg/pkg.py b/tests/pypi/patch_whl/testdata/pkg/pkg.py new file mode 100644 index 0000000000..0985d5a3dd --- /dev/null +++ b/tests/pypi/patch_whl/testdata/pkg/pkg.py @@ -0,0 +1,2 @@ +PATCHED = False +DATA = "hello" diff --git a/tests/pypi/repack_whl/BUILD.bazel b/tests/pypi/repack_whl/BUILD.bazel deleted file mode 100644 index 3f611a2e4f..0000000000 --- a/tests/pypi/repack_whl/BUILD.bazel +++ /dev/null @@ -1,8 +0,0 @@ -load("//python:py_test.bzl", "py_test") - -py_test( - name = "repack_whl_test", - size = "small", - srcs = ["repack_whl_test.py"], - deps = ["//python/private/pypi:repack_whl"], -) diff --git a/tests/pypi/repack_whl/repack_whl_test.py b/tests/pypi/repack_whl/repack_whl_test.py deleted file mode 100644 index 50781cc0e6..0000000000 --- a/tests/pypi/repack_whl/repack_whl_test.py +++ /dev/null @@ -1,37 +0,0 @@ -import unittest - -from python.private.pypi import repack_whl - - -class HasAllQuotedFilenamesTest(unittest.TestCase): - """Tests for _has_all_quoted_filenames detection logic.""" - - def test_all_quoted(self) -> None: - """Returns True when all lines start with quotes (torch-style).""" - record = """\ -"torch/__init__.py",sha256=abc,123 -"torch/utils.py",sha256=def,456 -"torch-2.0.0.dist-info/WHEEL",sha256=ghi,789 -""" - self.assertTrue(repack_whl._has_all_quoted_filenames(record)) - - def test_none_quoted(self) -> None: - """Returns False when no lines are quoted (standard style).""" - record = """\ -torch/__init__.py,sha256=abc,123 -torch/utils.py,sha256=def,456 -torch-2.0.0.dist-info/WHEEL,sha256=ghi,789 -""" - self.assertFalse(repack_whl._has_all_quoted_filenames(record)) - - def test_mixed_quoting(self) -> None: - """Returns False when only some lines are quoted.""" - record = """\ -"file,with,commas.py",sha256=abc,123 -normal_file.py,sha256=def,456 -""" - self.assertFalse(repack_whl._has_all_quoted_filenames(record)) - - -if __name__ == "__main__": - unittest.main() From de0405ad3aeac4bdaf45ab848b6897de8611d28d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 19 Jun 2026 17:51:13 -0700 Subject: [PATCH 017/179] feat(py_info): expose VenvSymlinkEntry and VenvSymlinkKind as public APIs (#3835) Currently, `VenvSymlinkEntry` and `VenvSymlinkKind` are defined in the private `python/private/py_info.bzl` file, making them inaccessible to users who want to programmatically define virtual environment symlinks when using `PyInfo` (or at the least, having to load private file paths). To fix this, we load and re-export both symbols in the public `python/py_info.bzl` file. Along the way * Adds `VenvSymlinkEntryBuilder` to allow fluent construction of symlink entries via PyInfoBuilder. * Adds `features.loadable_symbols` to allow programmatic detection of these public symbols. --- docs/BUILD.bazel | 1 + news/expose-venv-symlink.added.md | 2 + news/loadable-symbols.added.md | 2 + python/features.bzl | 19 ++ python/private/py_info.bzl | 260 +++++++++++++++++- python/py_info.bzl | 13 +- tests/base_rules/py_info/py_info_tests.bzl | 23 +- .../app_files_building_tests.bzl | 3 +- 8 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 news/expose-venv-symlink.added.md create mode 100644 news/loadable-symbols.added.md diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 67f21b253b..94cb588018 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -94,6 +94,7 @@ sphinx_stardocs( "//python:py_exec_tools_info_bzl", "//python:py_exec_tools_toolchain_bzl", "//python:py_executable_info_bzl", + "//python:py_info_bzl", "//python:py_library_bzl", "//python:py_runtime_bzl", "//python:py_runtime_info_bzl", diff --git a/news/expose-venv-symlink.added.md b/news/expose-venv-symlink.added.md new file mode 100644 index 0000000000..e339bb5d3f --- /dev/null +++ b/news/expose-venv-symlink.added.md @@ -0,0 +1,2 @@ +Exposed {bzl:obj}`VenvSymlinkEntry` and {bzl:obj}`VenvSymlinkKind` in +{bzl:target}`//python:py_info.bzl`. diff --git a/news/loadable-symbols.added.md b/news/loadable-symbols.added.md new file mode 100644 index 0000000000..7d92df7aea --- /dev/null +++ b/news/loadable-symbols.added.md @@ -0,0 +1,2 @@ +Added {bzl:obj}`features.loadable_symbols` to allow detecting public symbols +exported by bzl files. diff --git a/python/features.bzl b/python/features.bzl index a2cb95f1ee..fab44385c8 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -81,6 +81,15 @@ def _features_typedef(): :::{versionadded} 1.9.0 :::: + + ::::{field} loadable_symbols + :type: dict[str, list[str]] + + A map of bzl paths to the list of public symbols they export. + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + :::: """ _TARGETS = { @@ -90,10 +99,20 @@ _TARGETS = { "//python/cc:current_py_cc_headers_abi3": True, } +_LOADABLE_SYMBOLS = { + "//python:py_info.bzl": [ + # keep sorted + "PyInfo", + "VenvSymlinkEntry", + "VenvSymlinkKind", + ], +} + features = struct( TYPEDEF = _features_typedef, # keep sorted headers_abi3 = True, + loadable_symbols = _LOADABLE_SYMBOLS, precompile = True, py_info_venv_symlinks = True, targets = _TARGETS, diff --git a/python/private/py_info.bzl b/python/private/py_info.bzl index 551e0bb48c..da9421606a 100644 --- a/python/private/py_info.bzl +++ b/python/private/py_info.bzl @@ -116,6 +116,240 @@ PEP440 standard. }, ) +def _VenvSymlinkEntryBuilder_typedef(): + """Builder for VenvSymlinkEntry. + + To create an instance, use {obj}`PyInfoBuilder.add_venv_symlink()`. + + :::{field} files + :type: DepsetBuilder[File] + ::: + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + """ + +def _VenvSymlinkEntryBuilder(): + # buildifier: disable=uninitialized + self = struct( + _state = { + "kind": None, + "link_to_file": None, + "link_to_path": None, + "package": None, + "venv_path": None, + "version": None, + }, + files = builders.DepsetBuilder(), + get_kind = lambda: _VenvSymlinkEntryBuilder_get_kind(self), + set_kind = lambda k: _VenvSymlinkEntryBuilder_set_kind(self, k), + get_link_to_file = lambda: _VenvSymlinkEntryBuilder_get_link_to_file(self), + set_link_to_file = lambda f: _VenvSymlinkEntryBuilder_set_link_to_file(self, f), + get_link_to_path = lambda: _VenvSymlinkEntryBuilder_get_link_to_path(self), + set_link_to_path = lambda p: _VenvSymlinkEntryBuilder_set_link_to_path(self, p), + get_package = lambda: _VenvSymlinkEntryBuilder_get_package(self), + set_package = lambda p: _VenvSymlinkEntryBuilder_set_package(self, p), + get_venv_path = lambda: _VenvSymlinkEntryBuilder_get_venv_path(self), + set_venv_path = lambda p: _VenvSymlinkEntryBuilder_set_venv_path(self, p), + get_version = lambda: _VenvSymlinkEntryBuilder_get_version(self), + set_version = lambda v: _VenvSymlinkEntryBuilder_set_version(self, v), + build = lambda: _VenvSymlinkEntryBuilder_build(self), + ) + return self + +def _VenvSymlinkEntryBuilder_get_kind(self): + """Get the kind of the symlink. + + Args: + self: implicitly added. + + Returns: + {type}`string` One of the {obj}`VenvSymlinkKind` values. + """ + return self._state["kind"] + +def _VenvSymlinkEntryBuilder_get_link_to_file(self): + """Get the file that the symlink points to. + + Args: + self: implicitly added. + + Returns: + {type}`File | None` + """ + return self._state["link_to_file"] + +def _VenvSymlinkEntryBuilder_get_link_to_path(self): + """Get the runfiles-root relative path that the symlink points to. + + Args: + self: implicitly added. + + Returns: + {type}`string | None` + """ + return self._state["link_to_path"] + +def _VenvSymlinkEntryBuilder_get_package(self): + """Get the PyPI package name that the code originates from. + + Args: + self: implicitly added. + + Returns: + {type}`string | None` + """ + return self._state["package"] + +def _VenvSymlinkEntryBuilder_get_venv_path(self): + """Get the path relative to the kind directory within the venv. + + Args: + self: implicitly added. + + Returns: + {type}`string` + """ + return self._state["venv_path"] + +def _VenvSymlinkEntryBuilder_get_version(self): + """Get the PyPI package version that the code originates from. + + Args: + self: implicitly added. + + Returns: + {type}`string | None` + """ + return self._state["version"] + +def _VenvSymlinkEntryBuilder_set_kind(self, kind): + """Set the kind of the symlink. + + Args: + self: implicitly added. + kind: {type}`string` One of the {obj}`VenvSymlinkKind` values. + + Returns: + {type}`VenvSymlinkEntryBuilder` self. + """ + _check_arg_type("kind", "string", kind) + self._state["kind"] = kind + return self + +def _VenvSymlinkEntryBuilder_set_link_to_file(self, link_to_file): + """Set the file that the symlink points to. + + Args: + self: implicitly added. + link_to_file: {type}`File | None` + + Returns: + {type}`VenvSymlinkEntryBuilder` self. + """ + if link_to_file != None: + _check_arg_type("link_to_file", "File", link_to_file) + self._state["link_to_file"] = link_to_file + return self + +def _VenvSymlinkEntryBuilder_set_link_to_path(self, link_to_path): + """Set the runfiles-root relative path that the symlink points to. + + Args: + self: implicitly added. + link_to_path: {type}`string | None` + + Returns: + {type}`VenvSymlinkEntryBuilder` self. + """ + if link_to_path != None: + _check_arg_type("link_to_path", "string", link_to_path) + self._state["link_to_path"] = link_to_path + return self + +def _VenvSymlinkEntryBuilder_set_package(self, package): + """Set the PyPI package name that the code originates from. + + Args: + self: implicitly added. + package: {type}`string | None` + + Returns: + {type}`VenvSymlinkEntryBuilder` self. + """ + if package != None: + _check_arg_type("package", "string", package) + self._state["package"] = package + return self + +def _VenvSymlinkEntryBuilder_set_venv_path(self, venv_path): + """Set the path relative to the kind directory within the venv. + + Args: + self: implicitly added. + venv_path: {type}`string` + + Returns: + {type}`VenvSymlinkEntryBuilder` self. + """ + _check_arg_type("venv_path", "string", venv_path) + self._state["venv_path"] = venv_path + return self + +def _VenvSymlinkEntryBuilder_set_version(self, version): + """Set the PyPI package version that the code originates from. + + Args: + self: implicitly added. + version: {type}`string | None` + + Returns: + {type}`VenvSymlinkEntryBuilder` self. + """ + if version != None: + _check_arg_type("version", "string", version) + self._state["version"] = version + return self + +def _VenvSymlinkEntryBuilder_build(self): + """Builds into a {obj}`VenvSymlinkEntry` object. + + Args: + self: implicitly added. + + Returns: + {type}`VenvSymlinkEntry` + """ + if not self._state["venv_path"]: + fail("venv_path must be set") + return VenvSymlinkEntry( + files = self.files.build(), + kind = self._state["kind"], + link_to_file = self._state["link_to_file"], + link_to_path = self._state["link_to_path"], + package = self._state["package"], + venv_path = self._state["venv_path"], + version = self._state["version"], + ) + +# buildifier: disable=name-conventions +VenvSymlinkEntryBuilder = struct( + TYPEDEF = _VenvSymlinkEntryBuilder_typedef, + build = _VenvSymlinkEntryBuilder_build, + get_kind = _VenvSymlinkEntryBuilder_get_kind, + set_kind = _VenvSymlinkEntryBuilder_set_kind, + get_link_to_file = _VenvSymlinkEntryBuilder_get_link_to_file, + set_link_to_file = _VenvSymlinkEntryBuilder_set_link_to_file, + get_link_to_path = _VenvSymlinkEntryBuilder_get_link_to_path, + set_link_to_path = _VenvSymlinkEntryBuilder_set_link_to_path, + get_package = _VenvSymlinkEntryBuilder_get_package, + set_package = _VenvSymlinkEntryBuilder_set_package, + get_venv_path = _VenvSymlinkEntryBuilder_get_venv_path, + set_venv_path = _VenvSymlinkEntryBuilder_set_venv_path, + get_version = _VenvSymlinkEntryBuilder_get_version, + set_version = _VenvSymlinkEntryBuilder_set_version, +) + def _check_arg_type(name, required_type, value): """Check that a value is of an expected type.""" value_type = type(value) @@ -409,6 +643,8 @@ def _PyInfoBuilder_new(): _has_py2_only_sources = [False], _has_py3_only_sources = [False], _uses_shared_libraries = [False], + _venv_symlink_builders = [], + add_venv_symlink = lambda *a, **k: _PyInfoBuilder_add_venv_symlink(self, *a, **k), build = lambda *a, **k: _PyInfoBuilder_build(self, *a, **k), build_builtin_py_info = lambda *a, **k: _PyInfoBuilder_build_builtin_py_info(self, *a, **k), direct_original_sources = builders.DepsetBuilder(), @@ -438,6 +674,22 @@ def _PyInfoBuilder_new(): ) return self +def _PyInfoBuilder_add_venv_symlink(self): + """Create and return a new VenvSymlinkEntryBuilder. + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + + Args: + self: implicitly added. + + Returns: + {type}`VenvSymlinkEntryBuilder` + """ + entry_builder = _VenvSymlinkEntryBuilder() + self._venv_symlink_builders.append(entry_builder) + return entry_builder + def _PyInfoBuilder_get_has_py3_only_sources(self): """Get the `has_py3_only_sources` value. @@ -649,6 +901,11 @@ def _PyInfoBuilder_build(self): Returns: {type}`PyInfo` """ + venv_symlinks = depset( + direct = [b.build() for b in self._venv_symlink_builders], + transitive = [self.venv_symlinks.build()], + ) + return _EffectivePyInfo( has_py2_only_sources = self._has_py2_only_sources[0], has_py3_only_sources = self._has_py3_only_sources[0], @@ -663,7 +920,7 @@ def _PyInfoBuilder_build(self): transitive_original_sources = self.transitive_original_sources.build(), transitive_pyc_files = self.transitive_pyc_files.build(), transitive_pyi_files = self.transitive_pyi_files.build(), - venv_symlinks = self.venv_symlinks.build(), + venv_symlinks = venv_symlinks, ) def _PyInfoBuilder_build_builtin_py_info(self): @@ -692,6 +949,7 @@ def _PyInfoBuilder_build_builtin_py_info(self): PyInfoBuilder = struct( TYPEDEF = _PyInfoBuilder_typedef, new = _PyInfoBuilder_new, + add_venv_symlink = _PyInfoBuilder_add_venv_symlink, build = _PyInfoBuilder_build, build_builtin_py_info = _PyInfoBuilder_build_builtin_py_info, get_has_py2_only_sources = _PyInfoBuilder_get_has_py2_only_sources, diff --git a/python/py_info.bzl b/python/py_info.bzl index 350a4dbd9e..8e46926c53 100644 --- a/python/py_info.bzl +++ b/python/py_info.bzl @@ -14,6 +14,17 @@ """Public entry point for PyInfo.""" -load("//python/private:py_info.bzl", _PyInfo = "PyInfo") +load( + "//python/private:py_info.bzl", + _PyInfo = "PyInfo", + _VenvSymlinkEntry = "VenvSymlinkEntry", + _VenvSymlinkKind = "VenvSymlinkKind", +) PyInfo = _PyInfo + +# buildifier: disable=name-conventions +VenvSymlinkEntry = _VenvSymlinkEntry + +# buildifier: disable=name-conventions +VenvSymlinkKind = _VenvSymlinkKind diff --git a/tests/base_rules/py_info/py_info_tests.bzl b/tests/base_rules/py_info/py_info_tests.bzl index 273959b957..0e1a96548c 100644 --- a/tests/base_rules/py_info/py_info_tests.bzl +++ b/tests/base_rules/py_info/py_info_tests.bzl @@ -16,7 +16,7 @@ load("@rules_testing//lib:analysis_test.bzl", "analysis_test") load("@rules_testing//lib:test_suite.bzl", "test_suite") load("@rules_testing//lib:util.bzl", rt_util = "util") -load("//python:py_info.bzl", "PyInfo") +load("//python:py_info.bzl", "PyInfo", "VenvSymlinkKind") load("//python/private:py_info.bzl", "PyInfoBuilder") # buildifier: disable=bzl-visibility load("//python/private:reexports.bzl", "BuiltinPyInfo") # buildifier: disable=bzl-visibility load("//tests/support:py_info_subject.bzl", "py_info_subject") @@ -170,6 +170,12 @@ def _test_py_info_builder_impl(env, targets): builder.transitive_sources.add(trans) builder.merge_uses_shared_libraries(True) + symlink = builder.add_venv_symlink() + symlink.set_kind(VenvSymlinkKind.LIB) + symlink.set_venv_path("test_path") + symlink.set_link_to_path("test_target") + symlink.files.add(trans) + builder.merge_target(targets.py1) builder.merge_targets([targets.py2]) @@ -247,6 +253,21 @@ def _test_py_info_builder_impl(env, targets): "tests/base_rules/py_info/py6-trans.pyi", ]) + if hasattr(actual, "venv_symlinks"): + entries = actual.venv_symlinks.to_list() + env.expect.that_int(len(entries)).equals(1) + entry = entries[0] + env.expect.that_str(entry.venv_path).equals("test_path") + env.expect.that_str(entry.link_to_path).equals("test_target") + env.expect.that_str(entry.kind).equals(VenvSymlinkKind.LIB) + env.expect.that_collection(entry.files.to_list()).contains_exactly([trans]) + env.expect.that_bool(entry.package == None).equals(True) + env.expect.that_bool(entry.version == None).equals(True) + env.expect.that_bool(entry.link_to_file == None).equals(True) + + check(builder.build()) + + # Call build() again to verify it doesn't duplicate/leak state check(builder.build()) if BuiltinPyInfo != None: check(builder.build_builtin_py_info()) diff --git a/tests/venv_site_packages_libs/app_files_building/app_files_building_tests.bzl b/tests/venv_site_packages_libs/app_files_building/app_files_building_tests.bzl index 65124076c6..663ab6963e 100644 --- a/tests/venv_site_packages_libs/app_files_building/app_files_building_tests.bzl +++ b/tests/venv_site_packages_libs/app_files_building/app_files_building_tests.bzl @@ -2,10 +2,9 @@ load("@rules_testing//lib:analysis_test.bzl", "analysis_test") load("@rules_testing//lib:test_suite.bzl", "test_suite") -load("//python:py_info.bzl", "PyInfo") +load("//python:py_info.bzl", "PyInfo", "VenvSymlinkEntry", "VenvSymlinkKind") load("//python:py_library.bzl", "py_library") load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility -load("//python/private:py_info.bzl", "VenvSymlinkEntry", "VenvSymlinkKind") # buildifier: disable=bzl-visibility load("//python/private:venv_runfiles.bzl", "build_link_map", "get_venv_symlinks") # buildifier: disable=bzl-visibility load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") From 694cc060eb4e809f152770d37d4d46935b3d7e35 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 19 Jun 2026 20:26:15 -0700 Subject: [PATCH 018/179] doc: merge news into generated changelog (#3836) Currently, the generated changelog has a broken link to the news directory. Since we can process the files as part of docgen, fix the broken link by rendering the news as one would see it for a release. Along the way... * Delete defunct unreleased template handling, as we've switched to news file assembly instead. --- CHANGELOG.md | 20 +- docs/BUILD.bazel | 24 +- docs/merge_changelog.py | 38 +++ tests/tools/private/release/release_test.py | 268 ++++++++-------- tools/private/release/BUILD.bazel | 8 +- tools/private/release/changelog_news.py | 323 ++++++++++++++++++++ tools/private/release/release.py | 293 +----------------- 7 files changed, 542 insertions(+), 432 deletions(-) create mode 100644 docs/merge_changelog.py create mode 100644 tools/private/release/changelog_news.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d371293a..21108ffda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,24 +20,14 @@ A brief description of the categories of changes: * Particular sub-systems are identified using parentheses, e.g. `(bzlmod)` or `(docs)`. - -{#v0-0-0} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). {#v2-1-0} ## [2.1.0] - 2026-06-17 diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 94cb588018..315010c12d 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -17,6 +17,7 @@ load("@sphinxdocs//sphinxdocs:readthedocs.bzl", "readthedocs_install") load("@sphinxdocs//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") load("@sphinxdocs//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") load("@sphinxdocs//sphinxdocs:sphinx_stardoc.bzl", "sphinx_stardoc", "sphinx_stardocs") +load("//python:defs.bzl", "py_binary") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility load("//python/uv:lock.bzl", "lock") # buildifier: disable=bzl-visibility @@ -37,6 +38,27 @@ _TARGET_COMPATIBLE_WITH = select({ "//conditions:default": ["@platforms//:incompatible"], }) if BZLMOD_ENABLED else ["@platforms//:incompatible"] +py_binary( + name = "merge_changelog", + srcs = ["merge_changelog.py"], + target_compatible_with = _TARGET_COMPATIBLE_WITH, + deps = [ + "//tools/private/release:changelog_news", + ], +) + +genrule( + name = "merged_changelog", + srcs = [ + "//:CHANGELOG.md", + "//news:news_files", + ], + outs = ["changelog.md"], + cmd = "$(location :merge_changelog) --changelog $(location //:CHANGELOG.md) --news-dir news --output $@", + target_compatible_with = _TARGET_COMPATIBLE_WITH, + tools = [":merge_changelog"], +) + # See README.md for instructions. Short version: # * `bazel run //docs:docs.serve` in a separate terminal # * `ibazel build //docs:docs` to automatically rebuild docs @@ -60,8 +82,8 @@ sphinx_docs( "html", ], renamed_srcs = { - "//:CHANGELOG.md": "changelog.md", "//:CONTRIBUTING.md": "contributing.md", + ":merged_changelog": "changelog.md", "@sphinxdocs//sphinxdocs/inventories:bazel_inventory": "bazel_inventory.inv", }, sphinx = ":sphinx-build", diff --git a/docs/merge_changelog.py b/docs/merge_changelog.py new file mode 100644 index 0000000000..47af98b2a2 --- /dev/null +++ b/docs/merge_changelog.py @@ -0,0 +1,38 @@ +"""A tool to merge news entries into CHANGELOG.md for documentation preview.""" + +import argparse +import pathlib + +from tools.private.release import changelog_news + + +def main(): + parser = argparse.ArgumentParser(description="Merge news entries into CHANGELOG.md") + parser.add_argument( + "--changelog", type=pathlib.Path, required=True, help="Path to CHANGELOG.md" + ) + parser.add_argument( + "--news-dir", type=pathlib.Path, required=True, help="Path to news directory" + ) + parser.add_argument( + "--output", + type=pathlib.Path, + required=True, + help="Path to output merged changelog", + ) + args = parser.parse_args() + + # Call the public merge_new_into_changelog function + # Using deterministic date "0000-00-00" and version "0.0.0" + changelog_news.merge_new_into_changelog( + changelog_path=args.changelog, + output_path=args.output, + news_dir=args.news_dir, + version="unreleased", + release_date="0000-00-00", + delete_news=False, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 3335a5f65f..84c6abeaf3 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -5,22 +5,7 @@ import unittest from unittest.mock import patch -from tools.private.release import release as releaser - -_UNRELEASED_TEMPLATE = """ - -""" +from tools.private.release import changelog_news, release as releaser class ReleaserTest(unittest.TestCase): @@ -33,94 +18,37 @@ def setUp(self): # NOTE: On windows, this must be done before files are deleted. self.addCleanup(os.chdir, self.original_cwd) - def test_update_changelog(self): - changelog = f""" -# Changelog - -{_UNRELEASED_TEMPLATE} - -{{#v0-0-0}} -## Unreleased - -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 - -{{#v0-0-0-changed}} -### Changed -* Nothing changed - -{{#v0-0-0-fixed}} -### Fixed -* Nothing fixed - -{{#v0-0-0-added}} -### Added -* Nothing added - -{{#v0-0-0-removed}} -### Removed -* Nothing removed. -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - # Act - releaser.update_changelog( - "1.23.4", - "2025-01-01", - changelog_path=changelog_path, - ) - - # Assert - new_content = changelog_path.read_text() - - self.assertIn( - _UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n" - ) - self.assertIn("## [1.23.4] - 2025-01-01", new_content) - self.assertIn( - "[1.23.4]: https://github.com/bazel-contrib/rules_python/releases/tag/1.23.4", - new_content, - ) - self.assertIn("{#v1-23-4}", new_content) - self.assertIn("{#v1-23-4-changed}", new_content) - self.assertIn("{#v1-23-4-fixed}", new_content) - self.assertIn("{#v1-23-4-added}", new_content) - self.assertIn("{#v1-23-4-removed}", new_content) - def test_update_changelog_with_news(self): # Arrange - changelog = f""" -# Changelog + changelog = """# Changelog -{_UNRELEASED_TEMPLATE} - -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -{{#v0-0-0-removed}} +{#unreleased-removed} ### Removed * Nothing removed. -{{#v0-0-0-changed}} +{#unreleased-changed} ### Changed * Nothing changed. -{{#v0-0-0-fixed}} +{#unreleased-fixed} ### Fixed * Nothing fixed. -{{#v0-0-0-added}} +{#unreleased-added} ### Added * Nothing added. -{{#v2-0-2}} +{#v2-0-2} ## [2.0.2] - 2026-05-14 [2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 -{{#v2-0-2-added}} +{#v2-0-2-added} ### Added * (toolchains) Some older change. """ @@ -140,7 +68,7 @@ def test_update_changelog_with_news(self): (news_dir / "invalid_name.md").write_text("Should be ignored") # Act - releaser.update_changelog( + changelog_news.update_changelog( "3.0.0", "2026-06-16", changelog_path=changelog_path, @@ -157,20 +85,17 @@ def test_update_changelog_with_news(self): new_content = changelog_path.read_text() - # 2. Unreleased template comment should still be there - self.assertIn( - _UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n" - ) - - # 3. A fresh active Unreleased section should be present - self.assertIn("{#v0-0-0}", new_content) + # 2. A fresh active Unreleased section should be present + self.assertIn("{#unreleased}", new_content) self.assertIn("## Unreleased", new_content) self.assertIn( - "Unreleased changes are tracked as individual files in the [news/](./news) directory.", + "Unreleased changes are tracked as individual files in the [news/](./news)\n" + "directory, or view the [latest generated\n" + "changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).", new_content, ) - # 4. The new release section should be present + # 3. The new release section should be present self.assertIn("{#v3-0-0}", new_content) self.assertIn("## [3.0.0] - 2026-06-16", new_content) self.assertIn( @@ -178,7 +103,7 @@ def test_update_changelog_with_news(self): new_content, ) - # 5. Correct categories and content + # 4. Correct categories and content self.assertIn( "{#v3-0-0-fixed}\n### Fixed\n* Fixed a bug in the compiler", new_content ) @@ -187,34 +112,33 @@ def test_update_changelog_with_news(self): new_content, ) - # 6. Omitted categories should NOT be present in the new release + # 5. Omitted categories should NOT be present in the new release self.assertNotIn("{#v3-0-0-removed}", new_content) self.assertNotIn("{#v3-0-0-changed}", new_content) - # 7. Old release should still be there + # 6. Old release should still be there self.assertIn("{#v2-0-2}", new_content) self.assertIn("## [2.0.2] - 2026-05-14", new_content) def test_update_changelog_sorting(self): # Arrange - changelog = f""" -# Changelog - -{_UNRELEASED_TEMPLATE} + changelog = """# Changelog -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). -{{#v2-0-2}} +{#v2-0-2} ## [2.0.2] - 2026-05-14 [2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 -{{#v2-0-2-added}} +{#v2-0-2-added} ### Added * (toolchains) Some older change. """ @@ -232,7 +156,7 @@ def test_update_changelog_sorting(self): (news_dir / "5.fixed.md").write_text("No subcategory A") # Act - releaser.update_changelog( + changelog_news.update_changelog( "3.0.0", "2026-06-16", changelog_path=changelog_path, @@ -273,24 +197,23 @@ def side_effect(path_self, *args, **kwargs): mock_read_text.side_effect = side_effect - changelog = f""" -# Changelog + changelog = """# Changelog -{_UNRELEASED_TEMPLATE} - -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). -{{#v2-0-2}} +{#v2-0-2} ## [2.0.2] - 2026-05-14 [2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 -{{#v2-0-2-added}} +{#v2-0-2-added} ### Added * (toolchains) Some older change. """ @@ -311,7 +234,7 @@ def side_effect(path_self, *args, **kwargs): # Act & Assert # It should raise IOError with self.assertRaises(IOError): - releaser.update_changelog( + changelog_news.update_changelog( "3.0.0", "2026-06-16", changelog_path=changelog_path, @@ -328,24 +251,23 @@ def side_effect(path_self, *args, **kwargs): def test_update_changelog_merge_existing(self): # Arrange - changelog = f""" -# Changelog - -{_UNRELEASED_TEMPLATE} + changelog = """# Changelog -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). -{{#v2-0-3}} +{#v2-0-3} ## [2.0.3] - 2026-06-15 [2.0.3]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.3 -{{#v2-0-3-fixed}} +{#v2-0-3-fixed} ### Fixed * (pypi) Old fix multi-line detail @@ -365,7 +287,7 @@ def test_update_changelog_merge_existing(self): (news_dir / "2.added.md").write_text("(toolchains) New feature") # Act - releaser.update_changelog( + changelog_news.update_changelog( "2.0.3", "2026-06-15", changelog_path=changelog_path, @@ -400,6 +322,102 @@ def test_update_changelog_merge_existing(self): # Active Unreleased section should NOT be touched (should still be empty/pointing to news) self.assertIn("Unreleased changes are tracked as individual files", new_content) + def test_update_changelog_does_not_leak(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +This release body mentions the word unreleased and {#unreleased} anchor to test leaks. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + (news_dir / "1.fixed.md").write_text("Some fix") + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # The 2.0.2 body should NOT be modified + self.assertIn( + "This release body mentions the word unreleased and {#unreleased} anchor to test leaks.", + new_content, + ) + + def test_update_changelog_empty_news(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{#v2-0-2-added} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # The new release section should be present and contain "No notable changes." + self.assertIn("{#v3-0-0}", new_content) + self.assertIn("## [3.0.0] - 2026-06-16", new_content) + self.assertIn( + "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", + new_content, + ) + self.assertIn("No notable changes.", new_content) + + # Verify that we didn't accidentally create any categories + self.assertNotIn("{#v3-0-0-fixed}", new_content) + self.assertNotIn("{#v3-0-0-added}", new_content) + def test_replace_version_next(self): # Arrange mock_file_content = """ diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 31cc3a0239..0afb23962e 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -1,12 +1,18 @@ -load("@rules_python//python:defs.bzl", "py_binary") +load("@rules_python//python:defs.bzl", "py_binary", "py_library") package(default_visibility = ["//visibility:public"]) +py_library( + name = "changelog_news", + srcs = ["changelog_news.py"], +) + py_binary( name = "release", srcs = ["release.py"], main = "release.py", deps = [ + ":changelog_news", "@dev_pip//packaging", ], ) diff --git a/tools/private/release/changelog_news.py b/tools/private/release/changelog_news.py new file mode 100644 index 0000000000..b896af1f6b --- /dev/null +++ b/tools/private/release/changelog_news.py @@ -0,0 +1,323 @@ +"""Utility functions for handling news entries and merging them into CHANGELOG.md.""" + +import pathlib +import re + +_UNRELEASED_TEMPLATE_BODY = """## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).""" + + +def _get_sub_category(content): + """Extracts the sub-category in parentheses from the entry content.""" + match = re.match(r"^(?:\*|-)\s*\(([^)]+)\)", content) + if match: + return match.group(1).lower() + return "" + + +def is_news_file(path): + """Checks if a file path is a valid news file.""" + path = pathlib.Path(path) + if not path.is_file(): + return False + if path.suffix != ".md": + return False + parts = path.name.split(".") + if len(parts) < 3: + return False + return True + + +def _get_news_files(news_dir): + """Returns a list of news files matching the ..md pattern.""" + news_path = pathlib.Path(news_dir) + if not news_path.exists(): + return [] + + return [p for p in news_path.iterdir() if is_news_file(p)] + + +def _parse_new_files(news_files): + """Parses news files and groups them by category.""" + entries = {} + for p in news_files: + if not is_news_file(p): + continue + parts = p.name.split(".") + category = parts[1].lower() + + content = p.read_text(encoding="utf-8").strip() + + if not content: + continue + + # Format as list item if not already + if not (content.startswith("* ") or content.startswith("- ")): + content = f"* {content}" + + if category not in entries: + entries[category] = [] + entries[category].append(content) + + return entries + + +def generate_release_block(version, release_date, news_entries): + """Generates the markdown block for the release.""" + header_version = version.replace(".", "-") + lines = [ + f"{{#v{header_version}}}", + f"## [{version}] - {release_date}", + "", + f"[{version}]: https://github.com/bazel-contrib/rules_python/releases/tag/{version}", + "", + ] + + # Standard categories in preferred order + category_order = ["removed", "changed", "fixed", "added"] + # Add any other categories found + for cat in news_entries: + if cat not in category_order: + category_order.append(cat) + + has_entries = False + for cat in category_order: + if cat in news_entries and news_entries[cat]: + has_entries = True + lines.append(f"{{#v{header_version}-{cat}}}") + lines.append(f"### {cat.capitalize()}") + + # Sort entries by sub-category, then by content + sorted_entries = sorted( + news_entries[cat], key=lambda e: (_get_sub_category(e), e) + ) + + for entry in sorted_entries: + lines.append(entry) + lines.append("") + + if not has_entries: + lines.append("No notable changes.") + lines.append("") + + return "\n".join(lines) + + +def _add_news_to_changelog(input_path, output_path, version, entries, release_date): + """Adds or merges news entries into CHANGELOG.md.""" + input_path = pathlib.Path(input_path) + output_path = pathlib.Path(output_path) + changelog_content = input_path.read_text(encoding="utf-8") + + if version == "unreleased": + header_version = "unreleased" + version_anchor = "{#unreleased}" + category_anchor_fmt = "{{#unreleased-{cat}}}" + category_anchor_pattern = r"\{#unreleased-(?P[a-z]+)\}" + else: + header_version = version.replace(".", "-") + version_anchor = f"{{#v{header_version}}}" + category_anchor_fmt = "{{#v" + header_version + "-{cat}}}" + category_anchor_pattern = ( + r"\{#v" + re.escape(header_version) + r"-(?P[a-z]+)\}" + ) + + version_exists = version_anchor in changelog_content + + if version_exists: + if not entries and version != "unreleased": + print( + f"Version {version} already exists and no news entries found" + " to merge. Doing nothing." + ) + output_path.write_text(changelog_content, encoding="utf-8") + return + + print(f"Version {version} already exists in changelog. Merging news entries...") + # Extract the existing version block + pattern = ( + r"(?P" + + re.escape(version_anchor) + + r")(?P.*?)(?=\n\s*\{#v\d+-\d+-\d+\}|\Z)" + ) + match = re.search(pattern, changelog_content, re.DOTALL) + if not match: + raise RuntimeError( + f"Could not find content for existing version {version} in CHANGELOG.md" + ) + + content_block = match.group("content") + + # Strip the "Unreleased changes..." sentence for Unreleased preview + if version == "unreleased": + content_block = re.sub( + r"Unreleased\s+changes\s+are\s+tracked\s+as\s+individual\s+files\s+in\s+the\s+\[news/\]\(\./news\)\s+directory,\s+or\s+view\s+the\s+\[latest\s+generated\s+changelog\]\(https://rules-python\.readthedocs\.io/en/latest/changelog\.html\)\.\s*\n*", + "", + content_block, + ) + + # Parse existing categories + match_cat = re.search(category_anchor_pattern, content_block) + if match_cat: + header_end_idx = match_cat.start() + header_str = content_block[:header_end_idx] + categories_str = content_block[header_end_idx:] + else: + header_str = content_block + categories_str = "" + + existing_entries = {} + if categories_str: + cat_matches = list(re.finditer(category_anchor_pattern, categories_str)) + for i, m in enumerate(cat_matches): + cat = m.group("cat") + start_idx = m.end() + end_idx = ( + cat_matches[i + 1].start() + if i + 1 < len(cat_matches) + else len(categories_str) + ) + cat_content = categories_str[start_idx:end_idx].strip() + + lines = cat_content.splitlines() + cat_entries = [] + current_entry = [] + for line in lines: + if not line.strip() or line.strip().startswith("### "): + continue + if line.startswith("* ") or line.startswith("- "): + if current_entry: + cat_entries.append("\n".join(current_entry)) + current_entry = [line] + else: + if current_entry: + current_entry.append(line) + if current_entry: + cat_entries.append("\n".join(current_entry)) + existing_entries[cat] = cat_entries + + # Merge news entries + merged_entries = dict(existing_entries) + for cat, cat_entries in entries.items(): + if cat not in merged_entries: + merged_entries[cat] = [] + merged_entries[cat].extend(cat_entries) + + # Reconstruct categories + reconstructed_lines = [] + category_order = ["removed", "changed", "fixed", "added"] + for cat in merged_entries: + if cat not in category_order: + category_order.append(cat) + + for cat in category_order: + if cat in merged_entries and merged_entries[cat]: + reconstructed_lines.append(category_anchor_fmt.format(cat=cat)) + reconstructed_lines.append(f"### {cat.capitalize()}") + + sorted_entries = sorted( + merged_entries[cat], key=lambda e: (_get_sub_category(e), e) + ) + + for entry in sorted_entries: + reconstructed_lines.append(entry) + reconstructed_lines.append("") + + new_categories_str = "\n".join(reconstructed_lines) + new_release_block = ( + header_str.rstrip() + "\n\n" + new_categories_str.strip() + "\n" + ) + if version == "unreleased" and not new_categories_str.strip(): + new_release_block = ( + header_str.rstrip() + "\n\nNo notable unreleased changes.\n" + ) + + # Replace in changelog_content + new_content = re.sub( + pattern, + r"\g\n" + new_release_block.strip() + "\n", + changelog_content, + count=1, + flags=re.DOTALL, + ) + output_path.write_text(new_content, encoding="utf-8") + + else: + print( + f"Version {version} does not exist in changelog. Creating new" + " release section from news entries..." + ) + new_release_block = generate_release_block(version, release_date, entries) + replacement = ( + f"{{#unreleased}}\n{_UNRELEASED_TEMPLATE_BODY}\n\n{new_release_block}\n" + ) + + # Replace the active Unreleased section (from {#unreleased} to the first release anchor) + pattern = ( + r"(?P\{#unreleased\})(?P.*?)(?=\n\s*\{#v\d+-\d+-\d+\}|\Z)" + ) + + if not re.search(pattern, changelog_content, re.DOTALL): + raise RuntimeError( + "Could not find active Unreleased section to replace in CHANGELOG.md" + ) + + new_content = re.sub( + pattern, + replacement, + changelog_content, + count=1, + flags=re.DOTALL, + ) + output_path.write_text(new_content, encoding="utf-8") + + +def merge_new_into_changelog( + changelog_path, + output_path, + news_dir, + version, + release_date, + delete_news=False, +): + """Merges news entries from news_dir into changelog_path and writes to output_path.""" + news_files = _get_news_files(news_dir) + entries = _parse_new_files(news_files) + _add_news_to_changelog( + input_path=changelog_path, + output_path=output_path, + version=version, + entries=entries, + release_date=release_date, + ) + if delete_news: + for p in news_files: + p.unlink() + if news_files: + print(f"Removed {len(news_files)} processed news files.") + + +def update_changelog( + version, + release_date, + changelog_path="CHANGELOG.md", + output_path=None, + news_dir="news", + delete_news=True, +): + """Performs the version replacements in CHANGELOG.md.""" + if output_path is None: + output_path = changelog_path + merge_new_into_changelog( + changelog_path=changelog_path, + output_path=output_path, + news_dir=news_dir, + version=version, + release_date=release_date, + delete_news=delete_news, + ) diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 4f956bd56c..9cd949ae99 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -4,12 +4,13 @@ import datetime import fnmatch import os -import pathlib import re import subprocess from packaging.version import parse as parse_version +from tools.private.release import changelog_news + _EXCLUDE_PATTERNS = [ "./.git/*", "./.github/*", @@ -162,294 +163,6 @@ def determine_next_version(branch_name=None): return f"{major}.{minor}.{patch + 1}" -def _get_sub_category(content): - """Extracts the sub-category in parentheses from the entry content.""" - match = re.match(r"^(?:\*|-)\s*\(([^)]+)\)", content) - if match: - return match.group(1).lower() - return "" - - -def _get_news_files(news_dir): - """Returns a list of news files matching the ..md pattern.""" - news_path = pathlib.Path(news_dir) - if not news_path.exists(): - return [] - - valid_files = [] - for p in news_path.iterdir(): - if not p.is_file(): - continue - if p.suffix != ".md": - continue - parts = p.name.split(".") - if len(parts) < 3: - continue - valid_files.append(p) - - return valid_files - - -def _parse_new_files(news_files): - """Parses news files and groups them by category.""" - entries = {} - for p in news_files: - parts = p.name.split(".") - category = parts[1].lower() - - content = p.read_text(encoding="utf-8").strip() - - if not content: - continue - - # Format as list item if not already - if not (content.startswith("* ") or content.startswith("- ")): - content = f"* {content}" - - if category not in entries: - entries[category] = [] - entries[category].append(content) - - return entries - - -def generate_release_block(version, release_date, news_entries): - """Generates the markdown block for the release.""" - header_version = version.replace(".", "-") - lines = [ - f"{{#v{header_version}}}", - f"## [{version}] - {release_date}", - "", - f"[{version}]: https://github.com/bazel-contrib/rules_python/releases/tag/{version}", - "", - ] - - # Standard categories in preferred order - category_order = ["removed", "changed", "fixed", "added"] - # Add any other categories found - for cat in news_entries: - if cat not in category_order: - category_order.append(cat) - - for cat in category_order: - if cat in news_entries and news_entries[cat]: - lines.append(f"{{#v{header_version}-{cat}}}") - lines.append(f"### {cat.capitalize()}") - - # Sort entries by sub-category, then by content - sorted_entries = sorted( - news_entries[cat], key=lambda e: (_get_sub_category(e), e) - ) - - for entry in sorted_entries: - lines.append(entry) - lines.append("") - - return "\n".join(lines) - - -def _add_news_to_changelog(changelog_path, version, entries, release_date): - """Adds or merges news entries into CHANGELOG.md.""" - changelog_path_obj = pathlib.Path(changelog_path) - changelog_content = changelog_path_obj.read_text(encoding="utf-8") - - header_version = version.replace(".", "-") - version_anchor = f"{{#v{header_version}}}" - version_exists = version_anchor in changelog_content - - if version_exists: - if not entries: - print( - f"Version {version} already exists and no news entries found" - " to merge. Doing nothing." - ) - return - - print(f"Version {version} already exists in changelog. Merging news entries...") - # Extract the existing version block - # Match from the version anchor to the next version anchor (or end of file) - pattern = ( - r"(?P\{#v" - + re.escape(header_version) - + r"\})(?P.*?)(?=\n\s*\{#v(?!0-0-0)\d+-\d+-\d+\}|\Z)" - ) - match = re.search(pattern, changelog_content, re.DOTALL) - if not match: - raise RuntimeError( - f"Could not find content for existing version {version} in CHANGELOG.md" - ) - - content_block = match.group("content") - - # Split content_block into header and categories - category_anchor_pattern = ( - r"\{#v" + re.escape(header_version) + r"-(?P[a-z]+)\}" - ) - match_cat = re.search(category_anchor_pattern, content_block) - if match_cat: - header_end_idx = match_cat.start() - header_str = content_block[:header_end_idx] - categories_str = content_block[header_end_idx:] - else: - header_str = content_block - categories_str = "" - - # Parse existing categories - existing_entries = {} - if categories_str: - cat_matches = list(re.finditer(category_anchor_pattern, categories_str)) - for i, m in enumerate(cat_matches): - cat = m.group("cat") - start_idx = m.end() - end_idx = ( - cat_matches[i + 1].start() - if i + 1 < len(cat_matches) - else len(categories_str) - ) - cat_content = categories_str[start_idx:end_idx].strip() - - lines = cat_content.splitlines() - cat_entries = [] - current_entry = [] - for line in lines: - if not line.strip() or line.strip().startswith("### "): - continue - if line.startswith("* ") or line.startswith("- "): - if current_entry: - cat_entries.append("\n".join(current_entry)) - current_entry = [line] - else: - if current_entry: - current_entry.append(line) - if current_entry: - cat_entries.append("\n".join(current_entry)) - existing_entries[cat] = cat_entries - - # Merge news entries - merged_entries = dict(existing_entries) - for cat, cat_entries in entries.items(): - if cat not in merged_entries: - merged_entries[cat] = [] - merged_entries[cat].extend(cat_entries) - - # Reconstruct categories - reconstructed_lines = [] - category_order = ["removed", "changed", "fixed", "added"] - for cat in merged_entries: - if cat not in category_order: - category_order.append(cat) - - for cat in category_order: - if cat in merged_entries and merged_entries[cat]: - reconstructed_lines.append(f"{{#v{header_version}-{cat}}}") - reconstructed_lines.append(f"### {cat.capitalize()}") - - sorted_entries = sorted( - merged_entries[cat], key=lambda e: (_get_sub_category(e), e) - ) - - for entry in sorted_entries: - reconstructed_lines.append(entry) - reconstructed_lines.append("") - - new_categories_str = "\n".join(reconstructed_lines) - new_release_block = ( - header_str.rstrip() + "\n\n" + new_categories_str.strip() + "\n" - ) - - # Replace in changelog - new_content = re.sub( - pattern, - r"\g\n" + new_release_block.strip() + "\n", - changelog_content, - flags=re.DOTALL, - ) - changelog_path_obj.write_text(new_content, encoding="utf-8") - - else: - if entries: - print( - f"Version {version} does not exist in changelog. Creating new" - " release section from news entries..." - ) - # Extract template - template_match = re.search( - r"BEGIN_UNRELEASED_TEMPLATE\s*\n(.*?)\n\s*END_UNRELEASED_TEMPLATE", - changelog_content, - re.DOTALL, - ) - if not template_match: - raise RuntimeError( - "Could not find BEGIN_UNRELEASED_TEMPLATE in CHANGELOG.md" - ) - - unreleased_template = template_match.group(1).strip() - new_release_block = generate_release_block(version, release_date, entries) - - replacement = f"{unreleased_template}\n\n{new_release_block}\n" - - # Replace the active Unreleased section - pattern = r"(END_UNRELEASED_TEMPLATE\s*\n-->\s*\n)(.*?)(\n\s*\{#v(?!0-0-0)\d+-\d+-\d+\})" - - if not re.search(pattern, changelog_content, re.DOTALL): - raise RuntimeError( - "Could not find active Unreleased section to replace in" - " CHANGELOG.md" - ) - - new_content = re.sub( - pattern, - r"\g<1>" + replacement + r"\g<3>", - changelog_content, - flags=re.DOTALL, - ) - changelog_path_obj.write_text(new_content, encoding="utf-8") - else: - # Fallback to old behavior - print( - f"No news entries found and version {version} does not exist." - " Falling back to manual changelog update..." - ) - header_version = version.replace(".", "-") - lines = changelog_content.splitlines() - - new_lines = [] - after_template = False - before_already_released = True - for line in lines: - if "END_UNRELEASED_TEMPLATE" in line: - after_template = True - if re.match("#v[1-9]-", line): - before_already_released = False - - if after_template and before_already_released: - line = line.replace( - "## Unreleased", f"## [{version}] - {release_date}" - ) - line = line.replace("v0-0-0", f"v{header_version}") - line = line.replace("0.0.0", version) - - new_lines.append(line) - - changelog_path_obj.write_text("\n".join(new_lines), encoding="utf-8") - - -def update_changelog( - version, release_date, changelog_path="CHANGELOG.md", news_dir="news" -): - """Performs the version replacements in CHANGELOG.md.""" - news_files = _get_news_files(news_dir) - entries = _parse_new_files(news_files) - - _add_news_to_changelog(changelog_path, version, entries, release_date) - - # Delete news files after successful update - for p in news_files: - p.unlink() - if news_files: - print(f"Removed {len(news_files)} processed news files.") - - def replace_version_next(version): """Replaces all VERSION_NEXT_* placeholders with the new version.""" for filepath in _iter_version_placeholder_files(): @@ -506,7 +219,7 @@ def main(): print("Updating changelog ...") release_date = datetime.date.today().strftime("%Y-%m-%d") - update_changelog(version, release_date) + changelog_news.update_changelog(version, release_date) print("Replacing VERSION_NEXT placeholders ...") replace_version_next(version) From ca869843c39d54883b77896f86f9948c2b827eaf Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 19 Jun 2026 21:47:59 -0700 Subject: [PATCH 019/179] ci: enable bazel-diff (#3838) Enable bazel-diff to improve CI performance by skipping targets that aren't affected by a PR --- .bazelci/presubmit.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 6366741313..2cac5b961e 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml @@ -25,6 +25,8 @@ buildifier: bazel: 7.x skip_in_bazel_downstream_pipeline: "Bazel 7 required" .reusable_config: &reusable_config + environment: + USE_BAZEL_DIFF: "1" build_targets: - "--" - "..." From b16ce73f67acc07fe131a15700999d27400ce85b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 20 Jun 2026 20:35:09 -0700 Subject: [PATCH 020/179] fix: run bazel-diff in cquery mode under bazelci (#3839) This PR fixes the CI breakage on main where bazel-diff ran in plain query mode, ignoring target_compatible_with and attempting to run platform-incompatible tests. It re-introduces the java wrapper to inject cquery mode targeting only non-manual tests, and re-applies the visibility fix. --- .bazelci/presubmit.yml | 1 + tests/uv/lock/pyproject_toml/BUILD.bazel | 1 + 2 files changed, 2 insertions(+) diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 2cac5b961e..147f752724 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml @@ -27,6 +27,7 @@ buildifier: .reusable_config: &reusable_config environment: USE_BAZEL_DIFF: "1" + EXP_USE_CQUERY: "true" build_targets: - "--" - "..." diff --git a/tests/uv/lock/pyproject_toml/BUILD.bazel b/tests/uv/lock/pyproject_toml/BUILD.bazel index a64fe50d14..d73d206293 100644 --- a/tests/uv/lock/pyproject_toml/BUILD.bazel +++ b/tests/uv/lock/pyproject_toml/BUILD.bazel @@ -27,4 +27,5 @@ diff_test( timeout = "short", file1 = ":requirements", file2 = "requirements.txt", + visibility = ["//tests/uv/lock:__pkg__"], ) From e9ecdba91a389d3cac5cbd96e7ad27d59abf3884 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 20 Jun 2026 20:47:50 -0700 Subject: [PATCH 021/179] ci: disable bazel-diff due to platform compatibility conflicts (#3840) This PR disables the bazel-diff target filtering to repair the CI. Since we make heavy use of target_compatible_with to restrict tests to specific platforms, and Bazel CI's bazel-diff integration isn't able to handle those. --- .bazelci/presubmit.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 147f752724..79d2dfbc30 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml @@ -25,9 +25,10 @@ buildifier: bazel: 7.x skip_in_bazel_downstream_pipeline: "Bazel 7 required" .reusable_config: &reusable_config - environment: - USE_BAZEL_DIFF: "1" - EXP_USE_CQUERY: "true" + # USE_BAZEL_DIFF is disabled because target_compatible_with is used heavily, + # but BazelCI's bazel-diff integration errors when handling them. + # environment: + # USE_BAZEL_DIFF: "1" build_targets: - "--" - "..." From 01903fcbc66721961bbabaeb37bc8fed176bf9a5 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 21 Jun 2026 09:10:52 -0700 Subject: [PATCH 022/179] chore: warn on implicit __init__.py creation (#3841) Implicit `__init__.py` creation is deprecated and will be disabled by default in a future release. We need to warn users when their targets rely on this behavior so they can transition to explicit `__init__.py` files before the default changes and their builds break. Work towards #2945 --- news/2945.changed.md | 1 + python/private/py_executable.bzl | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 news/2945.changed.md diff --git a/news/2945.changed.md b/news/2945.changed.md new file mode 100644 index 0000000000..c0ff002661 --- /dev/null +++ b/news/2945.changed.md @@ -0,0 +1 @@ +(binaries/tests) Added a deprecation warning for targets relying on implicit `__init__.py` creation. diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 198ff9d548..04a43be917 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -1485,6 +1485,25 @@ def _get_base_runfiles_for_binary( app_runfiles = app_runfiles.build(ctx) if _should_create_init_files(ctx): + # buildifier: disable=print + print( + """ +====================================================================== +WARNING: Target {} is using implicit __init__.py creation. + This diabolic behavior is deprecated and will be disabled by default in a + future release. + See https://github.com/bazel-contrib/rules_python/issues/2945 + + Ensure all __init__.py files are explicitly created and + added to the srcs or deps of your targets. + + Disable implicit creation by setting: + legacy_create_init = 0 + on the target, or globally by setting: + --incompatible_default_to_explicit_init_py +====================================================================== + """.rstrip().format(ctx.label), + ) app_runfiles = _py_builtins.merge_runfiles_with_generated_inits_empty_files_supplier( ctx = ctx, runfiles = app_runfiles, From 1a15c3b4c01940e41ec3ab7371884714e7f37914 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 22 Jun 2026 16:50:25 -0700 Subject: [PATCH 023/179] fix(bootstrap): use retry loop for win32 version lookup in site init template (#3842) Align the Windows path resolution in the site initialization template with the bootstrap template to prevent flaky platform lookup errors on Windows 2022. This is achieved by wrapping the win32 version retrieval in a retrying loop. Fixes https://github.com/bazel-contrib/rules_python/issues/3721 --- news/win32_version_lookup.fixed.md | 3 +++ python/private/site_init_template.py | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 news/win32_version_lookup.fixed.md diff --git a/news/win32_version_lookup.fixed.md b/news/win32_version_lookup.fixed.md new file mode 100644 index 0000000000..6f01c9e73c --- /dev/null +++ b/news/win32_version_lookup.fixed.md @@ -0,0 +1,3 @@ +Fixed a flaky error on Windows 2022 when looking up the win32 version during +site initialization by retrying the lookup +([#3721](https://github.com/bazel-contrib/rules_python/issues/3721)). diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py index af80b22a24..12be98eb57 100644 --- a/python/private/site_init_template.py +++ b/python/private/site_init_template.py @@ -98,7 +98,15 @@ def _get_windows_path_with_unc_prefix(path): # Related doc: https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd#enable-long-paths-in-windows-10-version-1607-and-later import platform - if platform.win32_ver()[1] >= "10.0.14393": + win32_version = None + # Windows 2022 with Python 3.12.8 gives flakey errors, so try a couple times. + for _ in range(3): + try: + win32_version = platform.win32_ver()[1] + break + except (ValueError, KeyError): + pass + if win32_version and win32_version >= "10.0.14393": return path # import sysconfig only now to maintain python 2.6 compatibility From dd53218006120bac2c51276c144bb9c4b5915c15 Mon Sep 17 00:00:00 2001 From: Gleb Kolobkov Date: Tue, 23 Jun 2026 20:26:25 -0700 Subject: [PATCH 024/179] fix(rules): disambiguate slash target venv outputs (#3845) Fixes venv output naming for `py_binary` and `py_test` targets whose names contain path separators. Before this change, the venv output prefix was derived from the executable basename. Distinct targets such as `//:foo/tool` and `//:bar/tool` both have executable basename `tool`, so they could both declare the same package-relative venv output directory: ```text bazel-out/.../bin/_tool.venv ``` After this change, the venv output prefix is derived from the full target name with `/` replaced by `_`, so those targets use separate venv output directories such as: ```text _foo_tool.venv _bar_tool.venv ``` Added regression coverage for slash-named executable targets in the shared `py_binary` / `py_test` base-rule tests. Fixes #3844 --- news/slash-target-venv-output.fixed.md | 3 ++ python/private/py_executable.bzl | 5 ++- tests/base_rules/py_executable_base_tests.bzl | 39 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 news/slash-target-venv-output.fixed.md diff --git a/news/slash-target-venv-output.fixed.md b/news/slash-target-venv-output.fixed.md new file mode 100644 index 0000000000..780b498db7 --- /dev/null +++ b/news/slash-target-venv-output.fixed.md @@ -0,0 +1,3 @@ +(rules) Fixed venv output paths for `py_binary` and `py_test` targets whose +names contain path separators so distinct targets with the same basename no +longer share the same venv output directory. diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 04a43be917..11246ec513 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -306,6 +306,9 @@ def _create_executable( else: base_executable_name = executable.basename + # Venv outputs are package-relative, so preserve the full target name to + # avoid collisions between targets like foo/tool, bar/tool, and foo_tool. + venv_output_prefix = ctx.label.name venv = None # The check for stage2_bootstrap_template is to support legacy @@ -318,7 +321,7 @@ def _create_executable( ): venv = _create_venv( ctx, - output_prefix = base_executable_name, + output_prefix = venv_output_prefix, imports = imports, runtime_details = runtime_details, add_runfiles_root_to_sys_path = ( diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl index 7531de4c30..f6b3c9bb60 100644 --- a/tests/base_rules/py_executable_base_tests.bzl +++ b/tests/base_rules/py_executable_base_tests.bzl @@ -519,6 +519,45 @@ def _test_py_runtime_info_provided_impl(env, target): _tests.append(_test_py_runtime_info_provided) +def _test_venv_output_prefix_with_path_separators(name, config): + rt_util.helper_target( + config.rule, + name = name + "/foo/tool", + srcs = ["main.py"], + main = "main.py", + ) + rt_util.helper_target( + config.rule, + name = name + "/bar/tool", + srcs = ["main.py"], + main = "main.py", + ) + rt_util.helper_target( + config.rule, + name = name + "/foo_tool", + srcs = ["main.py"], + main = "main.py", + ) + analysis_test( + name = name, + impl = _test_venv_output_prefix_with_path_separators_impl, + targets = { + "bar": name + "/bar/tool", + "foo": name + "/foo/tool", + "foo_underscore": name + "/foo_tool", + }, + ) + +def _test_venv_output_prefix_with_path_separators_impl(env, targets): + for target in [targets.foo, targets.bar, targets.foo_underscore]: + target = env.expect.that_target(target) + venv_file = "*/_{}.venv/*site-packages/bazel.pth".format( + target.meta.format_str("{name}"), + ) + target.runfiles().contains_predicate(matching.str_matches(venv_file)) + +_tests.append(_test_venv_output_prefix_with_path_separators) + def _test_windows_target_with_path_separators(name, config): rt_util.helper_target( config.rule, From 4b19581b31ac30ff037b62f59741639847c73e20 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 23 Jun 2026 21:23:28 -0700 Subject: [PATCH 025/179] chore: automate releases (#3833) This introduces workflows and tools to automate releases. The basic idea it to have an issue with tasks, and workflows invoke tools that parse and update the tasks in the issue. The basic release steps are: 1. Prepare a release (update changelog via PR) 2. Create a release branch after preparation 3. Backport changes into the release branch 4. Tag RCs 5. Promote to final Each step has a workflow. For now, most of the workflows require a manual trigger, to help prevent workflows from running wild as we flush out bugs and edge cases. The workflows almost entirely delegate to the `release` tool. There's basically one command per workflow. This makes it easy to encode specialized logic and run it locally for testing or reproduction. --- .agents/plans/release_automation_plan.md | 186 ++++ .agents/rules/github_actions_workflows.md | 10 + .../release_tracking_template.md | 26 + .github/workflows/cut_release_branch.yml | 37 + .github/workflows/generate_rc.yml | 39 + .../on_prepare_release_pr_merged.yml | 35 + .github/workflows/prepare_release.yml | 36 + .github/workflows/process_backports.yml | 40 + .github/workflows/promote_rc.yml | 39 + tests/tools/private/release/release_test.py | 40 +- tools/private/release/BUILD.bazel | 7 +- tools/private/release/gh.py | 184 ++++ tools/private/release/git.py | 126 +++ tools/private/release/release.py | 843 ++++++++++++++++-- tools/private/release/utils.py | 28 + 15 files changed, 1601 insertions(+), 75 deletions(-) create mode 100644 .agents/plans/release_automation_plan.md create mode 100644 .agents/rules/github_actions_workflows.md create mode 100644 .github/ISSUE_TEMPLATE/release_tracking_template.md create mode 100644 .github/workflows/cut_release_branch.yml create mode 100644 .github/workflows/generate_rc.yml create mode 100644 .github/workflows/on_prepare_release_pr_merged.yml create mode 100644 .github/workflows/prepare_release.yml create mode 100644 .github/workflows/process_backports.yml create mode 100644 .github/workflows/promote_rc.yml create mode 100644 tools/private/release/gh.py create mode 100644 tools/private/release/git.py create mode 100644 tools/private/release/utils.py diff --git a/.agents/plans/release_automation_plan.md b/.agents/plans/release_automation_plan.md new file mode 100644 index 0000000000..90d87bc2a3 --- /dev/null +++ b/.agents/plans/release_automation_plan.md @@ -0,0 +1,186 @@ +# Plan for Better `rules_python` Release Automation + +The current release process (as described in `RELEASING.md`) has good +automation *after* a tag is pushed (`release.yml` handles GitHub Release, BCR +PR, and PyPI publishing). However, the steps *leading up* to the tag push are +largely manual. + +This plan outlines a **code-centric, reactive architecture** to automate the +manual preparation, branching, and tagging phases. By moving all core Git and +GitHub CLI logic out of the YAML workflow files and into the Python release +tool (`release.py`), we ensure the release process is robust, locally testable, and +independent of GitHub Actions runner syntax. + +## Prerequisite: Release Tracking Issue + +Every release is tracked by a dedicated GitHub Issue titled `Release ` +(e.g., `Release 0.38.0`), which acts as the single source of truth and state +controller. Release tracking issues are uniquely identified by the +`type:release` label. + +* **Role:** Contains a checklist of completed and remaining steps. +* **Automation Hub:** Workflows reactively trigger on tracking issue modifications. +* **Template File:** The issue body structure is decoupled from the codebase and + resides in `.github/ISSUE_TEMPLATE/release_tracking_template.md`. This is a + standard GitHub issue template equipped with frontmatter so maintainers can + easily create tracking issues manually from the GitHub web UI or let the + automation generate them programmatically. +* **Available Commands:** Placed at the very end of the issue body inside a + collapsed HTML `
` section to keep the main checklist clean. It + contains direct web links to the corresponding manual GitHub Action + workflows. + +### Tracking Issue Checklist Syntax + +The checklist uses a strict, machine-readable syntax using a pipe `|` separator +to attach space-separated metadata keys to tasks. + +#### Main Tasks Checklist + +```markdown +# Release tasks +- [ ] Prepare Release | status=pending pr=#1234 +- [ ] Create Release branch +- [ ] Tag RC0 +- [ ] Tag Final +``` + +* **Prepare Release**: + * Initial: `- [ ] Prepare Release | status=awaiting-preparation` + * Phase 1 PR created: `- [ ] Prepare Release | status=pending pr=#` + * PR merged (Phase 2): `- [x] Prepare Release | status=done pr=# commit=` +* **Create Release branch**: + * Initial: `- [ ] Create Release branch` + * Created: `- [x] Create Release branch | status=done branch=release/X.Y commit=` +* **Tag RC0**: + * Initial: `- [ ] Tag RC0` + * Tagged: `- [x] Tag RC0 | status=done tag=vX.Y.Z-rc0 commit=` +* **Tag Final**: + * Initial: `- [ ] Tag Final` + * Tagged: `- [x] Tag Final | status=done tag=vX.Y.Z commit=` + +#### Backports Checklist + +Maintainers list PRs to backport under the `## Backports` section: + +```markdown +- [x] #1234 | status=done rc=rc1 commit=deadbeef +- [ ] #2345 | status=merge-conflict +- [ ] #3456 +``` + +* **Pending:** `- [ ] #3456` (or `- [ ] #3456 | status=pending`) +* **Succeeded Cherry-pick:** `- [x] #1234 | status=done rc=rc commit=` (with checkbox marked `- [x]` to show it has been successfully completed). +* **Conflicting Cherry-pick:** `- [ ] #2345 | status=merge-conflict` (Unchecked, i.e., remains `- [ ]` to indicate it is not complete. Gates subsequent RC tags until resolved or removed). +* **Unmerged PR Error:** `- [ ] #3456 | status=unmerged-pr` (Unchecked, i.e., remains `- [ ]` to indicate it is not complete. Gates subsequent RC tags until resolved or removed). + +--- + +## Release Tool Commands + +The `release.py` script contains all execution logic. + +### 1. `determine-next-version` +* **Description:** Scans git tags and placeholders to determine the next release version. +* **Inputs:** None. +* **Outputs:** Prints the version string (e.g. `0.38.0`) to stdout. + +### 2. `create-release-issue` +* **Description:** Creates the tracking issue on GitHub using `release_tracking_template.md`. Exits with code `1` and prints a list of open tracking issue titles/URLs if a release is already in progress. +* **Inputs:** `--version ` (optional). + * *Version Resolution:* If not specified, determined automatically by calling `determine_next_version()`. + +### 3. `prepare` +* **Description:** Updates the changelog and placeholders. +* **Inputs:** `[version]` (optional), `--issue ` (optional). + * *Version Resolution:* If not specified, determined automatically by calling `determine_next_version()`. +* **Pre-checks:** + * If `--automation` is set, it first fetches upstream tags/commits and verifies that the workspace has **no uncommitted local edits**, exiting with code `1` if the workspace is dirty. +* **Automation Flag (`--automation`):** If set, it pushes to branch `prepare-{version}`, opens the preparation PR, and updates the tracking issue's `Prepare Release` task to `- [ ] Prepare Release | status=pending pr=#`. + +### 4. `complete-prepare` +* **Description:** Triggered when the prep PR merges. +* **Inputs:** `--pr ` (required), `--automation`. +* **Tracking Issue Resolution:** Automatically parses the tracking issue number + directly from the PR body (which links to the tracking issue). +* **State Updates:** + * Updates `Prepare Release` to: `status=done pr=# commit=` (checked). + +### 5. `create-release-branch` +* **Description:** Cuts the release branch. +* **Inputs:** `--issue ` (required), `--automation`. +* **State Updates:** + * Reads the `commit` SHA from the `Prepare Release` task. + * Cuts and pushes the `release/X.Y` branch. + * Updates `Create Release branch` to: `status=done branch=release/X.Y commit=` (checked). + +### 6. `process-backports` +* **Description:** Cherry-picks pending, merged backports. +* **Inputs:** `--issue ` (required), `--automation`. +* **Gating:** Resolves each backport PR. Unmerged PRs are marked as `status=unmerged-pr` (remains unchecked `- [ ]`) and the loop continues. Conflicting cherry-picks are marked as `status=merge-conflict` (remains unchecked `- [ ]`). If any PR is unmerged or cherry-pick fails with a conflict, the tool exits with code `1` at the end of the run. +* **State Updates:** + * Cherry-picks each pending, merged PR using `git cherry-pick -x` in chronological order. + * *Success:** Pushes to the release branch, updates the backport line to: `status=done rc=rc commit=` (with checkbox marked `- [x]` to show it has been successfully completed). + * *Conflict:* Aborts, updates the backport line to: `status=merge-conflict` (remains unchecked `- [ ]`). + * *Unmerged:* Updates the backport line to: `status=unmerged-pr` (remains unchecked `- [ ]`). + +### 7. `create-rc` +* **Description:** Tags the next RC. +* **Inputs:** `--issue ` (required), `--automation`. +* **Gating:** Fails if `Prepare Release` or `Create Release branch` are not `status=done`. Fails if any backport in the list is unchecked (`- [ ]`) or does not have `status=done`. +* **State Updates:** + * Queries git tags, increments to the next RC (e.g. `v0.38.0-rc0`), tags, and pushes. + * If tagging `rc0`, updates `Tag RC0` to: `status=done tag=vX.Y.Z-rc0 commit=` (checked). + * Announces the tag in an issue comment. + +### 8. `promote-rc` +* **Description:** Promotes the highest RC to final. +* **Inputs:** `[version]` (optional), `--issue ` (optional), `--automation`. + * *Version Resolution:* If not specified, determined automatically by finding the next version (which resolves to the active release version if it has not yet been tagged). + * *Issue Resolution:* If `--issue` is not specified, it searches for a single open tracking issue with the `type:release` label and matching version in the title. If zero or multiple tracking issues are found, the command errors and exits with code `1`. +* **State Updates:** + * Checks out the highest RC tag, tags `vX.Y.Z`, and pushes. + * Always attempts to update the tracking issue's `Tag Final` task to: `status=done tag=vX.Y.Z commit=` (checked), outputting a warning if the issue cannot be resolved. + +--- + +## GitHub Actions Workflows + +### 1. Prepare Release (`prepare_release.yml`) +* **Trigger:** Manual (`workflow_dispatch`). +* **Role:** Runs Phase 1. +* **Command:** `bazel run //tools/private/release -- --automation prepare`. +* **State Machine:** Transition: `Prepare Release` $\rightarrow$ `status=pending pr=#`. + +### 2. On PR Merged (`on_prepare_release_pr_merged.yml`) +* **Trigger:** `pull_request: [closed]` (merged, label `release-prepared`). +* **Role:** Completes Phase 1. +* **Command:** `bazel run //tools/private/release -- --automation complete-prepare --pr `. +* **State Machine:** Transition: `Prepare Release` $\rightarrow$ `status=done pr=# commit=` (checked). + +### 3. Cut Release Branch (`cut_release_branch.yml`) +* **Trigger:** `issues: [edited]` (filtered by label `type:release`). +* **Role:** Runs Phase 2 reactively. +* **Command:** `bazel run //tools/private/release -- --automation create-release-branch --issue `. +* **State Machine:** Transition: `Create Release branch` $\rightarrow$ `status=done branch=release/X.Y commit=` (checked). + +### 4. Process Backports (`process_backports.yml`) +* **Trigger:** Manual (`workflow_dispatch`), taking the tracking issue number. +* **Role:** Runs Phase 2.5 backport processing. +* **Command:** `bazel run //tools/private/release -- --automation process-backports --issue `. +* **State Machine:** + * Cherry-pick success: Backport PR $\rightarrow$ `status=done rc=rc commit=` (checked). + * Cherry-pick conflict: Backport PR $\rightarrow$ `status=merge-conflict` (unchecked). + * Unmerged PR error: Backport PR $\rightarrow$ `status=unmerged-pr` (unchecked). + +### 5. Generate RC Tag (`generate_rc.yml`) +* **Trigger:** Manual (`workflow_dispatch`), taking the tracking issue number. +* **Role:** Runs Phase 2.5 RC tagging. +* **Command:** `bazel run //tools/private/release -- --automation create-rc --issue `. +* **State Machine:** Transition: If tagging `rc0`, `Tag RC0` $\rightarrow$ `status=done tag=vX.Y.Z-rc0 commit=` (checked). + +### 6. Promote RC to Final (`promote_rc.yml`) +* **Trigger:** Manual (`workflow_dispatch`), taking the target version. +* **Role:** Runs Phase 3. +* **Command:** `bazel run //tools/private/release -- --automation promote-rc `. +* **State Machine:** Transition: `Tag Final` $\rightarrow$ `status=done tag=vX.Y.Z commit=` (checked). diff --git a/.agents/rules/github_actions_workflows.md b/.agents/rules/github_actions_workflows.md new file mode 100644 index 0000000000..29bf628a24 --- /dev/null +++ b/.agents/rules/github_actions_workflows.md @@ -0,0 +1,10 @@ +--- +name: github-actions-workflows +trigger: glob +globs: [".github/workflows/*.yml", ".github/workflows/*.yaml", ".github/*.yaml"] +--- + +# GitHub Actions Workflows Rule + +* When creating files in `.github/workflows/` (such as `.yml` or `.yaml` + files), always use the latest version of the referenced GitHub Actions. diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md new file mode 100644 index 0000000000..66beea28b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -0,0 +1,26 @@ +--- +name: Release Tracking Issue +about: Checklist for tracking a new release of rules_python. +title: 'Release ' +labels: ['type:release'] +--- +# Release tasks +- [ ] Prepare Release | status=awaiting-preparation +- [ ] Create Release branch +- [ ] Tag RC0 +- [ ] Tag Final + +## Backports + + +--- +*Maintainers: Automation will react to changes on this issue.* + +
+Available Commands + +Maintainers can trigger automation by running manual workflows: +- [Process Backports Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/process_backports.yml) +- [Generate RC Tag Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/generate_rc.yml) +- [Promote RC to Final Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/promote_rc.yml) +
diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/cut_release_branch.yml new file mode 100644 index 0000000000..b7c7118194 --- /dev/null +++ b/.github/workflows/cut_release_branch.yml @@ -0,0 +1,37 @@ +name: Cut Release Branch + +on: + issues: + types: [edited] + +permissions: + contents: write + issues: write + +jobs: + cut_branch: + # Run only if the issue has the type:release label + if: contains(github.event.issue.labels.*.name, 'type:release') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Attempt Branch Creation + run: | + bazel run //tools/private/release -- \ + create-release-branch --issue ${{ github.event.issue.number }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/generate_rc.yml new file mode 100644 index 0000000000..26685c2f62 --- /dev/null +++ b/.github/workflows/generate_rc.yml @@ -0,0 +1,39 @@ +name: Generate RC Tag + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string + +permissions: + contents: write + issues: write + +jobs: + generate_rc: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Attempt RC Tagging + run: | + bazel run //tools/private/release -- \ + create-rc --issue ${{ inputs.issue }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/on_prepare_release_pr_merged.yml b/.github/workflows/on_prepare_release_pr_merged.yml new file mode 100644 index 0000000000..8ad4d1055c --- /dev/null +++ b/.github/workflows/on_prepare_release_pr_merged.yml @@ -0,0 +1,35 @@ +name: On PR Merged (Release Prepared) + +on: + pull_request: + types: [closed] + +permissions: + contents: write + issues: write + +jobs: + on_pr_merged: + # Run only if the release-prepared PR was merged + if: | + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release-prepared') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Mark Prepare Release Complete + run: | + # Run the complete-prepare subcommand in the release tool to cleanly update checklist metadata + bazel run //tools/private/release -- \ + complete-prepare --pr ${{ github.event.pull_request.number }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml new file mode 100644 index 0000000000..7d5e93aa6b --- /dev/null +++ b/.github/workflows/prepare_release.yml @@ -0,0 +1,36 @@ +name: Prepare Release + +on: + workflow_dispatch: + # Allow manual triggering to prepare the release immediately + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + prepare_release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run Release Preparation Pipeline + run: | + # Manual trigger: run full preparation + bazel run //tools/private/release -- prepare + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/process_backports.yml b/.github/workflows/process_backports.yml new file mode 100644 index 0000000000..9a42b6c62c --- /dev/null +++ b/.github/workflows/process_backports.yml @@ -0,0 +1,40 @@ +name: Process Backports + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string + +permissions: + contents: write + issues: write + +jobs: + process_backports: + # Always gate GHA runs to ensure we are operating on a type:release labeled issue if metadata is queried + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Process Pending Backports + run: | + bazel run //tools/private/release -- \ + process-backports --issue ${{ inputs.issue }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/promote_rc.yml b/.github/workflows/promote_rc.yml new file mode 100644 index 0000000000..d5e697f6d7 --- /dev/null +++ b/.github/workflows/promote_rc.yml @@ -0,0 +1,39 @@ +name: Promote RC to Final Release + +on: + workflow_dispatch: + inputs: + version: + description: 'The final version to release (e.g., 0.38.0)' + required: true + type: string + +permissions: + contents: write + issues: write + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run Promote RC + run: | + bazel run //tools/private/release -- \ + promote-rc ${{ inputs.version }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 84c6abeaf3..9f9cd2e801 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -476,24 +476,26 @@ def test_replace_version_next_excludes_bazel_dirs(self): def test_valid_version(self): # These should not raise an exception - releaser.create_parser().parse_args(["0.28.0"]) - releaser.create_parser().parse_args(["1.0.0"]) - releaser.create_parser().parse_args(["1.2.3rc4"]) + releaser.create_parser().parse_args(["prepare", "0.28.0"]) + releaser.create_parser().parse_args(["promote-rc", "1.0.0"]) + releaser.create_parser().parse_args( + ["create-release-issue", "--version", "1.2.3rc4"] + ) def test_invalid_version(self): with self.assertRaises(SystemExit): - releaser.create_parser().parse_args(["0.28"]) + releaser.create_parser().parse_args(["prepare", "0.28"]) with self.assertRaises(SystemExit): - releaser.create_parser().parse_args(["a.b.c"]) + releaser.create_parser().parse_args(["prepare", "a.b.c"]) class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_success(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] self.assertEqual(releaser.get_latest_version(), "1.0.0") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_rc_is_latest(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -501,7 +503,7 @@ def test_get_latest_version_rc_is_latest(self, mock_get_tags): ): releaser.get_latest_version() - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] with self.assertRaisesRegex( @@ -509,7 +511,7 @@ def test_get_latest_version_no_tags(self, mock_get_tags): ): releaser.get_latest_version() - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["v1.0", "latest"] with self.assertRaisesRegex( @@ -517,7 +519,7 @@ def test_get_latest_version_no_matching_tags(self, mock_get_tags): ): releaser.get_latest_version() - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_only_rc_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -582,8 +584,8 @@ def test_both_markers(self): self.assertEqual(next_version, "1.3.0") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_with_existing_tags( self, mock_get_tags, mock_get_branch ): @@ -594,8 +596,8 @@ def test_determine_next_version_on_release_branch_with_existing_tags( self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_no_tags( self, mock_get_tags, mock_get_branch ): @@ -606,8 +608,8 @@ def test_determine_next_version_on_release_branch_no_tags( self.assertEqual(next_version, "0.38.0") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_with_active_rc( self, mock_get_tags, mock_get_branch ): @@ -620,8 +622,8 @@ def test_determine_next_version_on_release_branch_with_active_rc( # Should target 0.37.0, not 0.37.1 self.assertEqual(next_version, "0.37.0") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( self, mock_get_tags, mock_get_branch ): @@ -634,7 +636,7 @@ def test_determine_next_version_on_release_branch_with_stable_and_active_patch_r # Should target 0.37.1, not 0.37.2 self.assertEqual(next_version, "0.37.1") - @patch("tools.private.release.release._get_current_branch") + @patch("tools.private.release.release.git.get_current_branch") def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): mock_get_branch.return_value = "main" # Should fallback to default behavior (which uses mock_get_latest_version from setUp) diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 0afb23962e..747cb74e10 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -9,7 +9,12 @@ py_library( py_binary( name = "release", - srcs = ["release.py"], + srcs = [ + "gh.py", + "git.py", + "release.py", + "utils.py", + ], main = "release.py", deps = [ ":changelog_news", diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py new file mode 100644 index 0000000000..9fa94eee20 --- /dev/null +++ b/tools/private/release/gh.py @@ -0,0 +1,184 @@ +"""GitHub CLI helper functions for the release tool.""" + +import json +import os +import tempfile + +from tools.private.release.utils import run_cmd + + +def get_open_tracking_issues(): + """Returns a list of open tracking issues with the 'type:release' label.""" + output = run_cmd( + "gh", + "issue", + "list", + "--label=type:release", + "--state=open", + "--json=number,title,url", + ) + return json.loads(output) if output else [] + + +def resolve_issue_number(version): + """Resolves the tracking issue number for a given version. + + Searches for an open issue with label 'type:release' and 'Release ' in the title. + Raises ValueError if 0 or multiple issues are found. + """ + matching_issues = [] + for issue in get_open_tracking_issues(): + if f"Release {version}" in issue["title"]: + matching_issues.append(issue) + + if not matching_issues: + raise ValueError(f"No open tracking issue found matching 'Release {version}'") + if len(matching_issues) > 1: + urls = [issue["url"] for issue in matching_issues] + raise ValueError( + f"Multiple open tracking issues found for version {version}:\n" + + "\n".join(urls) + ) + + return matching_issues[0]["number"] + + +def create_tracking_issue(version, template_content): + """Creates a new release tracking issue from template content (strips YAML frontmatter).""" + # Strip YAML frontmatter if present + issue_body = template_content + if template_content.startswith("---"): + parts = template_content.split("---", 2) + if len(parts) >= 3: + issue_body = parts[2].strip() + + # Write body to a secure temporary file to pass to the CLI + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(issue_body) + temp_path = f.name + + try: + output = run_cmd( + "gh", + "issue", + "create", + f"--title=Release {version}", + "--label=type:release", + f"--body-file={temp_path}", + ) + issue_url = output.strip() + issue_num = int(issue_url.split("/")[-1]) + return issue_num + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def get_issue_body(issue_num): + """Fetches the body of a specific issue.""" + return run_cmd( + "gh", + "issue", + "view", + str(issue_num), + "--json=body", + "--jq=.body", + ) + + +def get_issue_title(issue_num): + """Fetches the title of a specific issue.""" + output = run_cmd( + "gh", + "issue", + "view", + str(issue_num), + "--json=title", + ) + return json.loads(output)["title"] if output else "" + + +def update_issue_body(issue_num, body): + """Updates the body of a specific issue.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(body) + temp_path = f.name + try: + run_cmd( + "gh", + "issue", + "edit", + str(issue_num), + f"--body-file={temp_path}", + capture_output=False, + ) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def create_pr(version, branch, issue_num): + """Creates a pull request for release preparation.""" + return run_cmd( + "gh", + "pr", + "create", + f"--title=Prepare release v{version}", + f"--body=Work towards #{issue_num}", + f"--head={branch}", + "--base=main", + "--label=release-prepared", + ) + + +def get_pr_info(pr_num): + """Gets information about a PR, including state, merge commit, and body.""" + output = run_cmd( + "gh", + "pr", + "view", + str(pr_num), + "--json=state,mergeCommit,body", + ) + return json.loads(output) if output else {} + + +def post_issue_comment(issue_num, comment_body): + """Posts a comment to a specific issue.""" + run_cmd( + "gh", + "issue", + "comment", + str(issue_num), + f"--body={comment_body}", + capture_output=False, + ) + + +def resolve_backport_commits(pending_items): + """Resolves PR references in pending backports to their merge commit SHAs. + + Marks unmerged PRs or resolution failures with status='unmerged-pr'. + """ + resolved_items = [] + for item in pending_items: + pr_num = item["pr_ref"].lstrip("#") + print(f"Resolving PR #{pr_num} to merge commit...") + try: + pr_info = get_pr_info(pr_num) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"PR #{pr_num} is not merged (state: {state}). Gating.") + item["status"] = "unmerged-pr" + else: + merge_commit = pr_info.get("mergeCommit") + if merge_commit and "oid" in merge_commit: + item["commit"] = merge_commit["oid"] + else: + print(f"PR #{pr_num} has no merge commit SHA. Gating.") + item["status"] = "unmerged-pr" + except Exception as e: + print(f"Error resolving PR #{pr_num}: {e}. Gating.") + item["status"] = "unmerged-pr" + resolved_items.append(item) + return resolved_items diff --git a/tools/private/release/git.py b/tools/private/release/git.py new file mode 100644 index 0000000000..9bfd905109 --- /dev/null +++ b/tools/private/release/git.py @@ -0,0 +1,126 @@ +"""Git helper functions for the release tool.""" + +import subprocess + +from tools.private.release.utils import run_cmd + + +def get_tags(): + """Returns a list of all git tags in the repository.""" + output = run_cmd("git", "tag") + return output.splitlines() if output else [] + + +def checkout(ref, create_branch=False): + """Checks out a git reference (tag, branch, or commit).""" + if create_branch: + run_cmd("git", "checkout", "-b", ref, capture_output=False) + else: + run_cmd("git", "checkout", ref, capture_output=False) + + +def add(*files): + """Stages files for commit.""" + run_cmd("git", "add", *files, capture_output=False) + + +def commit(message, amend=False, no_edit=False): + """Commits staged changes, optionally amending the previous commit.""" + cmd = ["git", "commit"] + if amend: + cmd.append("--amend") + if no_edit: + cmd.append("--no-edit") + if message: + cmd.extend(["-m", message]) + run_cmd(*cmd, capture_output=False) + + +def push(remote, ref): + """Pushes a reference to a remote repository.""" + run_cmd("git", "push", remote, ref, capture_output=False) + + +def fetch(remote="origin", tags=False, force=False): + """Fetches updates from a remote repository.""" + cmd = ["git", "fetch", remote] + if tags: + cmd.append("--tags") + if force: + cmd.append("--force") + run_cmd(*cmd, capture_output=False) + + +def merge(commit_ref, ff_only=True): + """Merges a commit into the current branch.""" + cmd = ["git", "merge", commit_ref] + if ff_only: + cmd.append("--ff-only") + run_cmd(*cmd, capture_output=False) + + +def tag(tag_name): + """Creates a local tag pointing to HEAD.""" + run_cmd("git", "tag", tag_name, capture_output=False) + + +def cherry_pick(sha): + """Cherry-picks a commit using -x to append the original commit info.""" + run_cmd("git", "cherry-pick", "-x", sha, capture_output=False) + + +def cherry_pick_abort(): + """Aborts an in-progress cherry-pick operation.""" + run_cmd("git", "cherry-pick", "--abort", capture_output=False) + + +def status(): + """Returns the output of git status --porcelain.""" + return run_cmd("git", "status", "--porcelain") + + +def get_commit_sha(ref="HEAD", short=False): + """Returns the commit SHA of a given reference.""" + cmd = ["git", "rev-parse"] + if short: + cmd.append("--short") + cmd.append(ref) + return run_cmd(*cmd) + + +def branch_exists(branch_name): + """Returns True if a local branch exists.""" + try: + run_cmd("git", "show-ref", "--verify", f"refs/heads/{branch_name}") + return True + except subprocess.CalledProcessError: + return False + + +def tag_exists(tag_name): + """Returns True if a local tag exists.""" + try: + run_cmd("git", "show-ref", "--verify", f"refs/tags/{tag_name}") + return True + except subprocess.CalledProcessError: + return False + + +def sort_commits_chronologically(shas): + """Sorts a list of commit SHAs chronologically (oldest first).""" + output = run_cmd("git", "log", "--no-walk", "--reverse", "--format=%H", *shas) + return output.splitlines() if output else [] + + +def get_tags_at_head(): + """Returns a list of tags pointing at the current HEAD commit.""" + output = run_cmd("git", "tag", "--points-at", "HEAD") + return output.splitlines() if output else [] + + +def get_current_branch(): + """Returns the current git branch name, or None if not in a git repo.""" + try: + return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") + except subprocess.CalledProcessError: + return None diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 9cd949ae99..0e509f1bf3 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -4,12 +4,15 @@ import datetime import fnmatch import os +import pathlib import re -import subprocess +import sys from packaging.version import parse as parse_version -from tools.private.release import changelog_news +from tools.private.release import changelog_news, gh, git + +_REPO_URL = "https://github.com/bazel-contrib/rules_python" _EXCLUDE_PATTERNS = [ "./.git/*", @@ -23,6 +26,8 @@ "./tests/tools/private/release/*", ] +_RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) + def _iter_version_placeholder_files(): for root, dirs, files in os.walk(".", topdown=True): @@ -44,16 +49,9 @@ def _iter_version_placeholder_files(): yield filepath -def _get_git_tags(): - """Runs a git command and returns the output.""" - return subprocess.check_output(["git", "tag"]).decode("utf-8").splitlines() - - def get_latest_version(): """Gets the latest version from git tags.""" - tags = _get_git_tags() - # The packaging module can parse PEP440 versions, including RCs. - # It has a good understanding of version precedence. + tags = git.get_tags() versions = [ (tag, parse_version(tag)) for tag in tags @@ -68,15 +66,24 @@ def get_latest_version(): if latest_version.is_prerelease: raise ValueError(f"The latest version is a pre-release version: {latest_tag}") - # After all that, we only want to consider stable versions for the release. stable_versions = [tag for tag, version in versions if not version.is_prerelease] if not stable_versions: raise ValueError("No stable git tags found matching X.Y.Z format.") - # The versions are already sorted, so the last one is the latest. return stable_versions[-1] +def get_latest_rc_tag(version): + """Queries git tags and returns the highest RC tag for the version.""" + tags = git.get_tags() + pattern = rf"^v{re.escape(version)}-rc\d+$" + rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] + if not rc_tags: + return None + rc_tags.sort(key=parse_version) + return rc_tags[-1] + + def should_increment_minor(): """Checks if the minor version should be incremented.""" for filepath in _iter_version_placeholder_files(): @@ -84,7 +91,6 @@ def should_increment_minor(): with open(filepath, "r") as f: content = f.read() except (IOError, UnicodeDecodeError): - # Ignore binary files or files with read errors continue if "VERSION_NEXT_FEATURE" in content: @@ -92,25 +98,10 @@ def should_increment_minor(): return False -def _get_current_branch(): - """Returns the current git branch name, or None if not in a git repo.""" - try: - return ( - subprocess.check_output( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - stderr=subprocess.DEVNULL, - ) - .decode("utf-8") - .strip() - ) - except subprocess.CalledProcessError: - return None - - def determine_next_version(branch_name=None): """Determines the next version based on git tags and the current branch.""" if branch_name is None: - branch_name = _get_current_branch() + branch_name = git.get_current_branch() if branch_name: release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name) @@ -122,11 +113,7 @@ def determine_next_version(branch_name=None): f" {branch_major}.{branch_minor}.x)" ) - # Find all stable tags matching this major.minor prefix. - # Crucially, we ignore release candidates (RCs) here. If an RC is active - # (e.g. 0.37.0-rc0 exists but 0.37.0 stable does not), we want to continue - # targeting 0.37.0, NOT increment to 0.37.1. - tags = _get_git_tags() + tags = git.get_tags() matching_patches = [] for tag in tags: tag = tag.strip() @@ -144,8 +131,6 @@ def determine_next_version(branch_name=None): ) return next_version else: - # No stable tags exist yet for this release branch (preparing X.Y.0, - # even if X.Y.0-rcN tags already exist) next_version = f"{branch_major}.{branch_minor}.0" print( f"No stable tags found for {branch_major}.{branch_minor}.x." @@ -153,7 +138,6 @@ def determine_next_version(branch_name=None): ) return next_version - # Fallback to default behavior (for main branch or other development branches) latest_version = get_latest_version() major, minor, patch = [int(n) for n in latest_version.split(".")] @@ -170,7 +154,6 @@ def replace_version_next(version): with open(filepath, "r") as f: content = f.read() except (IOError, UnicodeDecodeError): - # Ignore binary files or files with read errors continue if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content: @@ -188,43 +171,793 @@ def _semver_type(value): return value +# ============================================================================== +# Checklist Parser and Formatter (Using new | key=value syntax) +# ============================================================================== + + +def parse_metadata_line(line): + """Parses a checklist line with optional | key=value metadata.""" + match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line) + if not match: + return None + + checked = match.group(1).lower() == "x" + name = match.group(2).strip() + metadata_str = match.group(3) + + metadata = {} + if metadata_str: + pairs = metadata_str.strip().split() + for pair in pairs: + if "=" in pair: + k, v = pair.split("=", 1) + metadata[k] = v + + return { + "checked": checked, + "name": name, + "metadata": metadata, + "original_line": line, + } + + +def format_metadata_line(checked, name, metadata): + """Formats a checklist line with space-separated key=value metadata.""" + check_str = "x" if checked else " " + if not metadata: + return f"- [{check_str}] {name}" + + metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items()) + return f"- [{check_str}] {name} | {metadata_str}" + + +def update_task_in_body(body, task_name, checked, metadata): + """Updates a specific task's checked state and metadata in the issue body.""" + lines = body.splitlines() + updated_lines = [] + found = False + + for line in lines: + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == task_name.lower(): + updated_lines.append(format_metadata_line(checked, task_name, metadata)) + found = True + else: + updated_lines.append(line) + + if not found: + raise ValueError(f"Task '{task_name}' not found in issue body.") + + return "\n".join(updated_lines) + + +def parse_checklist_state(body): + """Parses the main checklist tasks and their metadata.""" + state = { + "prepare_release": { + "checked": False, + "status": None, + "pr": None, + "commit": None, + }, + "create_branch": { + "checked": False, + "status": None, + "branch": None, + "commit": None, + }, + "tag_final": {"checked": False, "status": None, "tag": None, "commit": None}, + "rc_tags": {}, # Dynamically mapped: int -> metadata dict + } + + lines = body.splitlines() + for line in lines: + parsed = parse_metadata_line(line) + if not parsed: + continue + + name = parsed["name"].strip() + meta = parsed["metadata"] + checked = parsed["checked"] + name_lower = name.lower() + + if "prepare release" in name_lower: + state["prepare_release"] = { + "checked": checked, + "status": meta.get("status"), + "pr": meta.get("pr"), + "commit": meta.get("commit"), + } + elif "create release branch" in name_lower: + state["create_branch"] = { + "checked": checked, + "status": meta.get("status"), + "branch": meta.get("branch"), + "commit": meta.get("commit"), + } + elif "tag final" in name_lower: + state["tag_final"] = { + "checked": checked, + "status": meta.get("status"), + "tag": meta.get("tag"), + "commit": meta.get("commit"), + } + else: + # Match Tag RC + rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE) + if rc_match: + rc_num = int(rc_match.group(1)) + state["rc_tags"][rc_num] = { + "checked": checked, + "status": meta.get("status"), + "tag": meta.get("tag"), + "commit": meta.get("commit"), + } + + return state + + +def parse_backports(body): + """Parses the ## Backports checklist section.""" + body = body.replace("\r\n", "\n") + match = re.search( + r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE + ) + if not match: + return [] + + section_content = match.group(1) + items = [] + lines = section_content.splitlines() + + for line in lines: + parsed = parse_metadata_line(line) + if parsed: + items.append( + { + "pr_ref": parsed["name"], + "checked": parsed["checked"], + "status": parsed["metadata"].get("status", "PENDING"), + "rc": parsed["metadata"].get("rc"), + "commit": parsed["metadata"].get("commit"), + "metadata": parsed["metadata"], + } + ) + return items + + +# ============================================================================== +# Subcommand Execution Functions +# ============================================================================== + + +def cmd_determine_next_version(args): + """Executes the determine-next-version subcommand.""" + version = determine_next_version() + print(version) + return 0 + + +def cmd_create_release_issue(args): + """Executes the create-release-issue subcommand.""" + version = args.version + if version is None: + version = determine_next_version() + + # Concurrency check + open_issues = gh.get_open_tracking_issues() + if open_issues: + print("Error: A release is already in progress. Active tracking issues:") + for issue in open_issues: + print(f"- {issue['title']}: {issue['url']}") + return 1 + + template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md") + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + template_content = template_path.read_text(encoding="utf-8") + + issue_num = gh.create_tracking_issue(version, template_content) + print(f"Created tracking issue #{issue_num} for v{version}") + return 0 + + +def cmd_prepare(args): + """Executes the prepare subcommand.""" + print("Fetching upstream to verify fresh release history...") + git.fetch(tags=True, force=True) + + # Run pre-check: verify there are no local edits + status = git.status() + if status: + print( + "Error: Local edits detected. Workspace must be completely clean" + " before running release preparation." + ) + for line in status.splitlines(): + print(f" {line}") + return 1 + print("Pre-check passed: Workspace is clean.") + + version = args.version + if version is None: + version = determine_next_version() + + print(f"Running preparation pipeline for v{version}...") + + branch_name = f"prepare-{version}" + if git.branch_exists(branch_name): + print(f"Branch {branch_name} already exists. Checking it out...") + git.checkout(branch_name) + else: + git.checkout(branch_name, create_branch=True) + + print("Updating changelog and placeholders...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + replace_version_next(version) + + modified_files = git.status() + if not modified_files: + print("No files modified by the release tool. Nothing to commit.") + return 0 + + # Stage only modified files + for line in modified_files.splitlines(): + file_path = line.strip().split()[-1] + git.add(file_path) + + git.commit(f"Prepare release {version}") + git.push("origin", branch_name) + + issue_num = args.issue + if not issue_num: + open_issues = gh.get_open_tracking_issues() + for issue in open_issues: + if f"Release {version}" in issue["title"]: + issue_num = issue["number"] + break + + if not issue_num: + print( + f"No active tracking issue found for v{version}. Creating a new one..." + ) + template_path = pathlib.Path( + ".github/ISSUE_TEMPLATE/release_tracking_template.md" + ) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + template_content = template_path.read_text(encoding="utf-8") + issue_num = gh.create_tracking_issue(version, template_content) + + print(f"Using tracking issue #{issue_num}") + + pr_url = gh.create_pr(version, branch_name, issue_num) + pr_num = pr_url.split("/")[-1] + print(f"Created Pull Request: {pr_url} (PR #{pr_num})") + + print(f"Updating tracking issue #{issue_num} checklist status to PENDING...") + body = gh.get_issue_body(issue_num) + metadata = {"status": "pending", "pr": f"#{pr_num}"} + updated_body = update_task_in_body( + body, "Prepare Release", checked=False, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Preparation pipeline completed successfully!") + return 0 + + +def cmd_complete_prepare(args): + """Executes the complete-prepare subcommand (Phase 2 PR merged).""" + print(f"Completing preparation for PR #{args.pr}...") + + pr_info = gh.get_pr_info(args.pr) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") + return 1 + + # Resolve issue number from PR body + pr_body = pr_info.get("body", "") + match = re.search(r"Work towards #(\d+)", pr_body) + if not match: + match = re.search(r"#(\d+)", pr_body) + if not match: + print( + f"Error: Could not determine tracking issue number from PR #{args.pr}" + f" body: {pr_body}" + ) + return 1 + + issue_num = int(match.group(1)) + print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") + + commit_sha = pr_info["mergeCommit"]["oid"] + short_commit = commit_sha[:8] + print(f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue...") + + # Update checklist: mark Prepare Release as done (checked) and set SUCCESS + body = gh.get_issue_body(issue_num) + metadata = {"status": "done", "pr": f"#{args.pr}", "commit": short_commit} + updated_body = update_task_in_body( + body, "Prepare Release", checked=True, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Prepare Release task marked complete successfully!") + return 0 + + +def cmd_create_release_branch(args): + """Executes the create-release-branch subcommand.""" + print(f"Evaluating branch creation for tracking issue #{args.issue}...") + body = gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"]["status"] != "done" + or not state["prepare_release"]["commit"] + ): + print( + "Error: Prepare Release task is not marked 'done' with a valid commit SHA." + ) + return 1 + + if state["create_branch"]["checked"]: + print("Release branch has already been created and checked. Skipping.") + return 0 + + # Extract version from issue title + issue_title = gh.get_issue_title(args.issue) + version_match = _RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + commit_sha = state["prepare_release"]["commit"] + print(f"Cutting branch {branch_name} from commit {commit_sha}...") + + # Create and push branch + git.fetch("origin") + git.checkout(commit_sha) + + if not git.branch_exists(branch_name): + git.checkout(branch_name, create_branch=True) + else: + git.checkout(branch_name) + git.merge(commit_sha, ff_only=True) + + git.push("origin", branch_name) + print(f"Successfully pushed branch {branch_name}") + + # Update tracking issue checklist + print("Updating tracking issue checklist...") + metadata = {"status": "done", "branch": branch_name, "commit": commit_sha[:8]} + updated_body = update_task_in_body( + body, "Create Release branch", checked=True, metadata=metadata + ) + gh.update_issue_body(args.issue, updated_body) + print("Create Release branch task marked complete successfully!") + return 0 + + +def cmd_process_backports(args): + """Executes the process-backports subcommand.""" + body = gh.get_issue_body(args.issue) + items = parse_backports(body) + + pending_items = [ + item + for item in items + if not item["checked"] and item["status"] != "merge-conflict" + ] + + if not pending_items: + print("No pending backports found.") + return 0 + + print(f"Found {len(pending_items)} pending backports to process.") + + # Determine branch name from issue title + issue_title = gh.get_issue_title(args.issue) + version_match = _RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag to write to backport metadata + git.fetch("--tags", "--force") + latest_rc = get_latest_rc_tag(version) + if not latest_rc: + next_rc_suffix = "rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_suffix = f"rc{rc_num + 1}" + + # Resolve PRs to merge commits using gh helper + resolved_items = gh.resolve_backport_commits(pending_items) + + shas = [] + sha_to_item = {} + any_failed = False + for item in resolved_items: + if item.get("commit"): + sha = item["commit"] + sha_to_item[sha] = item + shas.append(sha) + else: + any_failed = True + body = update_task_in_body( + body, + item["pr_ref"], + checked=False, + metadata={"status": item.get("status", "failed")}, + ) + gh.update_issue_body(args.issue, body) + + if not shas: + print("No valid merge commits to process.") + if any_failed: + return 1 + return 0 + + # Sort chronologically using git helper + sorted_shas = git.sort_commits_chronologically(shas) + + git.fetch("origin") + git.checkout(branch_name) + + for sha in sorted_shas: + item = sha_to_item[sha] + print(f"Cherry-picking {sha} (PR {item['pr_ref']})...") + try: + git.cherry_pick(sha) + + # Perform news processing (merging news/ files into the changelog) + print(f"Merging news fragments into changelog for PR {item['pr_ref']}...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + + # Stage changelog changes and news/ deletions + git.add("CHANGELOG.md", "news/") + + # Amend cherry-pick commit to include news merging and deletions + print(f"Amending cherry-pick commit for PR {item['pr_ref']}...") + git.commit("", amend=True, no_edit=True) + + # Push amended commit + git.push("origin", branch_name) + + new_sha = git.get_commit_sha("HEAD", short=True) + metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} + body = update_task_in_body( + body, item["pr_ref"], checked=True, metadata=metadata + ) + gh.update_issue_body(args.issue, body) + print(f"Applied: SUCCESS {new_sha}") + except Exception as e: + print(f"Conflict or error on {sha}: {e}. Aborting.") + try: + git.cherry_pick_abort() + except Exception: + pass + any_failed = True + + body = update_task_in_body( + body, + item["pr_ref"], + checked=False, + metadata={"status": "merge-conflict"}, + ) + gh.update_issue_body(args.issue, body) + print("Updated backport item to status=merge-conflict (unchecked)") + + if any_failed: + print("One or more cherry-picks/resolutions failed.") + return 1 + print("All backports successfully processed!") + return 0 + + +def cmd_create_rc(args): + """Executes the create-rc subcommand.""" + body = gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"]["status"] != "done" + or state["create_branch"]["status"] != "done" + ): + print( + "Error: Preconditions not met (release must be prepared and branch created)." + ) + return 1 + + # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done + backports = parse_backports(body) + conflicting_or_pending = [ + b for b in backports if not b["checked"] or b["status"] != "done" + ] + if conflicting_or_pending: + print( + f"Gating RC tagging: {len(conflicting_or_pending)} backports are still" + " unfinished, failed, or in conflict." + ) + return 1 + + # Resolve version and branch + issue_title = gh.get_issue_title(args.issue) + version_match = _RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag + git.fetch("--tags", "--force") + latest_rc = get_latest_rc_tag(version) + + if not latest_rc: + next_rc_num = 0 + next_rc = f"v{version}-rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_num = rc_num + 1 + next_rc = f"v{version}-rc{next_rc_num}" + + # Precheck: next RC number must exist and be unchecked in the checklist + rc_tags = state.get("rc_tags", {}) + if next_rc_num not in rc_tags: + print( + f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" + f" to cut v{version}-rc{next_rc_num}." + ) + return 1 + + target_rc_task = rc_tags[next_rc_num] + if target_rc_task["checked"] or target_rc_task["status"] == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist." + ) + return 1 + + # Verify HEAD is not already tagged + git.checkout(branch_name) + head_tags = git.get_tags_at_head() + if any(tag.startswith(f"v{version}-rc") for tag in head_tags): + print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") + return 0 + + print(f"Tagging and pushing next RC: {next_rc}...") + git.tag(next_rc) + git.push("origin", next_rc) + + commit_sha = git.get_commit_sha("HEAD") + + # Check off the appropriate "Tag RC{N}" task in the checklist + print(f"Checking off Tag RC{next_rc_num} task...") + metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} + task_name = f"Tag RC{next_rc_num}" + updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) + gh.update_issue_body(args.issue, updated_body) + + tag_url = f"{_REPO_URL}/releases/tag/{next_rc}" + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" + comment_body = f"""🚀 **New Release Candidate Tagged!** + +Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. + +View Tag: [{next_rc}]({tag_url}) +Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})""" + gh.post_issue_comment(args.issue, comment_body) + print("RC creation completed successfully!") + return 0 + + +def cmd_promote_rc(args): + """Executes the promote-rc subcommand (Phase 3).""" + version = args.version + if version is None: + version = determine_next_version() + version = version.replace("v", "") + final_tag = f"v{version}" + + git.fetch("--tags", "--force") + latest_rc = get_latest_rc_tag(version) + if not latest_rc: + print(f"Error: No release candidate tags found matching v{version}-rc*") + return 1 + + print(f"Promoting {latest_rc} to final release {final_tag}...") + git.checkout(latest_rc) + + commit_sha = git.get_commit_sha("HEAD") + + if not git.tag_exists(final_tag): + git.tag(final_tag) + git.push("origin", final_tag) + else: + print(f"Final tag {final_tag} already exists.") + + # Resolve issue number + issue_num = args.issue + if not issue_num: + try: + issue_num = gh.resolve_issue_number(version) + except Exception as e: + print(f"Warning: Could not query GitHub to find tracking issue: {e}") + + if issue_num: + print(f"Updating tracking issue #{issue_num} checklist...") + body = gh.get_issue_body(issue_num) + metadata = {"status": "done", "tag": final_tag, "commit": commit_sha[:8]} + updated_body = update_task_in_body( + body, "Tag Final", checked=True, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Checklist updated successfully.") + return 0 + else: + print( + "Error: No active tracking issue found or specified. Checklist was not updated." + ) + return 1 + + def create_parser(): - """Creates the argument parser.""" + """Creates the argument parser with subcommands.""" parser = argparse.ArgumentParser( description="Automate release steps for rules_python." ) - parser.add_argument( + + subparsers = parser.add_subparsers( + dest="command", required=True, help="Subcommands" + ) + + # Subcommand: determine-next-version + subparsers.add_parser( + "determine-next-version", + help="Determine the next version and print it, without making any changes.", + ) + + # Subcommand: create-release-issue + create_issue_parser = subparsers.add_parser( + "create-release-issue", + help="Search for open releases and create a new tracking issue.", + ) + create_issue_parser.add_argument( + "--version", + type=_semver_type, + help="The release version (e.g., 0.38.0). If not provided, determined automatically.", + ) + + # Subcommand: prepare + prepare_parser = subparsers.add_parser( + "prepare", + help="Prepare the release (updates changelog, placeholders).", + ) + prepare_parser.add_argument( "version", nargs="?", type=_semver_type, help="The new release version (e.g., 0.28.0). If not provided, " "it will be determined automatically.", ) + prepare_parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional, triggers automated branch/PR pipeline).", + ) + + # Subcommand: complete-prepare + complete_prep_parser = subparsers.add_parser( + "complete-prepare", + help="Mark the Prepare Release task as complete in the tracking issue.", + ) + complete_prep_parser.add_argument( + "--pr", + type=int, + required=True, + help="The merged preparation PR number.", + ) + + # Subcommand: create-release-branch + create_branch_parser = subparsers.add_parser( + "create-release-branch", + help="Create the release branch pointing to the merged PR commit.", + ) + create_branch_parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + + # Subcommand: process-backports + process_backports_parser = subparsers.add_parser( + "process-backports", + help="Cherry-pick pending backports listed in the tracking issue.", + ) + process_backports_parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + + # Subcommand: create-rc + create_rc_parser = subparsers.add_parser( + "create-rc", + help="Tags the next RC on the release branch if no backports remain.", + ) + create_rc_parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + + # Subcommand: promote-rc + promote_parser = subparsers.add_parser( + "promote-rc", + help="Promote the latest RC to final release.", + ) + promote_parser.add_argument( + "version", + nargs="?", + type=_semver_type, + help="The final version to release (e.g., 0.38.0).", + ) + promote_parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional).", + ) + return parser def main(): - # Change to the workspace root so the script can be run using `bazel run` if "BUILD_WORKSPACE_DIRECTORY" in os.environ: os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"]) parser = create_parser() args = parser.parse_args() - version = args.version - if version is None: - print("No version provided, determining next version automatically...") - version = determine_next_version() - print(f"Determined next version: {version}") - - print("Updating changelog ...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - - print("Replacing VERSION_NEXT placeholders ...") - replace_version_next(version) - - print("Done") + exit_code = 1 + try: + if args.command == "determine-next-version": + exit_code = cmd_determine_next_version(args) + elif args.command == "create-release-issue": + exit_code = cmd_create_release_issue(args) + elif args.command == "prepare": + exit_code = cmd_prepare(args) + elif args.command == "complete-prepare": + exit_code = cmd_complete_prepare(args) + elif args.command == "create-release-branch": + exit_code = cmd_create_release_branch(args) + elif args.command == "process-backports": + exit_code = cmd_process_backports(args) + elif args.command == "create-rc": + exit_code = cmd_create_rc(args) + elif args.command == "promote-rc": + exit_code = cmd_promote_rc(args) + except Exception as e: + print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) + sys.exit(1) + + sys.exit(exit_code if exit_code is not None else 0) if __name__ == "__main__": diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py new file mode 100644 index 0000000000..5a8411b657 --- /dev/null +++ b/tools/private/release/utils.py @@ -0,0 +1,28 @@ +"""Utility functions for the release tool.""" + +import subprocess + + +def run_cmd(*args, check=True, capture_output=True): + """Runs a command as a subprocess with separate arguments (prints command). + + If the command fails, it raises the CalledProcessError after attaching + a detailed note explaining the failure to preserve the stack trace. + """ + cmd = [str(arg) for arg in args] + print(f"Running: {' '.join(cmd)}") + try: + result = subprocess.run( + cmd, + check=check, + stdout=subprocess.PIPE if capture_output else None, + stderr=subprocess.PIPE if capture_output else None, + universal_newlines=True, + ) + return result.stdout.strip() if capture_output else None + except subprocess.CalledProcessError as e: + note = f"Error running command: {' '.join(cmd)}" + if capture_output: + note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" + e.add_note(note) + raise From 913201d0dc94dbc331b92a69e5d0e7aad33dcd65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:46:00 -0700 Subject: [PATCH 026/179] build(deps): bump actions/checkout from 6 to 7 (#3843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
Release notes

Sourced from actions/checkout's releases.

v7.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v6.0.3...v7.0.0

v6.0.3

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.3

v6.0.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v6.0.1...v6.0.2

v6.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.1

Changelog

Sourced from actions/checkout's changelog.

Changelog

v7.0.0

v6.0.3

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e324bfbf2a..daff8938e7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: # Checkout the code - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: jpetrucciani/mypy-check@master with: path: 'python/runfiles' @@ -31,7 +31,7 @@ jobs: ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: astral-sh/ruff-action@v4.0.0 with: # Keep in sync with .pre-commit-config.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e4f26981b..039e56a2b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.ref_name }} - name: Create release archive and notes @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.tag_name || github.ref_name }} - if: github.event_name == 'push' || github.event.inputs.publish_to_pypi From 038467656bf1649b53200c95f431ada4e7475c2b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 23 Jun 2026 22:26:28 -0700 Subject: [PATCH 027/179] chore: remove accidentally committed release automation plan (#3846) This file was not intended to be tracked in the repository, so it has been removed. --- .agents/plans/release_automation_plan.md | 186 ----------------------- 1 file changed, 186 deletions(-) delete mode 100644 .agents/plans/release_automation_plan.md diff --git a/.agents/plans/release_automation_plan.md b/.agents/plans/release_automation_plan.md deleted file mode 100644 index 90d87bc2a3..0000000000 --- a/.agents/plans/release_automation_plan.md +++ /dev/null @@ -1,186 +0,0 @@ -# Plan for Better `rules_python` Release Automation - -The current release process (as described in `RELEASING.md`) has good -automation *after* a tag is pushed (`release.yml` handles GitHub Release, BCR -PR, and PyPI publishing). However, the steps *leading up* to the tag push are -largely manual. - -This plan outlines a **code-centric, reactive architecture** to automate the -manual preparation, branching, and tagging phases. By moving all core Git and -GitHub CLI logic out of the YAML workflow files and into the Python release -tool (`release.py`), we ensure the release process is robust, locally testable, and -independent of GitHub Actions runner syntax. - -## Prerequisite: Release Tracking Issue - -Every release is tracked by a dedicated GitHub Issue titled `Release ` -(e.g., `Release 0.38.0`), which acts as the single source of truth and state -controller. Release tracking issues are uniquely identified by the -`type:release` label. - -* **Role:** Contains a checklist of completed and remaining steps. -* **Automation Hub:** Workflows reactively trigger on tracking issue modifications. -* **Template File:** The issue body structure is decoupled from the codebase and - resides in `.github/ISSUE_TEMPLATE/release_tracking_template.md`. This is a - standard GitHub issue template equipped with frontmatter so maintainers can - easily create tracking issues manually from the GitHub web UI or let the - automation generate them programmatically. -* **Available Commands:** Placed at the very end of the issue body inside a - collapsed HTML `
` section to keep the main checklist clean. It - contains direct web links to the corresponding manual GitHub Action - workflows. - -### Tracking Issue Checklist Syntax - -The checklist uses a strict, machine-readable syntax using a pipe `|` separator -to attach space-separated metadata keys to tasks. - -#### Main Tasks Checklist - -```markdown -# Release tasks -- [ ] Prepare Release | status=pending pr=#1234 -- [ ] Create Release branch -- [ ] Tag RC0 -- [ ] Tag Final -``` - -* **Prepare Release**: - * Initial: `- [ ] Prepare Release | status=awaiting-preparation` - * Phase 1 PR created: `- [ ] Prepare Release | status=pending pr=#` - * PR merged (Phase 2): `- [x] Prepare Release | status=done pr=# commit=` -* **Create Release branch**: - * Initial: `- [ ] Create Release branch` - * Created: `- [x] Create Release branch | status=done branch=release/X.Y commit=` -* **Tag RC0**: - * Initial: `- [ ] Tag RC0` - * Tagged: `- [x] Tag RC0 | status=done tag=vX.Y.Z-rc0 commit=` -* **Tag Final**: - * Initial: `- [ ] Tag Final` - * Tagged: `- [x] Tag Final | status=done tag=vX.Y.Z commit=` - -#### Backports Checklist - -Maintainers list PRs to backport under the `## Backports` section: - -```markdown -- [x] #1234 | status=done rc=rc1 commit=deadbeef -- [ ] #2345 | status=merge-conflict -- [ ] #3456 -``` - -* **Pending:** `- [ ] #3456` (or `- [ ] #3456 | status=pending`) -* **Succeeded Cherry-pick:** `- [x] #1234 | status=done rc=rc commit=` (with checkbox marked `- [x]` to show it has been successfully completed). -* **Conflicting Cherry-pick:** `- [ ] #2345 | status=merge-conflict` (Unchecked, i.e., remains `- [ ]` to indicate it is not complete. Gates subsequent RC tags until resolved or removed). -* **Unmerged PR Error:** `- [ ] #3456 | status=unmerged-pr` (Unchecked, i.e., remains `- [ ]` to indicate it is not complete. Gates subsequent RC tags until resolved or removed). - ---- - -## Release Tool Commands - -The `release.py` script contains all execution logic. - -### 1. `determine-next-version` -* **Description:** Scans git tags and placeholders to determine the next release version. -* **Inputs:** None. -* **Outputs:** Prints the version string (e.g. `0.38.0`) to stdout. - -### 2. `create-release-issue` -* **Description:** Creates the tracking issue on GitHub using `release_tracking_template.md`. Exits with code `1` and prints a list of open tracking issue titles/URLs if a release is already in progress. -* **Inputs:** `--version ` (optional). - * *Version Resolution:* If not specified, determined automatically by calling `determine_next_version()`. - -### 3. `prepare` -* **Description:** Updates the changelog and placeholders. -* **Inputs:** `[version]` (optional), `--issue ` (optional). - * *Version Resolution:* If not specified, determined automatically by calling `determine_next_version()`. -* **Pre-checks:** - * If `--automation` is set, it first fetches upstream tags/commits and verifies that the workspace has **no uncommitted local edits**, exiting with code `1` if the workspace is dirty. -* **Automation Flag (`--automation`):** If set, it pushes to branch `prepare-{version}`, opens the preparation PR, and updates the tracking issue's `Prepare Release` task to `- [ ] Prepare Release | status=pending pr=#`. - -### 4. `complete-prepare` -* **Description:** Triggered when the prep PR merges. -* **Inputs:** `--pr ` (required), `--automation`. -* **Tracking Issue Resolution:** Automatically parses the tracking issue number - directly from the PR body (which links to the tracking issue). -* **State Updates:** - * Updates `Prepare Release` to: `status=done pr=# commit=` (checked). - -### 5. `create-release-branch` -* **Description:** Cuts the release branch. -* **Inputs:** `--issue ` (required), `--automation`. -* **State Updates:** - * Reads the `commit` SHA from the `Prepare Release` task. - * Cuts and pushes the `release/X.Y` branch. - * Updates `Create Release branch` to: `status=done branch=release/X.Y commit=` (checked). - -### 6. `process-backports` -* **Description:** Cherry-picks pending, merged backports. -* **Inputs:** `--issue ` (required), `--automation`. -* **Gating:** Resolves each backport PR. Unmerged PRs are marked as `status=unmerged-pr` (remains unchecked `- [ ]`) and the loop continues. Conflicting cherry-picks are marked as `status=merge-conflict` (remains unchecked `- [ ]`). If any PR is unmerged or cherry-pick fails with a conflict, the tool exits with code `1` at the end of the run. -* **State Updates:** - * Cherry-picks each pending, merged PR using `git cherry-pick -x` in chronological order. - * *Success:** Pushes to the release branch, updates the backport line to: `status=done rc=rc commit=` (with checkbox marked `- [x]` to show it has been successfully completed). - * *Conflict:* Aborts, updates the backport line to: `status=merge-conflict` (remains unchecked `- [ ]`). - * *Unmerged:* Updates the backport line to: `status=unmerged-pr` (remains unchecked `- [ ]`). - -### 7. `create-rc` -* **Description:** Tags the next RC. -* **Inputs:** `--issue ` (required), `--automation`. -* **Gating:** Fails if `Prepare Release` or `Create Release branch` are not `status=done`. Fails if any backport in the list is unchecked (`- [ ]`) or does not have `status=done`. -* **State Updates:** - * Queries git tags, increments to the next RC (e.g. `v0.38.0-rc0`), tags, and pushes. - * If tagging `rc0`, updates `Tag RC0` to: `status=done tag=vX.Y.Z-rc0 commit=` (checked). - * Announces the tag in an issue comment. - -### 8. `promote-rc` -* **Description:** Promotes the highest RC to final. -* **Inputs:** `[version]` (optional), `--issue ` (optional), `--automation`. - * *Version Resolution:* If not specified, determined automatically by finding the next version (which resolves to the active release version if it has not yet been tagged). - * *Issue Resolution:* If `--issue` is not specified, it searches for a single open tracking issue with the `type:release` label and matching version in the title. If zero or multiple tracking issues are found, the command errors and exits with code `1`. -* **State Updates:** - * Checks out the highest RC tag, tags `vX.Y.Z`, and pushes. - * Always attempts to update the tracking issue's `Tag Final` task to: `status=done tag=vX.Y.Z commit=` (checked), outputting a warning if the issue cannot be resolved. - ---- - -## GitHub Actions Workflows - -### 1. Prepare Release (`prepare_release.yml`) -* **Trigger:** Manual (`workflow_dispatch`). -* **Role:** Runs Phase 1. -* **Command:** `bazel run //tools/private/release -- --automation prepare`. -* **State Machine:** Transition: `Prepare Release` $\rightarrow$ `status=pending pr=#`. - -### 2. On PR Merged (`on_prepare_release_pr_merged.yml`) -* **Trigger:** `pull_request: [closed]` (merged, label `release-prepared`). -* **Role:** Completes Phase 1. -* **Command:** `bazel run //tools/private/release -- --automation complete-prepare --pr `. -* **State Machine:** Transition: `Prepare Release` $\rightarrow$ `status=done pr=# commit=` (checked). - -### 3. Cut Release Branch (`cut_release_branch.yml`) -* **Trigger:** `issues: [edited]` (filtered by label `type:release`). -* **Role:** Runs Phase 2 reactively. -* **Command:** `bazel run //tools/private/release -- --automation create-release-branch --issue `. -* **State Machine:** Transition: `Create Release branch` $\rightarrow$ `status=done branch=release/X.Y commit=` (checked). - -### 4. Process Backports (`process_backports.yml`) -* **Trigger:** Manual (`workflow_dispatch`), taking the tracking issue number. -* **Role:** Runs Phase 2.5 backport processing. -* **Command:** `bazel run //tools/private/release -- --automation process-backports --issue `. -* **State Machine:** - * Cherry-pick success: Backport PR $\rightarrow$ `status=done rc=rc commit=` (checked). - * Cherry-pick conflict: Backport PR $\rightarrow$ `status=merge-conflict` (unchecked). - * Unmerged PR error: Backport PR $\rightarrow$ `status=unmerged-pr` (unchecked). - -### 5. Generate RC Tag (`generate_rc.yml`) -* **Trigger:** Manual (`workflow_dispatch`), taking the tracking issue number. -* **Role:** Runs Phase 2.5 RC tagging. -* **Command:** `bazel run //tools/private/release -- --automation create-rc --issue `. -* **State Machine:** Transition: If tagging `rc0`, `Tag RC0` $\rightarrow$ `status=done tag=vX.Y.Z-rc0 commit=` (checked). - -### 6. Promote RC to Final (`promote_rc.yml`) -* **Trigger:** Manual (`workflow_dispatch`), taking the target version. -* **Role:** Runs Phase 3. -* **Command:** `bazel run //tools/private/release -- --automation promote-rc `. -* **State Machine:** Transition: `Tag Final` $\rightarrow$ `status=done tag=vX.Y.Z commit=` (checked). From 63f77d3fc1cf0cd5f09a4d650998dd8d335b694c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 24 Jun 2026 20:54:00 -0700 Subject: [PATCH 028/179] feat: unified pypi hub repository (#3837) This implements a pypi hub that is the union of all pypi hubs. The basic design is: * The `pip` extension _always_ creates a `@pypi` repo unless the name is already taken by another hub definition. * The `--pypi_hub` flag dispatches to one of the hubs. If not set, then it uses a default one (first, or as configured) The set of packages and targets the unified hub exposes is a union of all other hubs. If the unified hub routes to a hub that doesn't support such a target, then it points to a target that fails at execution time. This is to allow query and cquery to work even if some targets don't exist in some hubs. --- .agents/plans/pypi_hub_proxy_feature.md | 296 ++++++++++++++++++ .bazelignore | 1 + .bazelrc.deleted_packages | 1 + .../python/config_settings/index.md | 17 + docs/environment-variables.md | 13 + docs/pypi/download.md | 74 +++++ news/3837.added.md | 1 + python/config_settings/BUILD.bazel | 7 + python/features.bzl | 1 + python/private/common_labels.bzl | 1 + python/private/pypi/BUILD.bazel | 27 ++ python/private/pypi/extension.bzl | 115 ++++++- python/private/pypi/missing_package.bzl | 36 +++ python/private/pypi/unified_hub_repo.bzl | 81 +++++ python/private/pypi/unified_hub_setup.bzl | 110 +++++++ python/private/text_util.bzl | 12 + python/private/transition_labels.bzl | 1 + tests/integration/BUILD.bazel | 5 + .../bzlmod_lockfile/MODULE.bazel.lock | 2 +- tests/integration/unified_pypi/.bazelrc | 1 + tests/integration/unified_pypi/BUILD.bazel | 48 +++ tests/integration/unified_pypi/MODULE.bazel | 48 +++ tests/integration/unified_pypi/WORKSPACE | 1 + .../integration/unified_pypi/WORKSPACE.bzlmod | 1 + tests/integration/unified_pypi/bin_extra_b.py | 1 + tests/integration/unified_pypi/bin_six_a.py | 1 + .../unified_pypi/requirements_a.txt | 3 + .../unified_pypi/requirements_b.txt | 6 + tests/integration/unified_pypi/test_a.py | 3 + tests/integration/unified_pypi/test_cli.py | 3 + .../integration/unified_pypi/test_default.py | 3 + tests/integration/unified_pypi_test.py | 79 +++++ tests/pypi/extension/extension_tests.bzl | 152 +++++++++ 33 files changed, 1146 insertions(+), 5 deletions(-) create mode 100644 .agents/plans/pypi_hub_proxy_feature.md create mode 100644 news/3837.added.md create mode 100644 python/private/pypi/missing_package.bzl create mode 100644 python/private/pypi/unified_hub_repo.bzl create mode 100644 python/private/pypi/unified_hub_setup.bzl create mode 100644 tests/integration/unified_pypi/.bazelrc create mode 100644 tests/integration/unified_pypi/BUILD.bazel create mode 100644 tests/integration/unified_pypi/MODULE.bazel create mode 100644 tests/integration/unified_pypi/WORKSPACE create mode 100644 tests/integration/unified_pypi/WORKSPACE.bzlmod create mode 100644 tests/integration/unified_pypi/bin_extra_b.py create mode 100644 tests/integration/unified_pypi/bin_six_a.py create mode 100644 tests/integration/unified_pypi/requirements_a.txt create mode 100644 tests/integration/unified_pypi/requirements_b.txt create mode 100644 tests/integration/unified_pypi/test_a.py create mode 100644 tests/integration/unified_pypi/test_cli.py create mode 100644 tests/integration/unified_pypi/test_default.py create mode 100644 tests/integration/unified_pypi_test.py diff --git a/.agents/plans/pypi_hub_proxy_feature.md b/.agents/plans/pypi_hub_proxy_feature.md new file mode 100644 index 0000000000..b3360d9dbd --- /dev/null +++ b/.agents/plans/pypi_hub_proxy_feature.md @@ -0,0 +1,296 @@ +# Implementation Plan: Canonical Automatic PyPI Proxy Hub + +This document defines the locked, production-ready architectural, Starlark API, +and testing specifications for implementing dynamic PyPI dependency resolution in +`rules_python` using the `venv` flag. + +--- + +## 1. Architectural Strategy: The Canonical `@pypi` Proxy + +The `pip` bzlmod extension will automatically synthesize a canonical `@pypi` +proxy repository rule that orchestrates routing to underlying concrete hubs. + +### Bzlmod-Exclusive Scope + +The Unified PyPI Hub Proxy is an **exclusive feature of `bzlmod`**. Legacy +`WORKSPACE` evaluations using independent `pip_parse` repository macros are not +supported, as bzlmod's module extension architecture provides the required +centralized coordination to inspect and interlink cross-module hubs. + +### Automatic Proxy Construction & Collision Logic + +During the evaluation of the `pip` extension across the dependency graph: +1. **Unconditional Creation**: The extension will **always** synthesize a + proxy repository rule with the apparent name `pypi`, even if zero + `pip.parse` concrete hubs are defined in the dependency graph (in which + case the proxy is completely valid but empty). +2. **Collision Prevention**: If a user explicitly defines a concrete hub + named `pypi` (`pip.parse(hub_name = "pypi")`), the automatic proxy + synthesis is skipped so the user maintains absolute control over that + repository name. + +In `MODULE.bazel`: +```starlark +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") + +# Concrete hubs defined for different execution contexts +pip.parse(hub_name = "pypi_a", ...) +pip.parse(hub_name = "pypi_b", ...) + +# Designate 'pypi_b' as the default hub for the unified '@pypi' repository +pip.default(default_hub = "pypi_b") + +# The canonical proxy is automatically created unconditionally: +use_repo(pip, "pypi") +``` + +### Unified PyPI Hub + +The canonical `@pypi` proxy repository matches exactly how concrete hubs create +their directory structure: a root package for shared configuration settings, and +a dedicated subdirectory (subpackage) for each PyPI package. + +Here is a complete, representative code example of what the generated files in +`@pypi` will look like when resolving packages between `pypi_a` and `pypi_b`: + +#### 1. `@pypi//BUILD.bazel` (Root Package) +The root package contains the shared `config_setting` targets following the +`_is_venv_` private naming convention. Leading underscores are strictly +applied because these configuration settings are an internal implementation +detail of the proxy repository and are not intended to be a public API. + +```starlark +package(default_visibility = ["//visibility:public"]) + +config_setting( + name = "_is_venv_pypi_a", + flag_values = { + "@rules_python//python/config_settings:venv": "pypi_a", + }, +) + +config_setting( + name = "_is_venv_pypi_b", + flag_values = { + "@rules_python//python/config_settings:venv": "pypi_b", + }, +) +``` + +#### 2. `@pypi//foo/BUILD.bazel` (PyPI Package Subpackage) +Each PyPI package subpackage defines the standard aliases (`pkg`, `whl`, `data`, +`dist_info`, `extracted_wheel_files`), plus a complete **union of all custom +`extra_hub_aliases`** defined across all concrete hubs. + +Each alias resolves dynamically to the active concrete hub based on the root +private configuration settings: + +```starlark +package(default_visibility = ["//visibility:public"]) + +alias( + name = "foo", + actual = ":pkg", +) + +alias( + name = "pkg", + actual = select({ + "//:_is_venv_pypi_a": "@pypi_a//foo:pkg", + "//:_is_venv_pypi_b": "@pypi_b//foo:pkg", + # When venv is "auto" (unset), it defaults to the designated fallback + # (or first defined concrete hub). + "//conditions:default": "@pypi_b//foo:pkg", + }), +) + +alias( + name = "whl", + actual = select({ + "//:_is_venv_pypi_a": "@pypi_a//foo:whl", + "//:_is_venv_pypi_b": "@pypi_b//foo:whl", + "//conditions:default": "@pypi_b//foo:whl", + }), +) + +# ... standard aliases for data, dist_info, extracted_wheel_files ... + +# 3. Unionized custom extra alias (defined in pypi_a but missing in pypi_b): +alias( + name = "my_custom_tool", + actual = select({ + "//:_is_venv_pypi_a": "@pypi_a//foo:my_custom_tool", + # Unrepresented branch routes to execution failure target: + "//:_is_venv_pypi_b": "//:_missing_package_error_pypi_b_foo", + "//conditions:default": "@pypi_a//foo:my_custom_tool", + }), +) +``` + +### Disjoint Hub Packages & Execution-Phase Failure + +If a package exists in one concrete hub but is missing in another (e.g., `scipy` +is in `pypi_b` but not `pypi_a`), our proxy synthesizes a package subpackage for +the union of all packages. + +To ensure that `bazel cquery` and `bazel query` successfully analyze over the +entire transitive build graph without failing, unrepresented select branches +must route to a dedicated **execution-phase error rule**. + +```starlark +# In @pypi//scipy/BUILD.bazel +alias( + name = "pkg", + actual = select({ + # Routes to execution-phase action failure target: + "//:_is_venv_pypi_a": "//:_missing_package_error_pypi_a_scipy", + "//:_is_venv_pypi_b": "@pypi_b//scipy:pkg", + "//conditions:default": "@pypi_b//scipy:pkg", + }), +) +``` + +The synthesized `//:_missing_package_error_XX` rule in `@pypi//BUILD.bazel` +returns standard Starlark Python providers so analysis/cquery passes, but +registers a build action that fails when executed: + +``` +Dependency Error: Third-party package 'scipy' is not available when building under PyPI hub 'pypi_a'. +``` + +### Fallback Hub Precedence (`"auto"`) + +When a target depends on `@pypi//foo` and the active build setting is `"auto"`, +the proxy resolves to a concrete hub using the following precedence: +1. **Designated Fallback**: If the user has explicitly designated a fallback + concrete hub via `pip.default(default_hub = "...")` in their root + `MODULE.bazel`, the proxy routes to it. +2. **First Defined Hub**: If no fallback is explicitly designated via + `pip.default()`, the proxy **automatically routes to the first defined + concrete hub** parsed during extension evaluation (e.g., `pypi_a`). + +```starlark +# Explicitly override the "auto" fallback hub +pip.default( + default_hub = "pypi_b", +) +``` + +--- + +## 2. Core Rule Integration: `config_settings` Transitions + +Users will switch active hubs using the standard, highly generic +`config_settings` transition attribute on executable targets. + +### Build Setting Definition + +In `python/config_settings/BUILD.bazel`: + +```starlark +string_flag( + name = "venv", + build_setting_default = "auto", # Default value is "auto" + visibility = ["//visibility:public"], +) +``` + +In `python/private/common_labels.bzl`: +```starlark + VENV = str(Label("//python/config_settings:venv")), +``` + +In `python/private/transition_labels.bzl`: +```starlark +_BASE_TRANSITION_LABELS = [ + # ... existing transition labels ... + labels.VENV, +] +``` + +Because `py_binary` and `py_test` implement an incoming transition +(`_transition_executable_impl`) that automatically processes any +`config_settings` keys matching `TRANSITION_LABELS`, **this provides complete +transition capabilities with zero changes to our core rule definitions**. + +### Usage in BUILD.bazel + +Libraries consume packages through the canonical proxy: + +```starlark +py_library( + name = "common", + deps = ["@pypi//foo"], # Apparent proxy repository +) +``` + +Binaries change the active hub by transitioning the build setting: + +```starlark +# Resolves @pypi -> pypi_b (default hub / designated fallback) +py_binary( + name = "bin_default", + deps = [":common"], +) + +# Resolves @pypi -> pypi_a via transition +py_binary( + name = "bin_a", + deps = [":common"], + config_settings = { + "//python/config_settings:venv": "pypi_a", + }, +) +``` + +### Analysis Cache & Memory Best Practices + +Because transitions fork the Bazel configuration, building targets with highly +diversified `config_settings` across large build graphs will result in +re-analysis and re-compilation of shared dependencies. + +We will include explicit documentation guidelines advising users to keep their +`venv` transition configurations localized and minimized to preserve Bazel +caching and memory efficiency. + +--- + +## 3. Integration Testing Specification + +We will construct a comprehensive Bazel-in-Bazel integration test suite in +`tests/integration/unified_pypi/` to guarantee correctness and verify +transitions. + +The integration test suite will assert: +1. **`"auto"` Precedence**: Author a test asserting `bazel run //:bin_default` + correctly inherits `"auto"` and resolves dependencies from the designated fallback. +2. **Transitional Resolution**: Author a test asserting two binary targets in + the same package with different `config_settings` successfully resolve + dependencies and execute against their respective concrete hubs (`pypi_a` + vs `pypi_b`). +3. **Command Line Override**: Author a test asserting + `bazel run --//python/config_settings:venv=pypi_a //:bin_default` + successfully forces the executable to run using imports resolved from + `pypi_a`. +4. **Disjoint Execution Failure**: Author a test asserting `bazel cquery` over + a target depending on an unrepresented missing package succeeds, while + `bazel run` on that target gracefully fails during execution with the exact + synthesized error message. +5. **Unionized Extra Hub Aliases**: Author a test asserting that a binary + successfully runs using a custom `extra_hub_aliases` target resolved + through the `@pypi proxy`. + +--- + +## 4. Execution Steps + +1. **Phase 1**: Define `venv` `string_flag` and register it in + `common_labels.bzl` and `transition_labels.bzl`. +2. **Phase 2**: Update `python/private/pypi/extension.bzl` to synthesize the + canonical `pypi` proxy repository rule. +3. **Phase 3**: Implement `missing_package_error` execution failure rule and + the `proxy_hub_repository` generation logic. +4. **Phase 4**: Author the Bazel-in-Bazel integration test suite in + `tests/integration/unified_pypi/`. +5. **Phase 5**: Run all tests and verify full pass before PR submission. diff --git a/.bazelignore b/.bazelignore index 2cf1523aef..5c3bb7caea 100644 --- a/.bazelignore +++ b/.bazelignore @@ -35,4 +35,5 @@ tests/integration/compile_pip_requirements/bazel-compile_pip_requirements tests/integration/local_toolchains/bazel-local_toolchains tests/integration/py_cc_toolchain_registered/bazel-py_cc_toolchain_registered tests/integration/toolchain_target_settings/bazel-module_under_test +tests/integration/unified_pypi/bazel-unified_pypi tests/integration/uv_lock/bazel-uv_lock diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index 407fd1cb48..ce42333e6f 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -40,6 +40,7 @@ common --deleted_packages=tests/integration/pip_parse_isolated common --deleted_packages=tests/integration/py_cc_toolchain_registered common --deleted_packages=tests/integration/runtime_manifests common --deleted_packages=tests/integration/toolchain_target_settings +common --deleted_packages=tests/integration/unified_pypi common --deleted_packages=tests/integration/uv_lock common --deleted_packages=tests/modules/another_module common --deleted_packages=tests/modules/other diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md index cd3cbc9829..7908a28190 100644 --- a/docs/api/rules_python/python/config_settings/index.md +++ b/docs/api/rules_python/python/config_settings/index.md @@ -350,6 +350,22 @@ Values: :::: +::::{bzl:flag} venv +Determines which PyPI repository hub is used when resolving package dependencies. + +This flag is transitioned on automatically by executable targets (`py_binary`, `py_test`) +to select the appropriate concrete PyPI hub (e.g., when fallback or disjoint packages exist across multiple hubs). + +Values: +* `auto`: (default) Resolves dependencies using the fallback or first available hub. +* ``: Explicitly forces resolution of packages from the + specified concrete PyPI hub (corresponding to a + {obj}`pip.parse.hub_name` value). + +:::{versionadded} VERSION_NEXT_FEATURE +::: +:::: + ::::{bzl:flag} venvs_use_declare_symlink Determines if relative symlinks are created using `declare_symlink()` at build @@ -373,6 +389,7 @@ is created. ::: :::: + ## Removed Flags :::{versionremoved} 2.1.0 diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 6c3444cba3..983ae3cb5f 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -118,6 +118,19 @@ which will effectively disable pyc caching. ::: +:::{envvar} RULES_PYTHON_PYPI_HUB_RESERVED + +When `1`, any PyPI hub named `"pypi"` will be renamed to `_pypi` +to prevent name collisions with the unified `@pypi` proxy repository, and +a warning is printed indicating that the renaming occurred. If not set (defaulting +to `0`), a warning is printed advising to rename the hub, and the collision +is not resolved. + +:::{versionadded} VERSION_NEXT_FEATURE +::: + +::: + :::{envvar} RULES_PYTHON_REPO_DEBUG When `1`, repository rules will print debug information about what they're diff --git a/docs/pypi/download.md b/docs/pypi/download.md index f0e70cf850..6705df1f3a 100644 --- a/docs/pypi/download.md +++ b/docs/pypi/download.md @@ -50,6 +50,79 @@ You can use the pip extension multiple times. This configuration will create multiple external repos that have no relation to one another and may result in downloading the same wheels numerous times. +(unified-pypi-hub)= +## Unified `@pypi` Hub for Multi-Hub Configurations + +:::{versionadded} VERSION_NEXT_FEATURE +Unified `@pypi` hub repository for Bzlmod multi-hub configurations. +::: + +When you call the `pip` extension multiple times with different `hub_name` +attributes, `rules_python` automatically generates a unified `@pypi` hub +repository (unless one of your concrete hubs is explicitly named `"pypi"`). + +This unified `@pypi` repository acts as a dynamic proxy that routes package +dependencies to the active concrete hub at build time. This is especially +useful in monorepos where shared library targets need to depend on PyPI +packages without knowing which specific hub or requirements lock file the +consuming binary is using. + +#### Reserved `"pypi"` Hub Name + +The hub name `"pypi"` is **reserved** for the automatically generated unified +hub repository. Defining a concrete hub named `"pypi"` will cause a collision. + +For details on how this collision is handled and resolved via environment +variables, see the {envvar}`RULES_PYTHON_PYPI_HUB_RESERVED` documentation. + +#### Configuring the Unified Hub + +To configure the unified hub, define your concrete hubs as usual, and +optionally designate a default hub using the `pip.default` tag's +`default_hub` attribute: + +```starlark +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") + +# Define concrete hub 'pypi_a' +pip.parse( + hub_name = "pypi_a", + python_version = "3.11", + requirements_lock = "//:requirements_a.txt", +) + +# Define concrete hub 'pypi_b' +pip.parse( + hub_name = "pypi_b", + python_version = "3.11", + requirements_lock = "//:requirements_b.txt", +) + +# Designate 'pypi_b' as the default hub for the unified '@pypi' repository +pip.default(default_hub = "pypi_b") + +# Import the unified hub repository +use_repo(pip, "pypi") +``` + +#### Dynamic Routing at Build Time + +By default, the unified `@pypi` repository will resolve packages from the +designated `default_hub`. You can dynamically switch the active hub for a build +using the `--@rules_python//python/config_settings:venv` command-line flag +or via target transitions: + +```bash +# Build using packages from 'pypi_a' +bazel build --@rules_python//python/config_settings:venv=pypi_a //my:binary +``` + +Shared library targets can simply depend on the unified hub (e.g., +`@pypi//numpy`), and the dependency will automatically resolve to the correct +wheel version from the active hub during the build. + + + As with any repository rule or extension, if you would like to ensure that `pip_parse` is re-executed to pick up a non-hermetic change to your environment (e.g., updating your system `python` interpreter), you can force it to re-execute by running `bazel sync --only [pip_parse @@ -334,6 +407,7 @@ into whatever HTTP(S) request it performs against `example.com`. See the [Credential Helper Spec][cred-helper-spec] for more details. + [rfc7617]: https://datatracker.ietf.org/doc/html/rfc7617 [cred-helper-design]: https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md [cred-helper-spec]: https://github.com/EngFlow/credential-helper-spec/blob/main/spec.md diff --git a/news/3837.added.md b/news/3837.added.md new file mode 100644 index 0000000000..6d3e4b5504 --- /dev/null +++ b/news/3837.added.md @@ -0,0 +1 @@ +(pypi) Added `@pypi` repo: a unified hub of `pip.parse` hubs. diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel index 5b1317872f..369ce6de55 100644 --- a/python/config_settings/BUILD.bazel +++ b/python/config_settings/BUILD.bazel @@ -148,6 +148,13 @@ string_flag( # pip.parse related flags +string_flag( + name = "venv", + build_setting_default = "auto", + # NOTE: Only public because it is used in pip hub repos and executable transitions. + visibility = ["//visibility:public"], +) + string_flag( name = "pip_whl_osx_version", build_setting_default = "", diff --git a/python/features.bzl b/python/features.bzl index fab44385c8..f5d2c315cd 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -97,6 +97,7 @@ _TARGETS = { "//command_line_option:enable_runfiles": True, "//command_line_option:extra_toolchains": True, "//python/cc:current_py_cc_headers_abi3": True, + "//python/config_settings:venv": True, } _LOADABLE_SYMBOLS = { diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl index a83ba2b462..135f8c0a1b 100644 --- a/python/private/common_labels.bzl +++ b/python/private/common_labels.bzl @@ -29,6 +29,7 @@ labels = struct( PY_FREETHREADED = str(Label("//python/config_settings:py_freethreaded")), PY_LINUX_LIBC = str(Label("//python/config_settings:py_linux_libc")), REPL_DEP = str(Label("//python/bin:repl_dep")), + VENV = str(Label("//python/config_settings:venv")), VENVS_SITE_PACKAGES = str(Label("//python/config_settings:venvs_site_packages")), VENVS_USE_DECLARE_SYMLINK = str(Label("//python/config_settings:venvs_use_declare_symlink")), VISIBLE_FOR_TESTING = str(Label("//python/private:visible_for_testing")), diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index b9a7a18aed..5e109a2a21 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -145,6 +145,7 @@ bzl_library( ":platform_bzl", ":pypi_cache_bzl", ":simpleapi_download_bzl", + ":unified_hub_repo_bzl", ":whl_library_bzl", "//python/private:auth_bzl", "//python/private:normalize_name_bzl", @@ -225,6 +226,15 @@ bzl_library( srcs = ["labels.bzl"], ) +bzl_library( + name = "missing_package_bzl", + srcs = ["missing_package.bzl"], + deps = [ + "//python/private:py_info_bzl", + "//python/private:reexports_bzl", + ], +) + bzl_library( name = "multi_pip_parse_bzl", srcs = ["multi_pip_parse.bzl"], @@ -445,6 +455,23 @@ bzl_library( ], ) +bzl_library( + name = "unified_hub_repo_bzl", + srcs = ["unified_hub_repo.bzl"], + deps = [ + "//python/private:text_util_bzl", + ], +) + +bzl_library( + name = "unified_hub_setup_bzl", + srcs = ["unified_hub_setup.bzl"], + deps = [ + ":labels_bzl", + ":missing_package_bzl", + ], +) + bzl_library( name = "urllib_bzl", srcs = ["urllib.bzl"], diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index a0b59e9ed1..5160b81ce8 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -28,6 +28,7 @@ load(":pip_repository_attrs.bzl", "ATTRS") load(":platform.bzl", _plat = "platform") load(":pypi_cache.bzl", "pypi_cache") load(":simpleapi_download.bzl", "simpleapi_download") +load(":unified_hub_repo.bzl", "unified_hub_repo") load(":whl_library.bzl", "whl_library") def _whl_mods_impl(whl_mods_dict): @@ -203,6 +204,7 @@ def build_config( Returns: A struct with the configuration. """ + default_hub = None defaults = { "platforms": default_platforms(), } @@ -211,6 +213,12 @@ def build_config( continue for tag in mod.tags.default: + if tag.default_hub: + if mod.is_root: + if default_hub: + fail("Duplicate pip.default tag: only one explicit default PyPI hub is allowed.") + default_hub = tag.default_hub + platform = tag.platform if platform: specific_config = defaults["platforms"].setdefault(platform, {}) @@ -244,6 +252,7 @@ def build_config( return struct( auth_patterns = defaults.get("auth_patterns", {}), + default_hub = default_hub, index_url = defaults.get("index_url", "https://pypi.org/simple").rstrip("/"), netrc = defaults.get("netrc", None), platforms = { @@ -343,9 +352,40 @@ You cannot use both the additive_build_content and additive_build_content_file a pip_hub_map = {} simpleapi_cache = pypi_cache(mctx = module_ctx) + is_pypi_hub_reserved = module_ctx.getenv("RULES_PYTHON_PYPI_HUB_RESERVED", "0") == "1" + renamed_default_hub = None + for mod in module_ctx.modules: for pip_attr in mod.tags.parse: hub_name = pip_attr.hub_name + if hub_name == "pypi": + if is_pypi_hub_reserved: + renamed_name = mod.name + "_pypi" + print( + ( + "WARNING: The PyPI hub name 'pypi' is reserved " + + "(module '{}'). The hub was renamed to '{}'. " + + "Please rename your hub." + ).format( + mod.name, + renamed_name, + ), + ) # buildifier: disable=print + hub_name = renamed_name + if not renamed_default_hub: + renamed_default_hub = hub_name + else: + print( + ( + "WARNING: The PyPI hub name 'pypi' is reserved " + + "(module '{}'). Please rename your hub, otherwise " + + "a future release will rename it to '{}_pypi'." + ).format( + mod.name, + mod.name, + ), + ) # buildifier: disable=print + if hub_name not in pip_hub_map: builder = hub_builder( name = hub_name, @@ -359,7 +399,7 @@ You cannot use both the additive_build_content and additive_build_content_file a available_interpreters = kwargs.get("available_interpreters", INTERPRETER_LABELS), logger = repo_utils.logger(module_ctx, "pypi:hub:" + hub_name, mod = mod), ) - pip_hub_map[pip_attr.hub_name] = builder + pip_hub_map[hub_name] = builder elif pip_hub_map[hub_name].module_name != mod.name: # We cannot have two hubs with the same name in different # modules. @@ -375,7 +415,7 @@ You cannot use both the additive_build_content and additive_build_content_file a )) else: - builder = pip_hub_map[pip_attr.hub_name] + builder = pip_hub_map[hub_name] builder.pip_parse( module_ctx, @@ -406,6 +446,7 @@ You cannot use both the additive_build_content and additive_build_content_file a return struct( config = config, + default_hub = config.default_hub or renamed_default_hub, exposed_packages = exposed_packages, extra_aliases = extra_aliases, facts = simpleapi_cache.get_facts(), @@ -422,6 +463,41 @@ You cannot use both the additive_build_content and additive_build_content_file a }, ) +def _create_unified_hub_repo(mods): + if "pypi" in mods.hub_whl_map: + return + + hubs = sorted(mods.hub_whl_map.keys()) + if mods.default_hub and mods.default_hub not in hubs: + fail("default_hub '%s' is not a defined PyPI hub. Available hubs: %s" % (mods.default_hub, ", ".join(hubs))) + + packages = {} + extra_aliases = {} + + for hub_name in hubs: + for pkg_name in mods.exposed_packages.get(hub_name, []): + norm_pkg = normalize_name(pkg_name) + if norm_pkg not in packages: + packages[norm_pkg] = [] + if hub_name not in packages[norm_pkg]: + packages[norm_pkg].append(hub_name) + + extra = mods.extra_aliases.get(hub_name, {}).get(norm_pkg, []) + for alias_name in extra: + qual_alias = "%s:%s" % (norm_pkg, alias_name) + if qual_alias not in extra_aliases: + extra_aliases[qual_alias] = [] + if hub_name not in extra_aliases[qual_alias]: + extra_aliases[qual_alias].append(hub_name) + + unified_hub_repo( + name = "pypi", + default_hub = mods.default_hub or (hubs[0] if hubs else ""), + extra_aliases = extra_aliases, + hubs = hubs, + packages = packages, + ) + def _pip_impl(module_ctx): """Implementation of a class tag that creates the pip hub and corresponding pip spoke whl repositories. @@ -513,6 +589,8 @@ def _pip_impl(module_ctx): groups = mods.hub_group_map.get(hub_name), ) + _create_unified_hub_repo(mods) + # The code is smart to not return facts if we don't support the mechanism for that. # Hence we should not pass it to the metadata if mods.facts: @@ -535,12 +613,14 @@ Either this or {attr}`env` `platform_machine` key should be specified. """, ), "config_settings": attr.label_list( - mandatory = True, doc = """\ The list of labels to `config_setting` targets that need to be matched for the platform to be -selected. +selected. Mandatory if platform is specified. """, ), + "default_hub": attr.string( + doc = "The name of the concrete PyPI hub to use by default when {flag}`--venv=auto`.", + ), "env": attr.string_dict( doc = """\ The values to use for environment markers when evaluating an expression. @@ -742,6 +822,11 @@ https://packaging.python.org/en/latest/specifications/simple-repository-api/ doc = """ The name of the repo pip dependencies will be accessible from. +The hub name `"pypi"` is reserved for the automatically generated +[Unified @pypi Hub](unified-pypi-hub) repository. Please choose a different name +for your concrete hubs. See [Unified @pypi Hub](unified-pypi-hub) for how to +handle collisions. + This name must be unique between modules; unless your module is guaranteed to always be the root module, it's highly recommended to include your module name in the hub name. Repo mapping, `use_repo(..., pip="my_modules_pip_deps")`, can @@ -757,6 +842,12 @@ is not required. Each hub is a separate resolution of pip dependencies. This means if different programs need different versions of some library, separate hubs can be created, and each program can use its respective hub's targets. Targets from different hubs should not be used together. + +:::{versionchanged} VERSION_NEXT_FEATURE +Using the hub name `"pypi"` is deprecated and is changed to +`{module_name}_pypi` depending on the +{envvar}`RULES_PYTHON_PYPI_HUB_RESERVED` environment variable. +::: """, ), "parallel_download": attr.bool( @@ -917,6 +1008,7 @@ other tags in this extension.""", ) pypi = module_extension( + environ = ["RULES_PYTHON_PYPI_HUB_RESERVED"], doc = """\ This extension is used to make dependencies from pip available. @@ -930,6 +1022,14 @@ can be made to configure different Python versions, and will be grouped by the `hub_name` argument. This allows the same logical name, e.g. `@pip//numpy` to automatically resolve to different, Python version-specific, libraries. +A unified `@pypi` proxy repository is always generated (unless a hub is +explicitly named "pypi") to route dependencies dynamically. See +[Unified @pypi Hub](unified-pypi-hub) for details. + +Environment Variables: +- `RULES_PYTHON_PYPI_HUB_RESERVED`: Enable fallback renaming for reserved hub name collisions. + See the {envvar}`RULES_PYTHON_PYPI_HUB_RESERVED` documentation for details. + pip.whl_mods: This tag class is used to help create JSON files to describe modifications to the BUILD files for wheels. @@ -941,6 +1041,10 @@ the BUILD files for wheels. doc = """\ This tag class allows for more customization of how the configuration for the hub repositories is built. +It can also be used to designate the default hub for the automatically +generated [Unified @pypi Hub](unified-pypi-hub) using the `default_hub` +attribute. + :::{seealso} The [environment markers][environment_markers] specification for the explanation of the @@ -961,6 +1065,9 @@ This tag class is used to create a pip hub and all of the spokes that are part o This tag class reuses most of the attributes found in {bzl:obj}`pip_parse`. The exception is it does not use the arg 'repo_prefix'. We set the repository prefix for the user and the alias arg is always True in bzlmod. + +You can use the automatically generated [Unified @pypi Hub](unified-pypi-hub) +repository to route package dependencies dynamically at build time. """, ), "whl_mods": tag_class( diff --git a/python/private/pypi/missing_package.bzl b/python/private/pypi/missing_package.bzl new file mode 100644 index 0000000000..c59f754b10 --- /dev/null +++ b/python/private/pypi/missing_package.bzl @@ -0,0 +1,36 @@ +"""Rule for generating an execution-phase action failure when a PyPI package is missing.""" + +load("//python/private:py_info.bzl", "PyInfo") +load("//python/private:reexports.bzl", "BuiltinPyInfo") + +def _missing_package_error_impl(ctx): + out = ctx.actions.declare_file(ctx.label.name + ".error") + + # Register an action that fails when Bazel attempts to stage/build this file + ctx.actions.run_shell( + outputs = [out], + command = "echo 'ERROR: PyPI package \"{pkg}\" is not available{hub_clause}.' >&2 && exit 1".format( + pkg = ctx.attr.package_name, + hub_clause = (' when building under PyPI hub "%s"' % ctx.attr.hub_name) if ctx.attr.hub_name else " because no PyPI hub or default hub is requested", + ), + ) + + maybe_builtin = [BuiltinPyInfo(transitive_sources = depset([out]))] if BuiltinPyInfo != None else [] + + return [ + DefaultInfo( + files = depset([out]), + data_runfiles = ctx.runfiles([out]), + ), + PyInfo( + transitive_sources = depset([out]), + ), + ] + maybe_builtin + +missing_package_error = rule( + implementation = _missing_package_error_impl, + attrs = { + "hub_name": attr.string(mandatory = True), + "package_name": attr.string(mandatory = True), + }, +) diff --git a/python/private/pypi/unified_hub_repo.bzl b/python/private/pypi/unified_hub_repo.bzl new file mode 100644 index 0000000000..cbe150575a --- /dev/null +++ b/python/private/pypi/unified_hub_repo.bzl @@ -0,0 +1,81 @@ +"""Repository rule for creating the Unified PyPI Hub.""" + +load("//python/private:text_util.bzl", "render") + +_ROOT_BUILD_TMPL = """\ +load("@rules_python//python/private/pypi:unified_hub_setup.bzl", "define_venv_flag_config_settings") + +package(default_visibility = ["//visibility:public"]) + +define_venv_flag_config_settings( + name = "venv_config_settings", + hubs = {hubs}, +) +""" + +_PKG_BUILD_TMPL = """\ +load("@rules_python//python/private/pypi:unified_hub_setup.bzl", "define_pypi_package_targets") + +package(default_visibility = ["//visibility:public"]) + +define_pypi_package_targets( + name = "{pkg_name}", + default_hub = {default_hub}, + extra_aliases = {extra_aliases}, + hubs = {hubs}, + pkg_hubs = {pkg_hubs}, +) +""" + +def _unified_hub_repo_impl(rctx): + hubs = rctx.attr.hubs + default_hub = rctx.attr.default_hub or None + + # 1. Generate Root BUILD.bazel with shared config settings + rctx.file( + "BUILD.bazel", + _ROOT_BUILD_TMPL.format(hubs = hubs), + ) + + # 2. Organize extra aliases by package + extra_aliases_by_pkg = {} + for qual_alias, alias_hubs in rctx.attr.extra_aliases.items(): + if ":" not in qual_alias: + fail("extra_aliases keys must be in 'pkg:alias' format.") + pkg, alias = qual_alias.split(":", 1) + extra_aliases_by_pkg.setdefault(pkg, {})[alias] = alias_hubs + + # 3. Generate package subpackages + for pkg_name, pkg_hubs in rctx.attr.packages.items(): + extra_aliases = extra_aliases_by_pkg.get(pkg_name, {}) + rctx.file( + pkg_name + "/BUILD.bazel", + _PKG_BUILD_TMPL.format( + default_hub = render.str(default_hub), + extra_aliases = extra_aliases, + hubs = hubs, + pkg_hubs = pkg_hubs, + pkg_name = pkg_name, + ), + ) + +unified_hub_repo = repository_rule( + implementation = _unified_hub_repo_impl, + attrs = { + "default_hub": attr.string( + doc = "The PyPI hub to use when no other hub's conditions match.", + ), + "extra_aliases": attr.string_list_dict( + doc = "Dictionary mapping 'package:alias' to a list of hubs that support it.", + ), + "hubs": attr.string_list( + mandatory = True, + doc = "List of all concrete PyPI hub names.", + ), + "packages": attr.string_list_dict( + mandatory = True, + doc = "Dictionary mapping package names to a list of hubs that contain them.", + ), + }, + doc = "Private repository rule creating the automatic Unified PyPI Hub.", +) diff --git a/python/private/pypi/unified_hub_setup.bzl b/python/private/pypi/unified_hub_setup.bzl new file mode 100644 index 0000000000..b19410fd4b --- /dev/null +++ b/python/private/pypi/unified_hub_setup.bzl @@ -0,0 +1,110 @@ +"""Helper functions for setting up targets within the Unified PyPI Hub repository.""" + +load( + "@rules_python//python/private/pypi:labels.bzl", + "DATA_LABEL", + "DIST_INFO_LABEL", + "EXTRACTED_WHEEL_FILES", + "PY_LIBRARY_PUBLIC_LABEL", + "WHEEL_FILE_PUBLIC_LABEL", +) +load("@rules_python//python/private/pypi:missing_package.bzl", "missing_package_error") + +def define_venv_flag_config_settings(name, hubs): + """Defines the root config_settings for each PyPI spoke hub. + + Args: + name: unused macro name required by buildifier. + hubs: list of concrete hub names. + """ + for hub in hubs: + native.config_setting( + name = "_is_venv_" + hub, + flag_values = {"@rules_python//python/config_settings:venv": hub}, + ) + +_STANDARD_ALIASES = [ + PY_LIBRARY_PUBLIC_LABEL, + WHEEL_FILE_PUBLIC_LABEL, + DATA_LABEL, + DIST_INFO_LABEL, + EXTRACTED_WHEEL_FILES, +] + +def define_pypi_package_targets(name, pkg_hubs, extra_aliases, hubs, default_hub = None): + """Define the targets for a PyPI package in the unified PyPI hub. + + Args: + name: normalized PyPI package name, serving as the main target name. + pkg_hubs: list of hubs that contain this package. + extra_aliases: dict mapping extra alias names to lists of hubs that support them. + hubs: list of all concrete hub names. + default_hub: the hub to use by default. + """ + pkg_name = name + + # Main apparent package target delegates to :pkg + native.alias( + name = pkg_name, + actual = ":pkg", + ) + + all_aliases = _STANDARD_ALIASES + sorted(extra_aliases.keys()) + missing_errors = {} + + for alias_name in all_aliases: + select_map = {} + for hub in hubs: + is_supported = ( + (alias_name in _STANDARD_ALIASES and hub in pkg_hubs) or + (alias_name not in _STANDARD_ALIASES and hub in extra_aliases.get(alias_name, [])) + ) + + if is_supported: + select_map["//:_is_venv_" + hub] = "@{hub}//{pkg}:{alias}".format( + hub = hub, + pkg = pkg_name, + alias = alias_name, + ) + else: + err_target = "_missing_{alias}_in_{hub}".format(alias = alias_name, hub = hub) + if err_target not in missing_errors: + missing_errors[err_target] = { + "hub_name": hub, + "package_name": pkg_name if alias_name in _STANDARD_ALIASES else (pkg_name + ":" + alias_name), + } + select_map["//:_is_venv_" + hub] = ":{}".format(err_target) + + # //conditions:default fallback + default_supported = ( + default_hub and + ((alias_name in _STANDARD_ALIASES and default_hub in pkg_hubs) or + (alias_name not in _STANDARD_ALIASES and default_hub in extra_aliases.get(alias_name, []))) + ) + + if default_supported: + select_map["//conditions:default"] = "@{hub}//{pkg}:{alias}".format( + hub = default_hub, + pkg = pkg_name, + alias = alias_name, + ) + else: + err_target = "_missing_{alias}_in_default".format(alias = alias_name) + if err_target not in missing_errors: + missing_errors[err_target] = { + "hub_name": default_hub or "", + "package_name": pkg_name if alias_name in _STANDARD_ALIASES else (pkg_name + ":" + alias_name), + } + select_map["//conditions:default"] = ":{}".format(err_target) + + native.alias( + name = alias_name, + actual = select(select_map), + ) + + # Generate missing package error targets + for err_name, err_args in missing_errors.items(): + missing_package_error( + name = err_name, + **err_args + ) diff --git a/python/private/text_util.bzl b/python/private/text_util.bzl index f725195978..eedf66009d 100644 --- a/python/private/text_util.bzl +++ b/python/private/text_util.bzl @@ -107,6 +107,18 @@ def _render_list(items, *, hanging_indent = "", value_repr = repr): return text def _render_str(value): + """Render a string value. + + If value is None, it is automatically rendered as the Starlark literal `None`. + + Args: + value: str or None. + + Returns: + The value represented as Starlark source text. + """ + if value == None: + return "None" return repr(value) def _render_string_list_dict(value): diff --git a/python/private/transition_labels.bzl b/python/private/transition_labels.bzl index 7a6531ed0f..d337044cb5 100644 --- a/python/private/transition_labels.bzl +++ b/python/private/transition_labels.bzl @@ -21,6 +21,7 @@ _BASE_TRANSITION_LABELS = [ labels.PYTHON_VERSION, labels.PY_FREETHREADED, labels.PY_LINUX_LIBC, + labels.VENV, labels.VENVS_SITE_PACKAGES, labels.VENVS_USE_DECLARE_SYMLINK, ] diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel index 904fb4c247..9301e19590 100644 --- a/tests/integration/BUILD.bazel +++ b/tests/integration/BUILD.bazel @@ -118,6 +118,11 @@ rules_python_integration_test( py_main = "toolchain_target_settings_test.py", ) +rules_python_integration_test( + name = "unified_pypi_test", + py_main = "unified_pypi_test.py", +) + rules_python_integration_test( name = "uv_lock_test", py_deps = [ diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index 2a0bc7d76b..0408f791e1 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -250,7 +250,7 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "46RcxJnhOapMeaxdcMm3RmVdNp1nPCewOOXoZyIbQ20=", + "bzlTransitiveDigest": "ELjwPp2kLku5M3S/gpjjVjy3TwT760/zVEQ70nJreHU=", "usagesDigest": "6yXGw7XDyXjOfqBL0SBu1YBEMMYPQzCE3jTzUCkxPgg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", diff --git a/tests/integration/unified_pypi/.bazelrc b/tests/integration/unified_pypi/.bazelrc new file mode 100644 index 0000000000..b3a24e8605 --- /dev/null +++ b/tests/integration/unified_pypi/.bazelrc @@ -0,0 +1 @@ +common --experimental_enable_bzlmod diff --git a/tests/integration/unified_pypi/BUILD.bazel b/tests/integration/unified_pypi/BUILD.bazel new file mode 100644 index 0000000000..8a37d4b153 --- /dev/null +++ b/tests/integration/unified_pypi/BUILD.bazel @@ -0,0 +1,48 @@ +load("@rules_python//python:py_binary.bzl", "py_binary") +load("@rules_python//python:py_test.bzl", "py_test") + +package(default_visibility = ["//visibility:public"]) + +py_test( + name = "test_default", + srcs = ["test_default.py"], + deps = ["@pypi//colorama"], +) + +py_test( + name = "test_cli", + srcs = ["test_cli.py"], + deps = ["@pypi//colorama"], +) + +py_test( + name = "test_a", + srcs = ["test_a.py"], + config_settings = { + "@rules_python//python/config_settings:venv": "pypi_a", + }, + deps = [ + "@pypi//colorama", + "@pypi//colorama:my_colorama", + ], +) + +# Sibling extra alias failure target (my_colorama is missing in pypi_b): +py_binary( + name = "bin_extra_b", + srcs = ["bin_extra_b.py"], + config_settings = { + "@rules_python//python/config_settings:venv": "pypi_b", + }, + deps = ["@pypi//colorama:my_colorama"], +) + +# Disjoint package failure target (six is missing in pypi_a): +py_binary( + name = "bin_six_a", + srcs = ["bin_six_a.py"], + config_settings = { + "@rules_python//python/config_settings:venv": "pypi_a", + }, + deps = ["@pypi//six"], +) diff --git a/tests/integration/unified_pypi/MODULE.bazel b/tests/integration/unified_pypi/MODULE.bazel new file mode 100644 index 0000000000..0d0f44f61c --- /dev/null +++ b/tests/integration/unified_pypi/MODULE.bazel @@ -0,0 +1,48 @@ +module(name = "unified_pypi") + +bazel_dep(name = "rules_python", version = "0.0.0") +local_path_override( + module_name = "rules_python", + path = "../../..", +) + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain(python_version = "3.11") + +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +pip.whl_mods( + additive_build_content = """\ +load("@rules_python//python:defs.bzl", "py_library") + +py_library( + name = "my_colorama", + deps = [":pkg"], +) +""", + hub_name = "whl_mods_hub", + whl_name = "colorama", +) +use_repo(pip, "whl_mods_hub") + +# pypi_a has colorama and an extra alias +pip.parse( + extra_hub_aliases = {"colorama": ["my_colorama"]}, + hub_name = "pypi_a", + python_version = "3.11", + requirements_lock = "//:requirements_a.txt", + whl_modifications = { + "@whl_mods_hub//:colorama.json": "colorama", + }, +) +use_repo(pip, "pypi_a") + +# pypi_b has colorama and six, and acts as designated fallback +pip.parse( + hub_name = "pypi_b", + python_version = "3.11", + requirements_lock = "//:requirements_b.txt", +) +use_repo(pip, "pypi_b") + +pip.default(default_hub = "pypi_b") +use_repo(pip, "pypi") diff --git a/tests/integration/unified_pypi/WORKSPACE b/tests/integration/unified_pypi/WORKSPACE new file mode 100644 index 0000000000..0a08afe832 --- /dev/null +++ b/tests/integration/unified_pypi/WORKSPACE @@ -0,0 +1 @@ +# Minimal WORKSPACE file diff --git a/tests/integration/unified_pypi/WORKSPACE.bzlmod b/tests/integration/unified_pypi/WORKSPACE.bzlmod new file mode 100644 index 0000000000..7bd1c969b9 --- /dev/null +++ b/tests/integration/unified_pypi/WORKSPACE.bzlmod @@ -0,0 +1 @@ +# Minimal WORKSPACE.bzlmod diff --git a/tests/integration/unified_pypi/bin_extra_b.py b/tests/integration/unified_pypi/bin_extra_b.py new file mode 100644 index 0000000000..f900d16fd2 --- /dev/null +++ b/tests/integration/unified_pypi/bin_extra_b.py @@ -0,0 +1 @@ +print("Should not be executed") diff --git a/tests/integration/unified_pypi/bin_six_a.py b/tests/integration/unified_pypi/bin_six_a.py new file mode 100644 index 0000000000..f900d16fd2 --- /dev/null +++ b/tests/integration/unified_pypi/bin_six_a.py @@ -0,0 +1 @@ +print("Should not be executed") diff --git a/tests/integration/unified_pypi/requirements_a.txt b/tests/integration/unified_pypi/requirements_a.txt new file mode 100644 index 0000000000..788f12f818 --- /dev/null +++ b/tests/integration/unified_pypi/requirements_a.txt @@ -0,0 +1,3 @@ +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 diff --git a/tests/integration/unified_pypi/requirements_b.txt b/tests/integration/unified_pypi/requirements_b.txt new file mode 100644 index 0000000000..c69f3631b2 --- /dev/null +++ b/tests/integration/unified_pypi/requirements_b.txt @@ -0,0 +1,6 @@ +colorama==0.4.5 \ + --hash=sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da \ + --hash=sha256:e6c6b4334fc50988a639d9b98ae42f5c90ec94cb1495b4fe76c5f72cf7f79435 +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 diff --git a/tests/integration/unified_pypi/test_a.py b/tests/integration/unified_pypi/test_a.py new file mode 100644 index 0000000000..a9127ba0c7 --- /dev/null +++ b/tests/integration/unified_pypi/test_a.py @@ -0,0 +1,3 @@ +import colorama + +assert colorama.__version__ == "0.4.6" diff --git a/tests/integration/unified_pypi/test_cli.py b/tests/integration/unified_pypi/test_cli.py new file mode 100644 index 0000000000..a9127ba0c7 --- /dev/null +++ b/tests/integration/unified_pypi/test_cli.py @@ -0,0 +1,3 @@ +import colorama + +assert colorama.__version__ == "0.4.6" diff --git a/tests/integration/unified_pypi/test_default.py b/tests/integration/unified_pypi/test_default.py new file mode 100644 index 0000000000..559df59961 --- /dev/null +++ b/tests/integration/unified_pypi/test_default.py @@ -0,0 +1,3 @@ +import colorama + +assert colorama.__version__ == "0.4.5" diff --git a/tests/integration/unified_pypi_test.py b/tests/integration/unified_pypi_test.py new file mode 100644 index 0000000000..707ab13444 --- /dev/null +++ b/tests/integration/unified_pypi_test.py @@ -0,0 +1,79 @@ +"""Integration test for Unified PyPI Hub dynamic dependency resolution.""" + +import contextlib +import unittest + +from tests.integration import runner + + +class UnifiedPypiTest(runner.TestCase): + def test_default_fallback_hub(self): + self.run_bazel("test", "//:test_default") + + def test_transitioned_hub(self): + self.run_bazel("test", "//:test_a") + + def test_cli_override(self): + self.run_bazel( + "run", + "--@rules_python//python/config_settings:venv=pypi_a", + "//:test_cli", + ) + + def test_disjoint_package_cquery_succeeds_but_build_fails(self): + self.run_bazel("cquery", "//:bin_six_a") + result = self.run_bazel("build", "//:bin_six_a", check=False) + self.assertNotEqual( + result.exit_code, + 0, + "Expected build to fail during execution phase", + ) + self.assert_result_matches( + result, + 'ERROR: PyPI package "six" is not available when building under PyPI hub "pypi_a".', + ) + + def test_sibling_extra_alias_cquery_succeeds_but_build_fails(self): + self.run_bazel("cquery", "//:bin_extra_b") + result = self.run_bazel("build", "//:bin_extra_b", check=False) + self.assertNotEqual( + result.exit_code, + 0, + "Expected build to fail during execution phase", + ) + self.assert_result_matches( + result, + 'ERROR: PyPI package "colorama:my_colorama" is not available when building under PyPI hub "pypi_b".', + ) + + @contextlib.contextmanager + def _temp_modify_file(self, path, new_content): + original_content = path.read_text() + path.write_text(new_content) + try: + yield + finally: + path.write_text(original_content) + + def test_invalid_default_hub_fails_evaluation(self): + module_bazel = self.repo_root / "MODULE.bazel" + invalid_content = module_bazel.read_text().replace( + 'pip.default(default_hub = "pypi_b")', + 'pip.default(default_hub = "invalid_hub")', + ) + with self._temp_modify_file(module_bazel, invalid_content): + # Run bazel cquery and expect it to fail during loading/extension phase + result = self.run_bazel("cquery", "//:test_default", check=False) + self.assertNotEqual( + result.exit_code, + 0, + "Expected extension evaluation to fail due to invalid default_hub", + ) + self.assert_result_matches( + result, + "default_hub 'invalid_hub' is not a defined PyPI hub", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pypi/extension/extension_tests.bzl b/tests/pypi/extension/extension_tests.bzl index 5a40714b64..bc4c0bcb5b 100644 --- a/tests/pypi/extension/extension_tests.bzl +++ b/tests/pypi/extension/extension_tests.bzl @@ -44,6 +44,7 @@ def _default( arch_name = None, auth_patterns = None, config_settings = None, + default_hub = "", env = None, index_url = None, marker = None, @@ -56,6 +57,7 @@ def _default( arch_name = arch_name, auth_patterns = auth_patterns or {}, config_settings = config_settings, + default_hub = default_hub, env = env or {}, index_url = index_url or "", marker = marker or "", @@ -104,6 +106,7 @@ def _parse_modules(env, **kwargs): return env.expect.that_struct( parse_modules(**kwargs), attrs = dict( + default_hub = subjects.str, exposed_packages = subjects.dict, hub_group_map = subjects.dict, hub_whl_map = subjects.dict, @@ -283,6 +286,155 @@ def _test_build_pipstar_platform(env): _tests.append(_test_build_pipstar_platform) +def _test_multiple_default_tags(env): + """Test that multiple pip.default tags do not trigger duplicate default hub failures. + + Only when multiple tags explicitly define default_hub should it fail. + """ + pypi = _parse_modules( + env, + module_ctx = _pypi_mock_mctx( + _mod( + name = "rules_python", + default = _default_tags_default + [ + _default(platform = "extra_custom_platform"), + ], + parse = [ + _parse( + hub_name = "pypi", + python_version = "3.15", + simpleapi_skip = ["simple"], + requirements_lock = "requirements.txt", + ), + ], + ), + os_name = "linux", + arch_name = "x86_64", + ), + available_interpreters = { + "python_3_15_host": "unit_test_interpreter_target", + }, + minor_mapping = {"3.15": "3.15.19"}, + ) + pypi.exposed_packages().contains_exactly({"pypi": ["simple"]}) + +_tests.append(_test_multiple_default_tags) + +def _test_name_collision_no_env(env): + """Test that a hub named 'pypi' is NOT renamed when the env var is not set.""" + pypi = _parse_modules( + env, + module_ctx = _pypi_mock_mctx( + _mod( + name = "rules_python", + parse = [ + _parse( + hub_name = "pypi", + python_version = "3.15", + simpleapi_skip = ["simple"], + requirements_lock = "requirements.txt", + ), + ], + ), + os_name = "linux", + arch_name = "x86_64", + environ = {}, # Env var NOT set + ), + available_interpreters = { + "python_3_15_host": "unit_test_interpreter_target", + }, + minor_mapping = {"3.15": "3.15.19"}, + ) + + # The hub name remains 'pypi' + pypi.exposed_packages().contains_exactly({"pypi": ["simple"]}) + pypi.default_hub().equals(None) + +_tests.append(_test_name_collision_no_env) + +def _test_name_collision_with_env(env): + """Test that a hub named 'pypi' is silently renamed to module_name_pypi and routed as default_hub when the env var is set.""" + pypi = _parse_modules( + env, + module_ctx = _pypi_mock_mctx( + _mod( + name = "rules_python", + parse = [ + _parse( + hub_name = "pypi", + python_version = "3.15", + simpleapi_skip = ["simple"], + requirements_lock = "requirements.txt", + ), + ], + ), + os_name = "linux", + arch_name = "x86_64", + environ = {"RULES_PYTHON_PYPI_HUB_RESERVED": "1"}, + ), + available_interpreters = { + "python_3_15_host": "unit_test_interpreter_target", + }, + minor_mapping = {"3.15": "3.15.19"}, + ) + + # The hub name is renamed to 'rules_python_pypi' + pypi.exposed_packages().contains_exactly({"rules_python_pypi": ["simple"]}) + + # It is used as the default_hub + pypi.default_hub().equals("rules_python_pypi") + +_tests.append(_test_name_collision_with_env) + +def _test_default_hub_precedence(env): + """Test that pip.default(default_hub = ...) has precedence over the fallback renamed default hub.""" + pypi = _parse_modules( + env, + module_ctx = _pypi_mock_mctx( + _mod( + name = "rules_python", + default = _default_tags_default + [ + _default( + platform = "extra_custom_platform", + default_hub = "other_pypi", + ), + ], + parse = [ + _parse( + hub_name = "pypi", + python_version = "3.15", + simpleapi_skip = ["simple"], + requirements_lock = "requirements.txt", + ), + _parse( + hub_name = "other_pypi", + python_version = "3.15", + simpleapi_skip = ["simple"], + requirements_lock = "requirements.txt", + ), + ], + ), + os_name = "linux", + arch_name = "x86_64", + environ = {"RULES_PYTHON_PYPI_HUB_RESERVED": "1"}, + ), + available_interpreters = { + "python_3_15_host": "unit_test_interpreter_target", + }, + minor_mapping = {"3.15": "3.15.19"}, + ) + + # The hub named 'pypi' is renamed to 'rules_python_pypi' + pypi.exposed_packages().contains_exactly({ + "other_pypi": ["simple"], + "rules_python_pypi": ["simple"], + }) + + # But the default_hub remains 'other_pypi' because pip.default has higher precedence! + pypi.default_hub().equals("other_pypi") + +_tests.append(_test_default_hub_precedence) + def extension_test_suite(name): """Create the test suite. From 12680c53a3a8740ff7c8ad5cadecbc1f0cf92d4c Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:58:14 +0900 Subject: [PATCH 029/179] feat(pypi): support importing uv.lock file (#3785) The strategy for this is: * First add a way for us to create a `uv.lock` file from the `lock` rule. * Then add a `uv.lock` reader via the bazel toml parser. * Then plug the code into the `parse_requirements` function so that we can reuse the most of code already there. * Add some sample docs for the `uv.lock`. Extra things that we could do: * Call the PyPI index to understand if the packages are yanked or not - lock file does not have that information. * Read the `pyproject.toml` file to get the index values for each package. * Add e2e tests from `rules_py` test suite. Closes #3557 Work towards #2787 --------- Co-authored-by: Richard Levasseur Co-authored-by: Richard Levasseur --- .gitattributes | 2 + MODULE.bazel | 2 + docs/BUILD.bazel | 9 + docs/requirements.txt | 60 +- docs/uv.lock | 1163 +++++++++++++++++ news/3785.added.md | 5 + python/private/py_repositories.bzl | 6 + python/private/pypi/BUILD.bazel | 2 + python/private/pypi/extension.bzl | 10 +- python/private/pypi/hub_builder.bzl | 2 + python/private/pypi/parse_requirements.bzl | 314 ++++- python/uv/private/BUILD.bazel | 43 +- python/uv/private/lock.bat | 7 - python/uv/private/lock.bzl | 415 +++--- python/uv/private/lock.sh | 9 - .../uv/private/{ => template}/lock_copier.py | 0 python/uv/private/template/uv_lock.bat | 21 + python/uv/private/template/uv_lock.sh | 32 + python/uv/private/template/uv_pip_compile.bat | 8 + python/uv/private/template/uv_pip_compile.sh | 15 + python/uv/private/uv_lock_to_requirements.bzl | 139 ++ .../bzlmod_lockfile/MODULE.bazel.lock | 2 + tests/pypi/extension/pip_parse.bzl | 2 + .../parse_requirements_tests.bzl | 636 +++++++++ tests/uv/lock/BUILD.bazel | 5 + tests/uv/lock/lock_run_test.py | 128 +- tests/uv/lock/lock_tests.bzl | 20 + tests/uv/lock/pyproject_toml/requirements.txt | 16 +- tests/uv/lock/testdata/constraints.txt | 4 + tests/uv/lock/testdata/pyproject.toml | 5 + tests/uv/lock/testdata/requirements.txt | 16 +- tests/uv/lock/testdata/uv_lock_expected.lock | 218 +++ .../uv/lock/uv_lock_to_requirements_tests.bzl | 289 ++++ 33 files changed, 3290 insertions(+), 315 deletions(-) create mode 100644 docs/uv.lock create mode 100644 news/3785.added.md delete mode 100755 python/uv/private/lock.bat delete mode 100755 python/uv/private/lock.sh rename python/uv/private/{ => template}/lock_copier.py (100%) create mode 100755 python/uv/private/template/uv_lock.bat create mode 100755 python/uv/private/template/uv_lock.sh create mode 100755 python/uv/private/template/uv_pip_compile.bat create mode 100755 python/uv/private/template/uv_pip_compile.sh create mode 100644 python/uv/private/uv_lock_to_requirements.bzl create mode 100644 tests/uv/lock/testdata/pyproject.toml create mode 100644 tests/uv/lock/testdata/uv_lock_expected.lock create mode 100644 tests/uv/lock/uv_lock_to_requirements_tests.bzl diff --git a/.gitattributes b/.gitattributes index fafafd001b..4f93d89d33 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,5 @@ tools/publish/*.txt linguist-generated=true tests/uv/lock/testdata/requirements.txt text eol=lf python/private/runtimes_manifest_workspace.bzl text eol=lf python/private/runtimes_manifest.txt text eol=lf + +*.bat text eol=crlf diff --git a/MODULE.bazel b/MODULE.bazel index 568c2732e5..4de83f31be 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -9,6 +9,7 @@ bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "package_metadata", version = "0.0.7") bazel_dep(name = "platforms", version = "0.0.11") bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "toml.bzl", version = "0.4.1") # Those are loaded only when using py_proto_library # Use py_proto_library directly from protobuf repository @@ -183,6 +184,7 @@ dev_pip = use_extension( "{os}_{arch}", "{os}_{arch}_freethreaded", ], + uv_lock = "//docs:uv.lock", ) for python_version in [ "3.9", diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 315010c12d..043f3e4de7 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -223,3 +223,12 @@ lock( python_version = "3.9", visibility = ["//:__subpackages__"], ) + +# Run bazel run //docs:uv_lock.update +lock( + name = "uv_lock", + srcs = ["pyproject.toml"], + out = "uv.lock", + python_version = "3.9", + visibility = ["//:__subpackages__"], +) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6397f0a7f1..468cd38c0d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -30,9 +30,9 @@ babel==2.18.0 \ --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 # via sphinx -certifi==2026.2.25 \ - --hash=sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa \ - --hash=sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7 +certifi==2026.6.17 \ + --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ + --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db # via requests charset-normalizer==3.4.7 \ --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ @@ -183,9 +183,9 @@ docutils==0.22.4 ; python_full_version >= '3.11' \ # myst-parser # sphinx # sphinx-rtd-theme -idna==3.11 \ - --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ - --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902 +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests imagesize==1.5.0 ; python_full_version < '3.10' \ --hash=sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899 \ @@ -216,9 +216,9 @@ markdown-it-py==3.0.0 ; python_full_version < '3.11' \ # via # mdit-py-plugins # myst-parser -markdown-it-py==4.0.0 ; python_full_version >= '3.11' \ - --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ - --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 +markdown-it-py==4.2.0 ; python_full_version >= '3.11' \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a # via # mdit-py-plugins # myst-parser @@ -319,9 +319,9 @@ mdit-py-plugins==0.4.2 ; python_full_version < '3.10' \ --hash=sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636 \ --hash=sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5 # via myst-parser -mdit-py-plugins==0.5.0 ; python_full_version >= '3.10' \ - --hash=sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f \ - --hash=sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6 +mdit-py-plugins==0.6.1 ; python_full_version >= '3.10' \ + --hash=sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d \ + --hash=sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0 # via myst-parser mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ @@ -335,13 +335,13 @@ myst-parser==4.0.1 ; python_full_version == '3.10.*' \ --hash=sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4 \ --hash=sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d # via rules-python-docs (docs/pyproject.toml) -myst-parser==5.0.0 ; python_full_version >= '3.11' \ - --hash=sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211 \ - --hash=sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a +myst-parser==5.1.0 ; python_full_version >= '3.11' \ + --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ + --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 # via rules-python-docs (docs/pyproject.toml) -packaging==26.1 \ - --hash=sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f \ - --hash=sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via # readthedocs-sphinx-ext # sphinx @@ -349,10 +349,14 @@ pefile==2024.8.26 \ --hash=sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632 \ --hash=sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f # via rules-python-docs (docs/pyproject.toml) -pyelftools==0.32 \ +pyelftools==0.32 ; python_full_version < '3.10' \ --hash=sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738 \ --hash=sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5 # via rules-python-docs (docs/pyproject.toml) +pyelftools==0.33 ; python_full_version >= '3.10' \ + --hash=sha256:660d82dcbeb8e83d1702bd97f223f761625da06111c0cc988eac6b8ab0c1b61f \ + --hash=sha256:f215ad5f47d3f1373a21496a6c9e0707c622840d0622f23ff7ce08678b020036 + # via rules-python-docs (docs/pyproject.toml) pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 @@ -442,9 +446,9 @@ requests==2.32.5 ; python_full_version < '3.10' \ # via # readthedocs-sphinx-ext # sphinx -requests==2.33.1 ; python_full_version >= '3.10' \ - --hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \ - --hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a +requests==2.34.2 ; python_full_version >= '3.10' \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via # readthedocs-sphinx-ext # sphinx @@ -452,9 +456,9 @@ roman-numerals==4.1.0 ; python_full_version >= '3.11' \ --hash=sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2 \ --hash=sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7 # via sphinx -snowballstemmer==3.0.1 \ - --hash=sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064 \ - --hash=sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895 +snowballstemmer==3.1.1 \ + --hash=sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752 \ + --hash=sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260 # via sphinx sphinx==7.4.7 ; python_full_version < '3.10' \ --hash=sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe \ @@ -594,10 +598,14 @@ typing-extensions==4.15.0 \ # rules-python-docs (docs/pyproject.toml) # astroid # sphinx-autodoc2 -urllib3==2.6.3 \ +urllib3==2.6.3 ; python_full_version < '3.10' \ --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 # via requests +urllib3==2.7.0 ; python_full_version >= '3.10' \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via requests zipp==3.23.1 ; python_full_version < '3.10' \ --hash=sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc \ --hash=sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110 diff --git a/docs/uv.lock b/docs/uv.lock new file mode 100644 index 0000000000..72e8f307a9 --- /dev/null +++ b/docs/uv.lock @@ -0,0 +1,1163 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "absl-py" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, +] + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "astroid" +version = "3.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "imagesize" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/59/4b0dd64676aa6fb4986a755790cb6fc558559cf0084effad516820208ec3/imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f", size = 1281127, upload-time = "2026-03-03T01:59:54.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/b1/a0662b03103c66cf77101a187f396ea91167cd9b7d5d3a2e465ad2c7ee9b/imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899", size = 5763, upload-time = "2026-03-03T01:59:52.343Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "myst-parser" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pyyaml", marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pyelftools" +version = "0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/ab/33968940b2deb3d92f5b146bc6d4009a5f95d1d06c148ea2f9ee965071af/pyelftools-0.32.tar.gz", hash = "sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5", size = 15047199, upload-time = "2025-02-19T14:20:05.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/43/700932c4f0638c3421177144a2e86448c0d75dbaee2c7936bda3f9fd0878/pyelftools-0.32-py3-none-any.whl", hash = "sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738", size = 188525, upload-time = "2025-02-19T14:19:59.919Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "readthedocs-sphinx-ext" +version = "2.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "packaging" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/ce/38130d8dec600bf5413eb89a3413dd38f204c7c728c4947e12ff8cb793b7/readthedocs-sphinx-ext-2.2.5.tar.gz", hash = "sha256:ee5fd5b99db9f0c180b2396cbce528aa36671951b9526bb0272dbfce5517bd27", size = 12303, upload-time = "2023-12-19T10:00:49.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/71/c89e7709a0d4f93af1848e9855112299a820b470d84f917b4dd5998bdd07/readthedocs_sphinx_ext-2.2.5-py2.py3-none-any.whl", hash = "sha256:f8c56184ea011c972dd45a90122568587cc85b0127bc9cf064d17c68bc809daa", size = 11332, upload-time = "2023-12-19T10:00:43.972Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "rules-python-docs" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "absl-py", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "absl-py", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "macholib" }, + { name = "markupsafe" }, + { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pefile" }, + { name = "pyelftools" }, + { name = "readthedocs-sphinx-ext" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc2" }, + { name = "sphinx-reredirects", version = "0.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-reredirects", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-rtd-theme" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "absl-py" }, + { name = "macholib" }, + { name = "markupsafe" }, + { name = "myst-parser" }, + { name = "pefile" }, + { name = "pyelftools" }, + { name = "readthedocs-sphinx-ext" }, + { name = "sphinx" }, + { name = "sphinx-autodoc2" }, + { name = "sphinx-reredirects" }, + { name = "sphinx-rtd-theme", specifier = ">=2.0" }, + { name = "typing-extensions" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "babel", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-autodoc2" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/5f/5350046d1aa1a56b063ae08b9ad871025335c9d55fe2372896ea48711da9/sphinx_autodoc2-0.5.0.tar.gz", hash = "sha256:7d76044aa81d6af74447080182b6868c7eb066874edc835e8ddf810735b6565a", size = 115077, upload-time = "2023-11-27T07:27:51.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/e6/48d47961bbdae755ba9c17dfc65d89356312c67668dcb36c87cfadfa1964/sphinx_autodoc2-0.5.0-py3-none-any.whl", hash = "sha256:e867013b1512f9d6d7e6f6799f8b537d6884462acd118ef361f3f619a60b5c9e", size = 43385, upload-time = "2023-11-27T07:27:49.929Z" }, +] + +[[package]] +name = "sphinx-reredirects" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/6b/bcca2785de4071f604a722444d4d7ba8a9d40de3c14ad52fce93e6d92694/sphinx_reredirects-0.1.6.tar.gz", hash = "sha256:c491cba545f67be9697508727818d8626626366245ae64456fe29f37e9bbea64", size = 7080, upload-time = "2025-03-22T10:52:30.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/6f/0b3625be30a1a50f9e4c2cb2ec147b08f15ed0e9f8444efcf274b751300b/sphinx_reredirects-0.1.6-py3-none-any.whl", hash = "sha256:efd50c766fbc5bf40cd5148e10c00f2c00d143027de5c5e48beece93cc40eeea", size = 5675, upload-time = "2025-03-22T10:52:29.113Z" }, +] + +[[package]] +name = "sphinx-reredirects" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/8d/0e39fe2740d7d71417edf9a6424aa80ca2c27c17fc21282cdc39f90d5a40/sphinx_reredirects-1.1.0.tar.gz", hash = "sha256:fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29", size = 614624, upload-time = "2025-12-22T08:28:02.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/81/b5dd07067f3daac6d23687ec737b2d593740671ebcd145830c8f92d381c5/sphinx_reredirects-1.1.0-py3-none-any.whl", hash = "sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9", size = 6351, upload-time = "2025-12-22T08:27:59.724Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] diff --git a/news/3785.added.md b/news/3785.added.md new file mode 100644 index 0000000000..db0ec5602d --- /dev/null +++ b/news/3785.added.md @@ -0,0 +1,5 @@ +(uv) Support for basic `uv.lock` generation via the `lock` rule +and basic support for importing the `uv.lock` file itself. Since this +may have bugs, please report this by creating new tickets. +Work towards [#2787](https://github.com/bazel-contrib/rules_python/issues/2787) +and [#1975](https://github.com/bazel-contrib/rules_python/issues/1975). diff --git a/python/private/py_repositories.bzl b/python/private/py_repositories.bzl index 9c4051ede9..3489613a6c 100644 --- a/python/private/py_repositories.bzl +++ b/python/private/py_repositories.bzl @@ -72,6 +72,12 @@ def py_repositories(transition_settings = []): strip_prefix = "rules_cc-0.1.5", urls = ["https://github.com/bazelbuild/rules_cc/releases/download/0.1.5/rules_cc-0.1.5.tar.gz"], ) + http_archive( + name = "toml.bzl", + sha256 = "63633c762bdb4d836add5a9a81deeeae93c4b2edbd62ac032733020b5652b90a", + strip_prefix = "toml.bzl-0.4.0", + url = "https://github.com/jvolkman/toml.bzl/releases/download/v0.4.0/toml.bzl-v0.4.0.tar.gz", + ) http_archive( name = "package_metadata", sha256 = "8f27dc7393e3f3bdc793bdc4ba36d67a63c22cc9d38cc65d3204654974ea4563", diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 5e109a2a21..23f636c870 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -154,6 +154,7 @@ bzl_library( "@pythons_hub//:interpreters_bzl", "@pythons_hub//:versions_bzl", "@rules_python_internal//:rules_python_config_bzl", + "@toml.bzl//:toml", ], ) @@ -263,6 +264,7 @@ bzl_library( ":select_whl_bzl", "//python/private:normalize_name_bzl", "//python/private:repo_utils_bzl", + "//python/uv/private:uv_lock_to_requirements_bzl", ], ) diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index 5160b81ce8..62d2c8de16 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -17,6 +17,7 @@ load("@pythons_hub//:interpreters.bzl", "INTERPRETER_LABELS") load("@pythons_hub//:versions.bzl", "MINOR_MAPPING") load("@rules_python_internal//:rules_python_config.bzl", rp_config = "config") +load("@toml.bzl", "toml") load("//python/private:auth.bzl", "AUTH_ATTRS") load("//python/private:normalize_name.bzl", "normalize_name") load("//python/private:repo_utils.bzl", "repo_utils") @@ -35,7 +36,7 @@ def _whl_mods_impl(whl_mods_dict): """Implementation of the pip.whl_mods tag class. This creates the JSON files used to modify the creation of different wheels. -""" + """ for hub_name, whl_maps in whl_mods_dict.items(): whl_mods = {} @@ -260,6 +261,7 @@ def build_config( for name, values in defaults["platforms"].items() }, enable_pipstar_extract = enable_pipstar_extract, + toml_decode = toml.decode, ) def parse_modules( @@ -908,6 +910,12 @@ a string `"{os}_{arch}"` as the value here. You could also use `"{os}_{arch}_fre :::{versionadded} 1.8.0 ::: +""", + ), + "uv_lock": attr.label( + doc = """\ +(label, optional): A label pointing to the uv.lock file. If provided, +the uv.lock file will be used as the primary source for package metadata. """, ), "whl_modifications": attr.label_keyed_string_dict( diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl index 34b726d6f6..7717f36731 100644 --- a/python/private/pypi/hub_builder.bzl +++ b/python/private/pypi/hub_builder.bzl @@ -503,9 +503,11 @@ def _create_whl_repos( ), logger = logger, ), + uv_lock = pip_attr.uv_lock, platforms = platforms, extra_pip_args = pip_attr.extra_pip_args, get_index_urls = self._get_index_urls.get(pip_attr.python_version), + toml_decode = getattr(self._config, "toml_decode", None), logger = logger, ) diff --git a/python/private/pypi/parse_requirements.bzl b/python/private/pypi/parse_requirements.bzl index c976e74bca..fd0399fc86 100644 --- a/python/private/pypi/parse_requirements.bzl +++ b/python/private/pypi/parse_requirements.bzl @@ -28,6 +28,7 @@ behavior. load("//python/private:normalize_name.bzl", "normalize_name") load("//python/private:repo_utils.bzl", "repo_utils") +load("//python/uv/private:uv_lock_to_requirements.bzl", "uv_lock_extras_map") # buildifier: disable=bzl-visibility load(":argparse.bzl", "argparse") load(":index_sources.bzl", "index_sources") load(":parse_requirements_txt.bzl", "parse_requirements_txt") @@ -40,15 +41,18 @@ def parse_requirements( *, requirements_by_platform = {}, extra_pip_args = [], - platforms = {}, + platforms, get_index_urls = None, extract_url_srcs = True, + uv_lock = None, + toml_decode = None, logger): """Get the requirements with platforms that the requirements apply to. Args: ctx: A context that has .read function that would read contents from a label. - platforms: The target platform descriptions. + platforms: The target platform descriptions. Cannot be empty and needs to have + at least the host platform and the definitions. requirements_by_platform (label_keyed_string_dict): a way to have different package versions (or different packages) for different os, arch combinations. @@ -59,28 +63,248 @@ def parse_requirements( distribution names to query. extract_url_srcs: A boolean to enable extracting URLs from requirement lines to enable using bazel downloader. + uv_lock: {type}`str | None` an optional label/file path to the uv.lock + file. The ctx.read function will be used to read the contents. + If provided, the function will use the uv.lock file as the primary + source for package metadata and perform a consistency check against + requirements files if both are provided. + toml_decode: {type}`callable | None` A function to decode TOML + content (e.g. `toml.decode`). Required when `uv_lock` is provided. logger: repo_utils.logger, a simple struct to log diagnostic messages. Returns: - {type}`dict[str, list[struct]]` where the key is the distribution name and the struct - contains the following attributes: - * `distribution`: {type}`str` The non-normalized distribution name. - * `srcs`: {type}`struct` The parsed requirement line for easier Simple - API downloading (see `index_sources` return value). - * `target_platforms`: {type}`list[str]` Target platforms that this package is for. - The format is `cp3{minor}_{os}_{arch}`. + {type}`list[struct]` where each struct contains the following attributes: + * `name`: {type}`str` The normalized distribution name. * `is_exposed`: {type}`bool` `True` if the package should be exposed via the hub repository. - * `extra_pip_args`: {type}`list[str]` pip args to use in case we are - not using the bazel downloader to download the archives. This should - be passed to {obj}`whl_library`. - * `whls`: {type}`list[struct]` The list of whl entries that can be - downloaded using the bazel downloader. - * `sdist`: {type}`list[struct]` The sdist that can be downloaded using - the bazel downloader. - - The second element is extra_pip_args should be passed to `whl_library`. + * `is_multiple_versions`: {type}`bool` `True` if multiple versions have been + specified for this package. + * `index_url`: {type}`str` The index URL used to download the package. + * `srcs`: {type}`list[struct]` A list of per-distribution source entries, each + containing: `distribution`, `extra_pip_args`, `requirement_line`, + `target_platforms`, `filename`, `sha256`, `url`, `yanked`. """ + if uv_lock and toml_decode: + uv_lock = toml_decode(ctx.read(uv_lock)) + return _parse_requirements_with_uv_lock( + ctx = ctx, + requirements_by_platform = requirements_by_platform, + extra_pip_args = extra_pip_args, + platforms = platforms, + get_index_urls = get_index_urls, + extract_url_srcs = extract_url_srcs, + uv_lock = uv_lock, + logger = logger, + ) + + return _parse_requirements_from_req_files( + ctx = ctx, + requirements_by_platform = requirements_by_platform, + extra_pip_args = extra_pip_args, + platforms = platforms, + get_index_urls = get_index_urls, + extract_url_srcs = extract_url_srcs, + logger = logger, + ) + +def _get_all_platforms(requirements_by_platform): + """Get the set of all platform names from requirements_by_platform.""" + all_platforms = {} + for plats in requirements_by_platform.values(): + for p in plats: + all_platforms[p] = None + return sorted(all_platforms) + +def _parse_requirements_with_uv_lock( + ctx, + *, + requirements_by_platform, + extra_pip_args, + platforms, + get_index_urls, + extract_url_srcs, + uv_lock, + logger): + """Parse requirements using uv.lock as the primary source.""" + if uv_lock: + return _parse_uv_lock_json( + uv_lock = uv_lock, + all_platforms = _get_all_platforms(requirements_by_platform) if requirements_by_platform else sorted(platforms.keys()), + platforms = platforms, + extra_pip_args = extra_pip_args, + logger = logger, + ) + + return _parse_requirements_from_req_files( + ctx = ctx, + requirements_by_platform = requirements_by_platform, + extra_pip_args = extra_pip_args, + platforms = platforms, + get_index_urls = get_index_urls, + extract_url_srcs = extract_url_srcs, + logger = logger, + ) + +def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, platforms = {}): + """Parse uv.lock JSON and build the same return structs as parse_requirements. + + Args: + uv_lock: {type}`dict` The decoded uv.lock contents. + all_platforms: {type}`list[str]` The list of all platform names. + logger: {type}`struct` A logger for diagnostic messages. + extra_pip_args: {type}`list[str] | None` Extra pip arguments to pass through. + platforms: {type}`dict[str, struct]` A dict of platform name to platform info + (containing `.env` with PEP 508 marker environment). + + Returns: + {type}`list[struct]` The same format as {func}`parse_requirements`. + """ + extras_map = uv_lock_extras_map(uv_lock) + + uv_packages = {} + + if not platforms: + fail("BUG: platforms must be configured") + + for pkg in uv_lock["package"]: + name = pkg["name"] + version = pkg["version"] + norm_name = normalize_name(name) + entry = uv_packages.setdefault(norm_name, { + "distribution": name, + "resolved_srcs": [], + "versions": {}, + }) + entry["versions"][version] = None + + pkg_extras = sorted(extras_map.get(name, [])) + extra_str = "[{}]".format(",".join(pkg_extras)) if pkg_extras else "" + + markers = pkg.get("resolution-markers", []) + if markers: + marker_expr = " or ".join(markers) + pkg_platforms = [ + p + for p in all_platforms + if evaluate(marker_expr, env = platforms[p].env) + ] + else: + pkg_platforms = list(all_platforms) + + # Prepare candidates + candidates = [] + for wheel in pkg.get("wheels", []): + url = wheel["url"] + _, _, filename = url.rpartition("/") + sha256 = wheel.get("hash", "").replace("sha256:", "") + candidates.append(struct( + filename = filename, + url = url, + sha256 = sha256, + kind = "wheel", + )) + + sdist_struct = None + sdist = pkg.get("sdist", None) + if sdist: + url = sdist["url"] + _, _, filename = url.rpartition("/") + sha256 = sdist.get("hash", "").replace("sha256:", "") + sdist_struct = struct( + filename = filename, + url = url, + sha256 = sha256, + kind = "sdist", + ) + + git_struct = None + if pkg.get("source", {}).get("git"): + url = pkg["source"]["git"] + _, _, filename = url.rpartition("/") + git_struct = struct( + filename = filename, + url = url, + sha256 = "", + kind = "git", + source = pkg["source"], + ) + + plat_to_src = {} + for p in pkg_platforms: + platform = platforms.get(p) + if not platform: + continue + + best_wheel = None + if candidates: + best_wheel = select_whl( + whls = candidates, + python_version = platform.env.get("python_full_version", "3"), + whl_platform_tags = platform.whl_platform_tags, + whl_abi_tags = platform.whl_abi_tags, + implementation_name = platform.env.get("implementation_name", "cpython"), + limit = 1, + logger = logger, + ) + + if best_wheel: + plat_to_src[p] = best_wheel + elif sdist_struct: + plat_to_src[p] = sdist_struct + elif git_struct: + plat_to_src[p] = git_struct + + # Group platforms by resolved source + src_to_plats = {} + for p, src in plat_to_src.items(): + key = src.filename + src.sha256 + src_to_plats.setdefault(key, struct(src = src, plats = [])).plats.append(p) + + # Build resolved_srcs + for key, val in src_to_plats.items(): + src = val.src + plats = sorted(val.plats) + requirement_line = "{name}{extras}=={version}".format( + name = name, + extras = extra_str, + version = version, + ) + entry["resolved_srcs"].append(struct( + distribution = name, + extra_pip_args = extra_pip_args or [], + requirement_line = requirement_line, + target_platforms = plats, + filename = src.filename, + sha256 = src.sha256, + url = src.url, + yanked = None, + )) + + ret = [] + for norm_name, info in sorted(uv_packages.items()): + versions = sorted(info["versions"].keys()) + item = struct( + name = norm_name, + is_exposed = True, + is_multiple_versions = len(versions) > 1, + index_url = "", + srcs = info["resolved_srcs"], + ) + ret.append(item) + + logger.debug(lambda: "Parsed {} packages from uv.lock".format(len(ret))) + return ret + +def _parse_requirements_from_req_files( + ctx, + *, + requirements_by_platform, + extra_pip_args, + platforms, + get_index_urls, + extract_url_srcs, + logger): + """Parse requirements from requirements.txt files (existing behavior).""" options = {} requirements = {} all_files_parsed = {} @@ -90,14 +314,8 @@ def parse_requirements( logger.trace(lambda: "Using {} for {}".format(file, plats)) contents = ctx.read(file) - # Parse the requirements file directly in starlark to get the information - # needed for the whl_library declarations later. parse_result = parse_requirements_txt(contents) - # Save parsed results from ALL files, even those with no matching - # platforms. This ensures the distributions dict (used for index URL - # queries) includes packages from all platform files, making the - # lockfile facts platform-independent. if file not in all_files_parsed: all_files_parsed[file] = parse_result.requirements @@ -133,23 +351,18 @@ def parse_requirements( options[plat] = pip_args + index_url = argparse.index_url(pip_args, index_url) + extra_index_urls = argparse.extra_index_url(pip_args, []) + platform = argparse.platform(pip_args, []) + if platform: + get_index_urls = None + + reqs_by_name = {} requirements_by_platform = {} for plat, parse_results in requirements.items(): - # Replicate a surprising behavior that WORKSPACE builds allowed: - # Defining a repo with the same name multiple times, but only the last - # definition is respected. - # The requirement lines might have duplicate names because lines for extras - # are returned as just the base package name. e.g., `foo[bar]` results - # in an entry like `("foo", "foo[bar] == 1.0 ...")`. requirements_dict = {} for entry in sorted( parse_results, - # Get the longest match and fallback to original WORKSPACE sorting, - # which should get us the entry with most extras. - # - # FIXME @aignas 2024-05-13: The correct behaviour might be to get an - # entry with all aggregated extras, but it is unclear if we - # should do this now. key = lambda x: (len(x[1].partition("==")[0]), x), ): req_line = entry[1] @@ -157,32 +370,28 @@ def parse_requirements( requirements_dict[req.name] = entry - extra_pip_args = options[plat] + extra_pip_args_for_plat = options[plat] for distribution, requirement_line in requirements_dict.values(): - for_whl = requirements_by_platform.setdefault( + for_whl = reqs_by_name.setdefault( normalize_name(distribution), {}, ) for_req = for_whl.setdefault( - (requirement_line, ",".join(extra_pip_args)), + (requirement_line, ",".join(extra_pip_args_for_plat)), struct( distribution = distribution, srcs = index_sources(requirement_line), requirement_line = requirement_line, target_platforms = [], - extra_pip_args = extra_pip_args, + extra_pip_args = extra_pip_args_for_plat, ), ) for_req.target_platforms.append(plat) index_urls = {} if get_index_urls: - # Collect all distributions from all requirements files irrespective - # of python_version and platform markers. This ensures that the index - # is queried for all packages, not just those matching the current - # platform's markers. distributions = {} for entries in all_files_parsed.values(): for entry in entries: @@ -203,7 +412,7 @@ def parse_requirements( ) ret = [] - for name, reqs in sorted(requirements_by_platform.items()): + for name, reqs in sorted(reqs_by_name.items()): requirement_target_platforms = {} for r in reqs.values(): for p in r.target_platforms: @@ -219,22 +428,7 @@ def parse_requirements( logger = logger, ) - # FIXME @aignas 2025-11-24: we can get the list of target platforms here - # - # However it is likely that we may stop exposing packages like torch in here - # which do not have wheels for all osx platforms. - # - # If users specify the target platforms accurately, then it is a different - # (better) story, but we may not be able to guarantee this - # - # target_platforms = [ - # p - # for dist in package_srcs - # for p in dist.target_platforms - # ] - item = struct( - # Return normalized names name = normalize_name(name), is_exposed = len(requirement_target_platforms) == len(requirements), is_multiple_versions = len(reqs.values()) > 1, diff --git a/python/uv/private/BUILD.bazel b/python/uv/private/BUILD.bazel index 3e4d6c7baa..4ac75f4d45 100644 --- a/python/uv/private/BUILD.bazel +++ b/python/uv/private/BUILD.bazel @@ -15,13 +15,8 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility -exports_files( - srcs = [ - "lock_copier.py", - ], - # only because this is used from a macro to template - visibility = ["//visibility:public"], -) +# public only because this is used from a macro to template +_NOT_REALLY_PUBLIC = ["//visibility:public"] filegroup( name = "distribution", @@ -68,6 +63,15 @@ bzl_library( ], ) +bzl_library( + name = "uv_lock_to_requirements_bzl", + srcs = ["uv_lock_to_requirements.bzl"], + visibility = [ + "//python/private:__subpackages__", + "//python/uv:__subpackages__", + ], +) + bzl_library( name = "uv_repository_bzl", srcs = ["uv_repository.bzl"], @@ -98,11 +102,28 @@ bzl_library( ) filegroup( - name = "lock_template", + name = "lock_copier_template", + srcs = ["template/lock_copier.py"], + target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], + visibility = _NOT_REALLY_PUBLIC, +) + +filegroup( + name = "uv_lock_template", + srcs = select({ + "@platforms//os:windows": ["template/uv_lock.bat"], + "//conditions:default": ["template/uv_lock.sh"], + }), + target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], + visibility = _NOT_REALLY_PUBLIC, +) + +filegroup( + name = "uv_pip_compile_template", srcs = select({ - "@platforms//os:windows": ["lock.bat"], - "//conditions:default": ["lock.sh"], + "@platforms//os:windows": ["template/uv_pip_compile.bat"], + "//conditions:default": ["template/uv_pip_compile.sh"], }), target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], - visibility = ["//visibility:public"], + visibility = _NOT_REALLY_PUBLIC, ) diff --git a/python/uv/private/lock.bat b/python/uv/private/lock.bat deleted file mode 100755 index 5190ddf9eb..0000000000 --- a/python/uv/private/lock.bat +++ /dev/null @@ -1,7 +0,0 @@ -if defined BUILD_WORKSPACE_DIRECTORY ( - set "out=%BUILD_WORKSPACE_DIRECTORY%\{{src_out}}" -) else ( - exit /b 1 -) - -"{{args}}" --output-file "%out%" %* diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index 7b2aa36098..8caf0ed3de 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -30,6 +30,7 @@ _RunLockInfo = provider( "args": "The args passed to the `uv` by default when running the runnable target.", "env": "The env passed to the execution.", "srcs": "Source files required to run the runnable target.", + "template": "The template file for writing a script.", }, ) @@ -70,10 +71,11 @@ def _args(ctx): add_all = _add_all, ) -def _lock_impl(ctx): - srcs = [] + ctx.files.srcs - +def _common_lock(ctx, locker): fname = "{}.out".format(ctx.label.name) + + # TODO @aignas 2026-06-21: do not append python_version for uv.lock as it should work for all + # python versions python_version = ctx.attr.python_version if python_version: fname = "{}.{}.out".format( @@ -86,20 +88,20 @@ def _lock_impl(ctx): uv = toolchain_info.uv_toolchain_info.uv[DefaultInfo].files_to_run.executable args = _args(ctx) + args.add(uv) + + # The output params are: + # * srcs are the srcs for the locking command action inputs tracking + # * output_filename if set is to ensure that we can do special prep for uv.lock versus + # requirements.txt + # * mnemonic is for the action mnemonic + # * progress_message is the same + srcs, output_filename, mnemonic, progress_message = locker(args, output) + args.add_all([ - uv, - "pip", - "compile", "--no-python-downloads", "--no-cache", ]) - pkg = ctx.label.package - update_target = ctx.attr.update_target - args.add("--custom-compile-command", "bazel run //{}:{}".format(pkg, update_target)) - if ctx.attr.generate_hashes: - args.add("--generate-hashes") - if not ctx.attr.strip_extras: - args.add("--no-strip-extras") project = None if ctx.attr.project: @@ -116,13 +118,11 @@ def _lock_impl(ctx): project = src.dirname if project == None: - project = pkg + project = ctx.label.package if project: args.add_all([project], before_each = "--project") - args.add_all(ctx.files.build_constraints, before_each = "--build-constraints") - args.add_all(ctx.files.constraints, before_each = "--constraints") args.add_all(ctx.attr.args) exec_tools = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE].exec_tools @@ -130,90 +130,83 @@ def _lock_impl(ctx): python = runtime.interpreter or runtime.interpreter_path python_files = runtime.files or depset() args.add("--python", python) - args.add_all(srcs) - - args.run_shell.add("--output-file", output) # These arguments does not change behaviour, but it reduces the output from # the command, which is especially verbose in stderr. - args.run_shell.add("--no-progress") - args.run_shell.add("--quiet") + args.add("--no-progress") + args.add("--quiet") - # Generate a wrapper script that copies the existing output (if any) and - # then runs uv. On POSIX, args are forwarded via exec "$@". On Windows, - # the full command line is embedded in the .bat file with backslash paths - # (CMD doesn't recognize forward slashes in executable paths). - if ctx.attr.is_windows: - ext = ".bat" - lines = ["@echo off"] - else: - ext = ".sh" - lines = ["#!/usr/bin/env bash", "set -euo pipefail"] + if ctx.files.existing_output: + src_out = ctx.files.existing_output[0].path + elif output_filename: + # special case - the output filename has to be in the source tree and it has to have a + # special name, we use the project folder to determine this. - python_path = getattr(python, "path", python) + if not project: + fail("Cannot lock this if the project dir is unset or cannot be infered") - if ctx.files.existing_output: - python_cmd = "from shutil import copy; copy(\"{src}\", \"{dst}\")".format( - src = ctx.files.existing_output[0].path, - dst = output.path, + src_out = "{project}/{out_filename}".format( + project = project, + out_filename = output_filename, ) - if ctx.attr.is_windows: - # In batch files, use "" to escape internal double quotes. - lines.append( - "\"{py}\" -c \"from shutil import copy; copy(\"\"{src}\"\", \"\"{dst}\"\")\"".format( - py = python_path, - src = ctx.files.existing_output[0].path, - dst = output.path, - ), - ) - else: - lines.append("{py} -c '{cmd}'".format( - py = python_path, - cmd = python_cmd, - )) + else: + src_out = "" - if ctx.attr.is_windows: - # Build the command line with backslash paths for CMD. - # args.run_info has most args; add the output/progress/quiet - # args that were only added directly to args.run_shell. - def _quote(arg): + is_windows = ctx.attr.is_windows + if is_windows: + path_sep = "\\" + ext = ".bat" + else: + path_sep = "/" + ext = "" + + output_path = output.path.replace("/", path_sep) if is_windows else output.path + src_out_path = src_out.replace("/", path_sep) if is_windows else src_out + + # On Windows, all args must be embedded in the .bat script because + # arguments are not passed on the command line. + if is_windows: + args_parts = [] + for i, arg in enumerate(args.run_info): if hasattr(arg, "path"): - arg = arg.path.replace("/", "\\") - else: - arg = str(arg) - return '"' + arg.replace('"', '""') + '"' + arg = arg.path - bat_args = args.run_info + [ - "--output-file", - output, - "--no-progress", - "--quiet", - ] - lines.append(" ".join([_quote(a) for a in bat_args])) - - # Normalize CRLF line endings in the output on Windows. - lines.append( - "\"{py}\" -c \"import pathlib;p=pathlib.Path(r\"\"{dst}\"\");p.write_bytes(p.read_bytes().replace(b'\\r\\n', b'\\n'))\"".format( - py = python_path, - dst = output.path, - ), - ) + # Only use backslashes for the executable itself (first arg) + # to ensure CMD can run it, but keep forward slashes for arguments + # so that uv writes consistent paths in comments. + if i == 0: + a = arg.replace("/", "\\") + else: + a = arg + a = a.replace('"', '""') + args_parts.append('"' + a + '"') + + # uv pip compile adds --output-file to run_shell (not run_info). + # For the lock case, output_filename is "uv.lock" and uv lock + # writes to the project directory without --output-file. + if not output_filename: + args_parts.append('"--output-file"') + args_parts.append('"' + output_path + '"') + windows_args = " ".join(args_parts) else: - lines.append('exec "$@"') + windows_args = " ".join([]) script = ctx.actions.declare_file(ctx.label.name + "_lock" + ext) - if ctx.attr.is_windows: - content = "\r\n".join(lines) + "\r\n" - else: - content = "\n".join(lines) + "\n" - ctx.actions.write(output = script, content = content, is_executable = True) - - srcs = srcs + ctx.files.build_constraints + ctx.files.constraints + ctx.actions.expand_template( + template = ctx.files._template[0], + substitutions = { + '"{{args}}"': windows_args, + "{{out}}": output_path, + "{{src_out}}": src_out_path, + }, + output = script, + is_executable = True, + ) ctx.actions.run( executable = script, + mnemonic = mnemonic, inputs = srcs + ctx.files.existing_output, - mnemonic = "PyRequirementsLockUv", outputs = [output], # On Windows, the command line is embedded directly in the .bat # script (with backslash paths). On POSIX, args are forwarded via @@ -224,12 +217,13 @@ def _lock_impl(ctx): python_files, script, ], + # User reported being unable to add `--action_env` and get it to work. # Without this flag. # # Ref: https://app.slack.com/client/TA4K1KQ87/CA306CEV6 use_default_shell_env = True, - progress_message = "Creating a requirements.txt with uv: %{label}", + progress_message = progress_message, env = ctx.attr.env, ) @@ -242,9 +236,36 @@ def _lock_impl(ctx): srcs + [uv], transitive = [python_files], ), + template = ctx.files._template[0], ), ] +def _pip_compile_impl(ctx): + def _setup_args(args, output): + args.add_all(["pip", "compile"]) + pkg = ctx.label.package + update_target = ctx.attr.update_target + args.add("--custom-compile-command", "bazel run //{}:{}".format(pkg, update_target)) + + if ctx.attr.generate_hashes: + args.add("--generate-hashes") + if not ctx.attr.strip_extras: + args.add("--no-strip-extras") + + args.add_all(ctx.files.build_constraints, before_each = "--build-constraints") + args.add_all(ctx.files.constraints, before_each = "--constraints") + + args.run_shell.add("--output-file", output) + mnemonic = "PyRequirementsLockUv" + progress_message = "Creating a requirements.txt with uv: %{label}" + + args.add_all(ctx.files.srcs) + srcs = ctx.files.srcs + ctx.files.build_constraints + ctx.files.constraints + + return srcs, None, mnemonic, progress_message + + return _common_lock(ctx, _setup_args) + def _transition_impl(input_settings, attr): settings = { labels.PYTHON_VERSION: input_settings[labels.PYTHON_VERSION], @@ -259,65 +280,72 @@ _python_version_transition = transition( outputs = [labels.PYTHON_VERSION], ) -_lock = rule( - implementation = _lock_impl, - doc = """\ -The lock rule that does the locking in a build action (that makes it possible -to use RBE) and also prepares information for a `bazel run` executable rule. -""", - attrs = { - "args": attr.string_list( - doc = "Public, see the docs in the macro.", - ), - "build_constraints": attr.label_list( - allow_files = True, - doc = "Public, see the docs in the macro.", - ), - "constraints": attr.label_list( - allow_files = True, - doc = "Public, see the docs in the macro.", - ), - "env": attr.string_dict( - doc = "Public, see the docs in the macro.", - ), - "existing_output": attr.label( - mandatory = False, - allow_single_file = True, - doc = """\ +_common_attrs = { + "args": attr.string_list( + doc = "Public, see the docs in the macro.", + ), + "env": attr.string_dict( + doc = "Public, see the docs in the macro.", + ), + "existing_output": attr.label( + mandatory = False, + allow_single_file = True, + doc = """\ An already existing output file that is used as a basis for further modifications and the locking is not done from scratch. """, - ), - "generate_hashes": attr.bool( - doc = "Public, see the docs in the macro.", - default = True, - ), - "is_windows": attr.bool(mandatory = True), - "output": attr.string( - doc = "Public, see the docs in the macro.", - mandatory = True, - ), - "project": attr.string( - doc = """\ + ), + "is_windows": attr.bool(mandatory = True), + "output": attr.string( + doc = "Public, see the docs in the macro.", + mandatory = True, + ), + "project": attr.string( + doc = """\ Overrides the `--project` directory passed to `uv pip compile`. If not set, the project directory is auto-detected: when `pyproject.toml` files are in {obj}`lock.srcs`, the one with the shortest directory path is selected. This makes `uv` read `[tool.uv]` settings (e.g. `no-build-isolation`, `exclude-dependencies`) from that `pyproject.toml`. +""", + ), + "python_version": attr.string( + doc = "Public, see the docs in the macro.", + ), + "srcs": attr.label_list( + mandatory = True, + allow_files = True, + doc = "Public, see the docs in the macro.", + ), + "_allowlist_function_transition": attr.label( + default = "@bazel_tools//tools/allowlists/function_transition_allowlist", + ), +} + +_pip_compile = rule( + implementation = _pip_compile_impl, + doc = """\ +The lock rule that does the locking in a build action (that makes it possible +to use RBE) and also prepares information for a `bazel run` executable rule. -:::{versionadded} 2.1.0 +:::{versionchanged} 2.1.0 +Added the {attr}`project` to configure the project setting if autodetection fails. ::: """, - ), - "python_version": attr.string( + attrs = { + "build_constraints": attr.label_list( + allow_files = True, doc = "Public, see the docs in the macro.", ), - "srcs": attr.label_list( - mandatory = True, + "constraints": attr.label_list( allow_files = True, doc = "Public, see the docs in the macro.", ), + "generate_hashes": attr.bool( + doc = "Public, see the docs in the macro.", + default = True, + ), "strip_extras": attr.bool( doc = "Public, see the docs in the macro.", default = False, @@ -328,8 +356,46 @@ shortest directory path is selected. This makes `uv` read The string to input for the 'uv pip compile'. """, ), - "_allowlist_function_transition": attr.label( - default = "@bazel_tools//tools/allowlists/function_transition_allowlist", + "_template": attr.label( + default = "//python/uv/private:uv_pip_compile_template", + doc = """\ +The template to be used for 'uv pip compile'. This is either .bat or bash +script depending on what the target platform is executed on. +""", + ), + } | _common_attrs, + toolchains = [ + EXEC_TOOLS_TOOLCHAIN_TYPE, + UV_TOOLCHAIN_TYPE, + ], + cfg = _python_version_transition, +) + +def _lock_impl(ctx): + def _setup_args(args, _output): + args.add("lock") + mnemonic = "PyUvLock" + progress_message = "Creating a uv.lock with uv: %{label}" + + return ctx.files.srcs, "uv.lock", mnemonic, progress_message + + return _common_lock(ctx, _setup_args) + +_lock = rule( + implementation = _lock_impl, + doc = """\ +The lock rule that does the locking in a build action and also prepares information for a `bazel +run` executable rule. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + attrs = _common_attrs | { + "_template": attr.label( + default = "//python/uv/private:uv_lock_template", + doc = """\ +The template to be used for 'uv lock'. Used when output ends with '.lock'. +""", ), }, toolchains = [ @@ -339,7 +405,7 @@ The string to input for the 'uv pip compile'. cfg = _python_version_transition, ) -def _lock_run_impl(ctx): +def _run_impl(ctx): if ctx.attr.is_windows: path_sep = "\\" ext = ".bat" @@ -359,9 +425,10 @@ def _lock_run_impl(ctx): return shell.quote(arg) info = ctx.attr.lock[_RunLockInfo] + executable = ctx.actions.declare_file(ctx.label.name + ext) ctx.actions.expand_template( - template = ctx.files._template[0], + template = info.template, substitutions = { '"{{args}}"': " ".join([_maybe_path(arg) for arg in info.args]), "{{src_out}}": "{}/{}".format(ctx.label.package, ctx.attr.output).replace( @@ -383,8 +450,8 @@ def _lock_run_impl(ctx): ), ] -_lock_run = rule( - implementation = _lock_run_impl, +_run_locker = rule( + implementation = _run_impl, doc = """\ """, attrs = { @@ -397,13 +464,6 @@ _lock_run = rule( "output": attr.string( doc = """\ The output that we would be updated, relative to the package the macro is used in. -""", - ), - "_template": attr.label( - default = "//python/uv/private:lock_template", - doc = """\ -The template to be used for 'uv pip compile'. This is either .ps1 or bash -script depending on what the target platform is executed on. """, ), }, @@ -424,8 +484,10 @@ def _maybe_file(path): path: {type}`str` the file name. """ for p in native.glob([path], allow_empty = True): - if path == p: - return p + if path != p: + continue + + return p return None @@ -438,7 +500,7 @@ def _expand_template_impl(ctx): dst = "{}/{}".format(pkg, ctx.attr.output) if pkg else ctx.attr.output ctx.actions.expand_template( - template = ctx.files._template[0], + template = ctx.files._lock_copier_template[0], substitutions = { "{{dst}}": dst, "{{src}}": "{}".format(ctx.files.src[0].short_path), @@ -454,8 +516,8 @@ _expand_template = rule( "output": attr.string(mandatory = True), "src": attr.label(mandatory = True), "update_target": attr.string(mandatory = True), - "_template": attr.label( - default = "//python/uv/private:lock_copier.py", + "_lock_copier_template": attr.label( + default = "//python/uv/private:lock_copier_template", allow_single_file = True, ), }, @@ -513,8 +575,8 @@ def lock( build_constraints: {type}`list[Label]` The list of build constraints to use. constraints: {type}`list[Label]` The list of constraints files to use. generate_hashes: {type}`bool` Generate hashes for all of the - requirements. This is a must if you want to use - {attr}`pip.parse.experimental_index_url`. Defaults to `True`. + requirements. Only meaningful for `requirements.txt` style output. + Defaults to `True`. strip_extras: {type}`bool` whether to strip extras from the output. Currently `rules_python` requires `--no-strip-extras` to properly function, but sometimes one may want to not have the extras if you @@ -547,43 +609,52 @@ def lock( if not BZLMOD_ENABLED: kwargs["target_compatible_with"] = ["@platforms//:incompatible"] - _lock( - name = name, - args = args, - build_constraints = build_constraints, - constraints = constraints, - env = env, - existing_output = maybe_out, - generate_hashes = generate_hashes, - project = project, - is_windows = select({ + uv_kwargs = { + "is_windows": select({ "@platforms//os:windows": True, "//conditions:default": False, }), - python_version = python_version, - srcs = srcs, - strip_extras = strip_extras, - update_target = update_target, - output = out, - tags = [ + "output": out, + } | kwargs + + lock_target_kwargs = { + "args": args, + "env": env, + "existing_output": maybe_out, + "project": project, + "python_version": python_version, + "srcs": srcs, + "tags": [ "no-cache", "requires-network", ] + tags, - **kwargs - ) + } | uv_kwargs + + # NOTE @aignas 2026-06-20: if the user passes these args the command will fail + # with an error message instead of silently ignoring the args + if build_constraints: + lock_target_kwargs["build_constraints"] = build_constraints + if constraints: + lock_target_kwargs["constraints"] = constraints + + if out.endswith(".lock"): + _lock(name = name, **lock_target_kwargs) + else: + _pip_compile( + name = name, + generate_hashes = generate_hashes, + strip_extras = strip_extras, + update_target = update_target, + **lock_target_kwargs + ) # A target for updating the in-tree version directly by skipping the in-action - # uv pip compile. - _lock_run( + # uv pip compile or uv lock, depending what is defined for the locker_target. + _run_locker( name = locker_target, lock = name, - output = out, - is_windows = select({ - "@platforms//os:windows": True, - "//conditions:default": False, - }), tags = tags, - **kwargs + **uv_kwargs ) # FIXME @aignas 2025-03-20: is it possible to extend `py_binary` so that the diff --git a/python/uv/private/lock.sh b/python/uv/private/lock.sh deleted file mode 100755 index ffb19b2bea..0000000000 --- a/python/uv/private/lock.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then - readonly out="${BUILD_WORKSPACE_DIRECTORY}/{{src_out}}" -else - exit 1 -fi -exec "{{args}}" --output-file "$out" "$@" diff --git a/python/uv/private/lock_copier.py b/python/uv/private/template/lock_copier.py similarity index 100% rename from python/uv/private/lock_copier.py rename to python/uv/private/template/lock_copier.py diff --git a/python/uv/private/template/uv_lock.bat b/python/uv/private/template/uv_lock.bat new file mode 100755 index 0000000000..d4f4558c53 --- /dev/null +++ b/python/uv/private/template/uv_lock.bat @@ -0,0 +1,21 @@ +@echo off +if not defined BUILD_WORKSPACE_DIRECTORY goto :not_in_workspace +"{{args}}" %* +exit /b %ERRORLEVEL% + +:not_in_workspace + +if not exist "{{src_out}}" goto :no_src_out +copy /y "{{src_out}}" "{{out}}" +del /f "{{src_out}}" +copy /y "{{out}}" "{{src_out}}" +"{{args}}" %* +set "exit_code=%ERRORLEVEL%" +copy /y "{{src_out}}" "{{out}}" +exit /b %exit_code% + +:no_src_out +"{{args}}" %* +set "exit_code=%ERRORLEVEL%" +copy /y "{{src_out}}" "{{out}}" +exit /b %exit_code% \ No newline at end of file diff --git a/python/uv/private/template/uv_lock.sh b/python/uv/private/template/uv_lock.sh new file mode 100755 index 0000000000..a6dd6d3c39 --- /dev/null +++ b/python/uv/private/template/uv_lock.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + exec "{{args}}" "$@" +fi + +# Build action mode +# +# If the uv.lock exists, remove because the existing uv.lock file is read-only, then symlink so +# that we can reuse the existing contents and not do a full relock all the time. If +# nothing exists, just symlink. +# +# On Windows we do it with file copies: +# 1. If the file exists: +# 1. Copy the current file to out. +# 2. Rm the existing file +# 3. Copy the contents back +# 4. Run uv +# 5. Copy the contents to out. +# 1. If the current uv.lock does not exist yet +# 1. Run uv +# 2. Copy the contents to out. +readonly out="{{out}}" +if [[ -f "{{src_out}}" ]]; then + cp "{{src_out}}" "$out" + rm "{{src_out}}" + ln -s "$(pwd)"/"$out" "{{src_out}}" +else + ln -s "$(pwd)"/"$out" "{{src_out}}" +fi +exec "$@" diff --git a/python/uv/private/template/uv_pip_compile.bat b/python/uv/private/template/uv_pip_compile.bat new file mode 100755 index 0000000000..2ea1b5a44a --- /dev/null +++ b/python/uv/private/template/uv_pip_compile.bat @@ -0,0 +1,8 @@ +@echo off +if not defined BUILD_WORKSPACE_DIRECTORY goto :else +set "out=%BUILD_WORKSPACE_DIRECTORY%\{{src_out}}" +"{{args}}" --output-file "%out%" %* +exit /b %ERRORLEVEL% + +:else +"{{args}}" %* diff --git a/python/uv/private/template/uv_pip_compile.sh b/python/uv/private/template/uv_pip_compile.sh new file mode 100755 index 0000000000..ba379ed0f9 --- /dev/null +++ b/python/uv/private/template/uv_pip_compile.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + readonly out="${BUILD_WORKSPACE_DIRECTORY}/{{src_out}}" + exec "{{args}}" --output-file "$out" "$@" +fi + +# Build action mode: seed the output with the source file, then run +# the full command (which includes --output-file from the action args). +readonly out="{{out}}" +if [[ -f "{{src_out}}" ]]; then + cp "{{src_out}}" "$out" +fi +exec "$@" diff --git a/python/uv/private/uv_lock_to_requirements.bzl b/python/uv/private/uv_lock_to_requirements.bzl new file mode 100644 index 0000000000..73d912bbfd --- /dev/null +++ b/python/uv/private/uv_lock_to_requirements.bzl @@ -0,0 +1,139 @@ +"""Convert a parsed uv.lock to requirements.txt format.""" + +def uv_lock_extras_map(uv_lock): + """Compute extras for each package from uv.lock data. + + Args: + uv_lock: a decoded JSON struct from a uv.lock file. + + Returns: + A dict of {package_name: [extra1, extra2, ...]} for packages with extras. + """ + extras_map = {} + for pkg in uv_lock.get("package", []): + pkg_name = pkg.get("name", "") + + for extra in pkg.get("provides-extras", pkg.get("extras", [])): + _add_extras(extras_map, pkg_name, [extra]) + + opt_deps = pkg.get("optional-dependencies", {}) + if opt_deps: + _add_extras(extras_map, pkg_name, _sorted(opt_deps.keys())) + + deps = pkg.get("dependencies", []) + for dep in deps: + dep_name = dep.get("name", "") + dep_extras_raw = dep.get("extra", []) + dep_extras = [dep_extras_raw] if type(dep_extras_raw) == "string" else dep_extras_raw + if dep_extras and dep_name != pkg_name: + _add_extras(extras_map, dep_name, dep_extras) + + metadata = pkg.get("metadata", {}) + for rd in metadata.get("requires-dist", []): + rd_name = rd.get("name", "") + rd_extras_raw = rd.get("extras", []) + rd_extras = [rd_extras_raw] if type(rd_extras_raw) == "string" else rd_extras_raw + if rd_extras and rd_name != pkg_name: + _add_extras(extras_map, rd_name, rd_extras) + + return extras_map + +def uv_lock_to_requirements(uv_lock): + """Convert a parsed uv.lock JSON struct to a requirements.txt formatted string. + + Args: + uv_lock: a decoded JSON struct from a uv.lock file. + + Returns: + A requirements.txt formatted string. + """ + packages = uv_lock.get("package", []) + extras_map = uv_lock_extras_map(uv_lock) + + dependents = {} + for pkg in packages: + pkg_name = pkg.get("name", "") + deps = pkg.get("dependencies", []) + for dep in deps: + dep_name = dep.get("name", "") + if dep_name != pkg_name: + _add_dependent(dependents, dep_name, pkg_name) + opt_deps = pkg.get("optional-dependencies", {}) + for _extra, deps in opt_deps.items(): + for dep in deps: + dep_name = dep.get("name", "") + if dep_name != pkg_name: + _add_dependent(dependents, dep_name, pkg_name) + + lines = [] + for pkg in packages: + source = pkg.get("source", {}) + if not source.get("registry"): + continue + + pkg_name = pkg.get("name", "") + version = pkg.get("version", "") + + markers = pkg.get("resolution-markers", []) + hashes = _collect_hashes(pkg) + + pkg_extras = extras_map.get(pkg_name, []) + if pkg_extras: + req = "{}[{}]=={}".format(pkg_name, ",".join(pkg_extras), version) + else: + req = "{}=={}".format(pkg_name, version) + if markers: + req += " ; " + " or ".join(markers) + + if hashes: + req += " \\" + lines.append(req) + + _emit_hashes(lines, hashes) + + dep_vias = _sorted(dependents.get(pkg_name, [])) + if dep_vias: + if len(dep_vias) == 1: + lines.append(" # via " + dep_vias[0]) + else: + lines.append(" # via") + for via in dep_vias: + lines.append(" # " + via) + + lines.append("") + + return "\n".join(lines) + +def _add_dependent(dependents, dep_name, dependent_name): + if dep_name not in dependents: + dependents[dep_name] = [] + if dependent_name not in dependents[dep_name]: + dependents[dep_name].append(dependent_name) + +def _add_extras(extras_map, pkg_name, extras): + existing = extras_map.get(pkg_name, []) + for extra in extras: + if extra not in existing: + existing.append(extra) + extras_map[pkg_name] = _sorted(existing) + +def _collect_hashes(pkg): + hashes = [] + for wheel in pkg.get("wheels", []): + whash = wheel.get("hash", "") + if whash.startswith("sha256:"): + hashes.append(whash[len("sha256:"):]) + sdist = pkg.get("sdist") + if sdist: + shash = sdist.get("hash", "") + if shash.startswith("sha256:"): + hashes.append(shash[len("sha256:"):]) + return _sorted(hashes) + +def _sorted(items): + return sorted(items) + +def _emit_hashes(lines, hashes): + for i, h in enumerate(hashes): + suffix = " \\" if i < len(hashes) - 1 else "" + lines.append(" --hash=sha256:{}{}".format(h, suffix)) diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index 0408f791e1..c7468217e9 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -161,6 +161,8 @@ "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", + "https://bcr.bazel.build/modules/toml.bzl/0.4.1/MODULE.bazel": "6bc0b938f03ade8d58c2fca0ad5c3fa12b4764e1e1927ad50b0c860286db2167", + "https://bcr.bazel.build/modules/toml.bzl/0.4.1/source.json": "86a90afd8b43c9b69ad31f5c03998c3adcf4b08175e621addb93e3a38eec538b", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", diff --git a/tests/pypi/extension/pip_parse.bzl b/tests/pypi/extension/pip_parse.bzl index 95cf666056..939639f2c5 100644 --- a/tests/pypi/extension/pip_parse.bzl +++ b/tests/pypi/extension/pip_parse.bzl @@ -30,6 +30,7 @@ def pip_parse( target_platforms = [], simpleapi_skip = [], timeout = 600, + uv_lock = None, whl_modifications = {}, **kwargs): """A simple helper for testing to simulate the PyPI extension parse tag class""" @@ -61,6 +62,7 @@ def pip_parse( requirements_lock = requirements_lock, requirements_windows = requirements_windows, timeout = timeout, + uv_lock = uv_lock, whl_modifications = whl_modifications, parallel_download = False, experimental_index_url_overrides = {}, diff --git a/tests/pypi/parse_requirements/parse_requirements_tests.bzl b/tests/pypi/parse_requirements/parse_requirements_tests.bzl index 2ef2f44764..8dd35c2260 100644 --- a/tests/pypi/parse_requirements/parse_requirements_tests.bzl +++ b/tests/pypi/parse_requirements/parse_requirements_tests.bzl @@ -97,6 +97,21 @@ foo==0.0.3 --hash=sha256:deadbaaf foo[extra]==0.0.2 --hash=sha256:deadbeef bar==0.0.1 --hash=sha256:deadb00f """, + "uv_lock_empty": """{"package":[]}""", + "uv_lock_foo": """{"package":[{"dependencies":[{"extra":"extra","name":"bar"}],"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_foo_bar": """{"package":[{"name":"bar","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"sdist":{"hash":"sha256:deadb00f","url":"https://files.pythonhosted.org/packages/bar-0.0.1.tar.gz"}},{"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_foo_dep_extra": """{"package":[{"name":"bar","version":"0.0.2","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/bar-0.0.2-py3-none-any.whl"}]},{"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"bar","extra":["extra1"]}],"wheels":[{"hash":"sha256:baadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_foo_multi_versions": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.2","wheels":[{"hash":"sha256:deadb11f","url":"https://files.pythonhosted.org/packages/foo-0.0.2-py3-none-any.whl"}]}]}""", + "uv_lock_foo_multi_wheel_dedup": """{"package":[{"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:aaa","url":"https://files.pythonhosted.org/packages/foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl"},{"hash":"sha256:bbb","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_foo_only": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.2"}]}""", + "uv_lock_foo_optional_deps": """{"package":[{"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"optional-dependencies":{"extra1":[],"extra2":[]},"wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_foo_requires_dist_extras": """{"package":[{"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"root-pkg","source":{"virtual":"."},"version":"0.0.0","dependencies":[{"name":"foo"}],"metadata":{"requires-dist":[{"name":"foo","extras":["all"]}]}}]}""", + "uv_lock_foo_resolution_markers_dedup": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","resolution-markers":["sys_platform == 'linux'"],"wheels":[{"hash":"sha256:aaa","url":"https://files.pythonhosted.org/packages/foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl"},{"hash":"sha256:bbb","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.2","resolution-markers":["sys_platform == 'darwin'"],"wheels":[{"hash":"sha256:ccc","url":"https://files.pythonhosted.org/packages/foo-0.0.2-cp39-cp39-macosx_11_0_arm64.whl"},{"hash":"sha256:ddd","url":"https://files.pythonhosted.org/packages/foo-0.0.2-py3-none-any.whl"}]}]}""", + "uv_lock_foo_sdist": """{"package":[{"name":"foo","sdist":{"hash":"sha256:feedcafe","url":"https://files.pythonhosted.org/packages/foo-0.0.1.tar.gz"},"source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_foo_virtual": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"virtual-pkg","source":{"virtual":true},"version":"0.0.0"}]}""", + "uv_lock_foo_with_extras": """{"package":[{"name":"foo","provides-extras":["extra"],"source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_git_vcs": """{"package":[{"name":"foo","source":{"git":"https://github.com/org/foo.git"},"version":"0.1.0"}]}""", + "uv_lock_rules_python_pkg": """{"package":[{"name":"rules_python","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/rules_python-0.0.1-py3-none-any.whl"}]}]}""", } return mocks.mctx( @@ -107,7 +122,41 @@ bar==0.0.1 --hash=sha256:deadb00f _tests = [] +def _make_platforms(platform_names): + """Create minimal platform structs for testing, matching py3-none-any wheels.""" + platforms = {} + for name in platform_names: + platforms[name] = struct( + env = pep508_env(python_version = "3.11.0", os = "linux", arch = "x86_64"), + whl_abi_tags = ["none"], + whl_platform_tags = ["any"], + ) + return platforms + def parse_requirements(debug = False, **kwargs): + """Get requirements by calling the original parse_requirements. + + Args: + debug: If True, set verbosity to TRACE. + **kwargs: forwarded to the underlying function. + + Returns: + The result of the underlying parse_requirements call. + """ + kwargs.setdefault("toml_decode", json.decode) + + # Provide default platforms when not specified. + if "platforms" not in kwargs: + if "requirements_by_platform" in kwargs: + platform_names = {} + for _plats in kwargs["requirements_by_platform"].values(): + for _p in _plats: + platform_names[_p] = None + platform_names = sorted(platform_names) + kwargs["platforms"] = _make_platforms(platform_names) + elif "uv_lock" in kwargs: + kwargs["platforms"] = _make_platforms(["linux_x86_64"]) + return _parse_requirements( ctx = _mock_ctx(), logger = repo_utils.logger(struct( @@ -992,6 +1041,593 @@ def _test_get_index_urls_all_versions(env): _tests.append(_test_get_index_urls_all_versions) +def _test_uv_lock_consistent(env): + """Test that uv_lock with requirements_by_platform uses correct platforms.""" + got = parse_requirements( + requirements_by_platform = { + "requirements_lock": ["linux_x86_64", "windows_x86_64"], + }, + uv_lock = "uv_lock_foo_with_extras", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo[extra]==0.0.1", + target_platforms = ["linux_x86_64", "windows_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_consistent) + +def _test_uv_lock_primary_source(env): + """Test that uv.lock can be used as the sole source without requirements files.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_sdist", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_primary_source) + +def _test_uv_lock_primary_source_multiple_versions(env): + """Test that uv.lock with multiple versions of the same package works.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_multi_versions", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = True, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.2", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.2-py3-none-any.whl", + sha256 = "deadb11f", + url = "https://files.pythonhosted.org/packages/foo-0.0.2-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_primary_source_multiple_versions) + +def _test_uv_lock_primary_source_with_extras(env): + """Test that uv.lock extras are included in requirement lines.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_with_extras", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo[extra]==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_primary_source_with_extras) + +def _test_uv_lock_primary_source_includes_virtual(env): + """Test that virtual packages in uv.lock are included.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_virtual", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + struct( + name = "virtual_pkg", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [], + ), + ]) + +_tests.append(_test_uv_lock_primary_source_includes_virtual) + +def _test_uv_lock_cross_consistent(env): + """Test that the uv.lock and requirements work together for cross-platform.""" + got = parse_requirements( + requirements_by_platform = { + "requirements_lock": ["linux_x86_64", "windows_x86_64"], + }, + uv_lock = "uv_lock_foo_with_extras", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo[extra]==0.0.1", + target_platforms = ["linux_x86_64", "windows_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_cross_consistent) + +def _test_uv_lock_vcs_entry(env): + """Test that VCS entries in uv.lock are handled without crashing.""" + got = parse_requirements( + uv_lock = "uv_lock_git_vcs", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.1.0", + target_platforms = ["linux_x86_64"], + filename = "foo.git", + sha256 = "", + url = "https://github.com/org/foo.git", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_vcs_entry) + +def _test_uv_lock_rules_python_pkg_not_skipped(env): + """Test that 'rules_python' package is not skipped from uv.lock.""" + got = parse_requirements( + uv_lock = "uv_lock_rules_python_pkg", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "rules_python", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "rules_python", + extra_pip_args = [], + requirement_line = "rules_python==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "rules_python-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/rules_python-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_rules_python_pkg_not_skipped) + +def _test_uv_lock_no_consistency_check(env): + """Test that uv.lock is used as the primary source when both uv.lock and requirements exist.""" + got = parse_requirements( + requirements_by_platform = { + "requirements_lock": ["linux_x86_64"], + }, + uv_lock = "uv_lock_foo", + ) + + # The result comes from uv.lock (no extras since uv_lock_foo doesn't have provides-extras) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_no_consistency_check) + +def _test_uv_lock_multiple_packages(env): + """Test that multiple packages from uv.lock are all returned.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_bar", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "bar", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "bar", + extra_pip_args = [], + requirement_line = "bar==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "bar-0.0.1.tar.gz", + sha256 = "deadb00f", + url = "https://files.pythonhosted.org/packages/bar-0.0.1.tar.gz", + yanked = None, + ), + ], + ), + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_multiple_packages) + +def _test_uv_lock_with_extra_pip_args(env): + """Test that extra_pip_args are passed through with uv.lock.""" + got = parse_requirements( + uv_lock = "uv_lock_foo", + extra_pip_args = ["--index-url=example.org"], + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = ["--index-url=example.org"], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_with_extra_pip_args) + +def _test_uv_lock_multi_os_with_requirements(env): + """Test that uv.lock works with requirements_by_platform for multi-platform.""" + got = parse_requirements( + requirements_by_platform = { + "requirements_foo": ["linux_aarch64"], + "requirements_lock": ["linux_x86_64", "windows_x86_64"], + }, + uv_lock = "uv_lock_foo", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_aarch64", "linux_x86_64", "windows_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_multi_os_with_requirements) + +def _test_uv_lock_extras_optional_deps(env): + """Test that extras from optional-dependencies in uv.lock are included.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_optional_deps", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo[extra1,extra2]==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_extras_optional_deps) + +def _test_uv_lock_extras_dep_edge(env): + """Test that dep extra edges in uv.lock add extras to the dependency.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_dep_extra", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "bar", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "bar", + extra_pip_args = [], + requirement_line = "bar[extra1]==0.0.2", + target_platforms = ["linux_x86_64"], + filename = "bar-0.0.2-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/bar-0.0.2-py3-none-any.whl", + yanked = None, + ), + ], + ), + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "baadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_extras_dep_edge) + +def _test_uv_lock_wheel_dedup_single_version(env): + """Test that overlapping wheels for a single version are deduplicated to one per platform.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_multi_wheel_dedup", + platforms = { + "cp39_linux_x86_64": struct( + env = pep508_env(python_version = "3.9.0", os = "linux", arch = "x86_64"), + whl_abi_tags = ["none", "abi3", "cp39"], + whl_platform_tags = ["any", "linux_x86_64", "manylinux_*_x86_64"], + ), + }, + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["cp39_linux_x86_64"], + filename = "foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", + sha256 = "aaa", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_wheel_dedup_single_version) + +def _test_uv_lock_wheel_dedup_resolution_markers(env): + """Test that resolution-markers filtering and wheel dedup work together. + + Two versions of foo with resolution-markers for different platforms. + Each version has a platform-specific wheel and a generic py3-none-any wheel. + The dedup should pick the platform-specific wheel for each platform and + the resolution-markers should split versions across platforms. + """ + got = parse_requirements( + uv_lock = "uv_lock_foo_resolution_markers_dedup", + platforms = { + "cp39_linux_x86_64": struct( + env = pep508_env(python_version = "3.9.0", os = "linux", arch = "x86_64"), + whl_abi_tags = ["none", "abi3", "cp39"], + whl_platform_tags = ["any", "linux_x86_64", "manylinux_*_x86_64"], + ), + "cp39_osx_aarch64": struct( + env = pep508_env(python_version = "3.9.0", os = "osx", arch = "aarch64"), + whl_abi_tags = ["none", "abi3", "cp39"], + whl_platform_tags = ["any", "macosx_*_arm64"], + ), + }, + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = True, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["cp39_linux_x86_64"], + filename = "foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", + sha256 = "aaa", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", + yanked = None, + ), + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.2", + target_platforms = ["cp39_osx_aarch64"], + filename = "foo-0.0.2-cp39-cp39-macosx_11_0_arm64.whl", + sha256 = "ccc", + url = "https://files.pythonhosted.org/packages/foo-0.0.2-cp39-cp39-macosx_11_0_arm64.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_wheel_dedup_resolution_markers) + +def _test_uv_lock_requires_dist_extras(env): + """Test that extras from metadata.requires-dist appear in requirement_line.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_requires_dist_extras", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo[all]==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + sha256 = "deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + struct( + name = "root_pkg", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [], + ), + ]) + +_tests.append(_test_uv_lock_requires_dist_extras) + def parse_requirements_test_suite(name): """Create the test suite. diff --git a/tests/uv/lock/BUILD.bazel b/tests/uv/lock/BUILD.bazel index 0b72f015b7..60d680bde8 100644 --- a/tests/uv/lock/BUILD.bazel +++ b/tests/uv/lock/BUILD.bazel @@ -1,4 +1,5 @@ load(":lock_tests.bzl", "lock_test_suite") +load(":uv_lock_to_requirements_tests.bzl", "uv_lock_to_requirements_test_suite") exports_files( glob(["testdata/*"]), @@ -8,3 +9,7 @@ exports_files( lock_test_suite( name = "lock_tests", ) + +uv_lock_to_requirements_test_suite( + name = "uv_lock_to_requirements_tests", +) diff --git a/tests/uv/lock/lock_run_test.py b/tests/uv/lock/lock_run_test.py index e2508161d5..6de5a96378 100644 --- a/tests/uv/lock/lock_run_test.py +++ b/tests/uv/lock/lock_run_test.py @@ -63,6 +63,86 @@ def _subprocess_env(self, workspace_dir: Path) -> dict[str, str]: env[key] = os.environ[key] return env + def test_requirements_run_script_for_new_file(self): + """Verify the requirements_new_file.run script has expected args.""" + run_script_path = _relative_rpath("requirements_new_file.run") + content = run_script_path.read_text() + + if os.name == "nt": + self.assertIn("@echo off", content) + else: + self.assertIn("#!/usr/bin/env bash", content) + self.assertIn("BUILD_WORKSPACE_DIRECTORY", content) + self.assertIn("--no-progress", content) + self.assertIn("--quiet", content) + self.assertIn("does_not_exist.txt", content) + + def test_uv_lock_run_script(self): + """Verify the uv_lock_test.run script has expected args.""" + run_script_path = _relative_rpath("uv_lock_test.run") + content = run_script_path.read_text() + + if os.name == "nt": + self.assertIn("@echo off", content) + else: + self.assertIn("#!/usr/bin/env bash", content) + self.assertIn("--no-progress", content) + self.assertIn("--quiet", content) + + def test_run_script_has_no_output_file_arg(self): + """Verify the uv lock .run script does NOT have --output-file (uv lock doesn't use it).""" + run_script_path = _relative_rpath("uv_lock_test.run") + content = run_script_path.read_text() + + self.assertNotIn("--output-file", content) + + def test_debug_requirements_diff(self): + """Temporary test to print diff of generated vs expected requirements on Windows.""" + expected_path = _relative_rpath("testdata/requirements.txt") + try: + # Pass "requirements.out" directly. We need to handle the case where + # _relative_rpath might append extensions on Windows. + # Actually, _relative_rpath has: + # exts = (".exe", ".bat", "") if os.name == "nt" else ... + # So on Windows it will try .exe, .bat, then empty. + # If "requirements.out" exists, the empty extension will match it. + generated_path = _relative_rpath("requirements.out") + except ValueError as e: + print(f"Could not find requirements.out: {e}") + # Dump runfiles directory to help debug if needed + runfiles_dir = os.environ.get("RUNFILES_DIR") + if runfiles_dir: + print(f"RUNFILES_DIR: {runfiles_dir}") + for root, _, files in os.walk(runfiles_dir): + for f in files: + if "requirements" in f: + print(os.path.join(root, f)) + raise + + expected = expected_path.read_text() + generated = generated_path.read_text() + + if expected != generated: + import difflib + + diff = list( + difflib.unified_diff( + expected.splitlines(keepends=True), + generated.splitlines(keepends=True), + fromfile="expected (testdata/requirements.txt)", + tofile="generated (requirements.out)", + ) + ) + print("\n=== DIFF START ===") + print("".join(diff)) + print("=== DIFF END ===\n") + + # Also print representation to see line endings + print(f"Expected line endings: {repr(expected[:100])}") + print(f"Generated line endings: {repr(generated[:100])}") + + self.assertEqual(expected, generated, "Files differ! See diff above.") + def test_requirements_updating_for_the_first_time(self): # Given copier_path = _relative_rpath("requirements_new_file.update") @@ -94,8 +174,6 @@ def test_requirements_updating_for_the_first_time(self): def test_requirements_updating(self): # Given copier_path = _relative_rpath("requirements.update") - existing_file = _relative_rpath("testdata/requirements.txt") - want_text = existing_file.read_text() # When with tempfile.TemporaryDirectory() as dir: @@ -109,9 +187,6 @@ def test_requirements_updating(self): / "requirements.txt" ) want_path.parent.mkdir(parents=True) - want_path.write_text( - want_text + "\n\n" - ) # Write something else to see that it is restored output = _run_binary( copier_path, @@ -126,7 +201,8 @@ def test_requirements_updating(self): "cp /tests/uv/lock/requirements", stdout, ) - self.assertEqual(want_path.read_text(), want_text) + self.assertTrue(want_path.exists(), "The path should exist after the test") + self.assertNotEqual(want_path.read_text(), "") def test_requirements_run_on_the_first_time(self): # Given @@ -141,7 +217,7 @@ def test_requirements_run_on_the_first_time(self): want_path.parent.mkdir(parents=True) self.assertFalse( - want_path.exists(), "The path should not exist after the test" + want_path.exists(), "The path should not exist before the test" ) output = _run_binary( copier_path, @@ -154,16 +230,33 @@ def test_requirements_run_on_the_first_time(self): self.assertTrue(want_path.exists(), "The path should exist after the test") got_contents = want_path.read_text() self.assertNotEqual(got_contents, "") - self.assertIn( - got_contents, - output.stdout.decode("utf-8"), - ) + # NOTE: stdout is typically empty because uv runs with --quiet --no-progress + + def test_requirements_run_script_has_expected_args(self): + """Verify the .run script template has expected args embedded.""" + run_script_path = _relative_rpath("requirements.run") + content = run_script_path.read_text() + + if os.name == "nt": + self.assertIn("@echo off", content) + self.assertIn("%*", content) + else: + self.assertIn("#!/usr/bin/env bash", content) + self.assertIn('"$@"', content) + self.assertIn("BUILD_WORKSPACE_DIRECTORY", content) + self.assertIn("--custom-compile-command", content) + self.assertIn("--generate-hashes", content) + self.assertIn("--no-strip-extras", content) + self.assertIn("--no-python-downloads", content) + self.assertIn("--no-cache", content) + self.assertIn("--no-progress", content) + self.assertIn("--quiet", content) + self.assertIn("--output-file", content) + self.assertIn("requirements.txt", content) def test_requirements_run(self): # Given copier_path = _relative_rpath("requirements.run") - existing_file = _relative_rpath("testdata/requirements.txt") - want_text = existing_file.read_text() # When with tempfile.TemporaryDirectory() as dir: @@ -176,11 +269,7 @@ def test_requirements_run(self): / "testdata" / "requirements.txt" ) - want_path.parent.mkdir(parents=True) - want_path.write_text( - want_text + "\n\n" - ) # Write something else to see that it is restored output = _run_binary( copier_path, @@ -193,10 +282,7 @@ def test_requirements_run(self): self.assertTrue(want_path.exists(), "The path should exist after the test") got_contents = want_path.read_text() self.assertNotEqual(got_contents, "") - self.assertIn( - got_contents, - output.stdout.decode("utf-8"), - ) + # NOTE: stdout is typically empty because uv runs with --quiet --no-progress if __name__ == "__main__": diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl index 3e067f3e73..e3e035717f 100644 --- a/tests/uv/lock/lock_tests.bzl +++ b/tests/uv/lock/lock_tests.bzl @@ -15,6 +15,7 @@ "" load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@bazel_skylib//rules:native_binary.bzl", "native_test") load("//python/uv:lock.bzl", "lock") load("//tests/support:py_reconfig.bzl", "py_reconfig_test") @@ -65,6 +66,8 @@ def lock_test_suite(name): "requirements.update", "requirements.run", "testdata/requirements.txt", + "uv_lock_test.run", + ":requirements", ], main = "lock_run_test.py", tags = [ @@ -87,11 +90,28 @@ def lock_test_suite(name): file2 = "testdata/requirements.txt", ) + lock( + name = "uv_lock_test", + srcs = ["testdata/pyproject.toml"], + out = "testdata/uv_lock_expected.lock", + tags = ["no-remote-exec"], + ) + + native_test( + name = "uv_lock_test_check", + src = ":uv_lock_test.update", + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + ) + native.test_suite( name = name, tests = [ ":requirements_test", "//tests/uv/lock/pyproject_toml:requirements_test", ":requirements_run_tests", + ":uv_lock_test_check", ], ) diff --git a/tests/uv/lock/pyproject_toml/requirements.txt b/tests/uv/lock/pyproject_toml/requirements.txt index 4ea098b0e4..2f4330e1ae 100644 --- a/tests/uv/lock/pyproject_toml/requirements.txt +++ b/tests/uv/lock/pyproject_toml/requirements.txt @@ -3,16 +3,24 @@ certifi==2025.1.31 \ --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \ --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe - # via requests + # via + # -c tests/uv/lock/testdata/constraints.txt + # requests idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 - # via requests + # via + # -c tests/uv/lock/testdata/constraints.txt + # requests requests==2.32.3 \ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 - # via test (tests/uv/lock/pyproject_toml/pyproject.toml) + # via + # -c tests/uv/lock/testdata/constraints.txt + # test (tests/uv/lock/pyproject_toml/pyproject.toml) urllib3==2.3.0 \ --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d - # via requests + # via + # -c tests/uv/lock/testdata/constraints.txt + # requests diff --git a/tests/uv/lock/testdata/constraints.txt b/tests/uv/lock/testdata/constraints.txt index 18ade2c5b9..1d4f29173d 100644 --- a/tests/uv/lock/testdata/constraints.txt +++ b/tests/uv/lock/testdata/constraints.txt @@ -1 +1,5 @@ charset-normalizer==3.4.0 +certifi==2025.1.31 +requests==2.32.3 +idna==3.10 +urllib3==2.3.0 diff --git a/tests/uv/lock/testdata/pyproject.toml b/tests/uv/lock/testdata/pyproject.toml new file mode 100644 index 0000000000..d72efcf7c0 --- /dev/null +++ b/tests/uv/lock/testdata/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "test-project" +version = "0.0.1" +dependencies = ["requests"] +requires-python = ">=3.9" diff --git a/tests/uv/lock/testdata/requirements.txt b/tests/uv/lock/testdata/requirements.txt index d02844636d..8b718fb145 100644 --- a/tests/uv/lock/testdata/requirements.txt +++ b/tests/uv/lock/testdata/requirements.txt @@ -3,7 +3,9 @@ certifi==2025.1.31 \ --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \ --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe - # via requests + # via + # -c tests/uv/lock/testdata/constraints.txt + # requests charset-normalizer==3.4.0 \ --hash=sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621 \ --hash=sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6 \ @@ -117,12 +119,18 @@ charset-normalizer==3.4.0 \ idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 - # via requests + # via + # -c tests/uv/lock/testdata/constraints.txt + # requests requests==2.32.3 \ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 - # via -r tests/uv/lock/testdata/requirements.in + # via + # -c tests/uv/lock/testdata/constraints.txt + # -r tests/uv/lock/testdata/requirements.in urllib3==2.3.0 \ --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d - # via requests + # via + # -c tests/uv/lock/testdata/constraints.txt + # requests diff --git a/tests/uv/lock/testdata/uv_lock_expected.lock b/tests/uv/lock/testdata/uv_lock_expected.lock new file mode 100644 index 0000000000..fc027674ef --- /dev/null +++ b/tests/uv/lock/testdata/uv_lock_expected.lock @@ -0,0 +1,218 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "test-project" +version = "0.0.1" +source = { virtual = "." } +dependencies = [ + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [{ name = "requests" }] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/tests/uv/lock/uv_lock_to_requirements_tests.bzl b/tests/uv/lock/uv_lock_to_requirements_tests.bzl new file mode 100644 index 0000000000..1b947877c9 --- /dev/null +++ b/tests/uv/lock/uv_lock_to_requirements_tests.bzl @@ -0,0 +1,289 @@ +"""Tests for uv_lock_to_requirements.""" + +load("@rules_testing//lib:test_suite.bzl", "test_suite") +load("//python/uv/private:uv_lock_to_requirements.bzl", "uv_lock_to_requirements") # buildifier: disable=bzl-visibility + +_tests = [] + +def _test_empty(env): + got = uv_lock_to_requirements(json.decode("""{"package":[]}""")) + env.expect.that_str(got).equals("") + +_tests.append(_test_empty) + +def _test_simple_package(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_simple_package) + +def _test_package_with_markers(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"resolution-markers":["python_full_version < '3.10'"],"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 ; python_full_version < '3.10' \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_package_with_markers) + +def _test_package_with_multiple_markers(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"resolution-markers":["python_full_version == '3.10.*'","python_full_version >= '3.14'"],"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 ; python_full_version == '3.10.*' or python_full_version >= '3.14' \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_package_with_multiple_markers) + +def _test_package_with_deps(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"bar","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:baadbeef","url":"https://example.org/bar.whl"}]}, + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"bar"}],"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +bar==0.0.1 \\ + --hash=sha256:baadbeef + # via foo + +foo==0.0.1 \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_package_with_deps) + +def _test_package_with_optional_deps(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"bar","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:baadbeef","url":"https://example.org/bar.whl"}]}, + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"optional-dependencies":{"extra1":[{"name":"bar"}]},"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +bar==0.0.1 \\ + --hash=sha256:baadbeef + # via foo + +foo[extra1]==0.0.1 \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_package_with_optional_deps) + +def _test_self_edge_excluded(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"pydantic","version":"2.0.0","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"pydantic","extra":["email"]}],"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/pydantic.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +pydantic==2.0.0 \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_self_edge_excluded) + +def _test_multiple_dependents(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"common","version":"1.0.0","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:aaaa","url":"https://example.org/common.whl"}]}, + {"name":"pkg_a","version":"0.1.0","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"common"}],"wheels":[{"hash":"sha256:bbbb","url":"https://example.org/a.whl"}]}, + {"name":"pkg_b","version":"0.2.0","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"common"}],"wheels":[{"hash":"sha256:cccc","url":"https://example.org/b.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +common==1.0.0 \\ + --hash=sha256:aaaa + # via + # pkg_a + # pkg_b + +pkg_a==0.1.0 \\ + --hash=sha256:bbbb + +pkg_b==0.2.0 \\ + --hash=sha256:cccc +""") + +_tests.append(_test_multiple_dependents) + +def _test_git_source_skipped(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.1.0","source":{"git":"https://github.com/org/foo.git"}}, + {"name":"bar","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/bar.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +bar==0.0.1 \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_git_source_skipped) + +def _test_virtual_source_skipped(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"virtual-pkg","version":"0.0.0","source":{"virtual":true}}, + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 \\ + --hash=sha256:deadbeef +""") + +_tests.append(_test_virtual_source_skipped) + +def _test_sdist_hash(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"bar","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"sdist":{"hash":"sha256:deadb00f","url":"https://example.org/bar.tar.gz"}} + ]}""")) + env.expect.that_str(got).equals("""\ +bar==0.0.1 \\ + --hash=sha256:deadb00f +""") + +_tests.append(_test_sdist_hash) + +def _test_wheel_and_sdist_hashes(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"sdist":{"hash":"sha256:feedcafe","url":"https://example.org/foo.tar.gz"},"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 \\ + --hash=sha256:deadbeef \\ + --hash=sha256:feedcafe +""") + +_tests.append(_test_wheel_and_sdist_hashes) + +def _test_multiple_versions_same_package(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"resolution-markers":["python_full_version < '3.10'"],"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo-0.0.1.whl"}]}, + {"name":"foo","version":"0.0.2","source":{"registry":"https://pypi.org/simple"},"resolution-markers":["python_full_version >= '3.10'"],"wheels":[{"hash":"sha256:deadb11f","url":"https://example.org/foo-0.0.2.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 ; python_full_version < '3.10' \\ + --hash=sha256:deadbeef + +foo==0.0.2 ; python_full_version >= '3.10' \\ + --hash=sha256:deadb11f +""") + +_tests.append(_test_multiple_versions_same_package) + +def _test_dep_with_extras(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]}, + {"name":"bar","version":"0.0.2","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"foo","extra":["extra1"]}],"wheels":[{"hash":"sha256:baadbeef","url":"https://example.org/bar.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo[extra1]==0.0.1 \\ + --hash=sha256:deadbeef + # via bar + +bar==0.0.2 \\ + --hash=sha256:baadbeef +""") + +_tests.append(_test_dep_with_extras) + +def _test_multiple_hashes_from_wheels(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[ + {"hash":"sha256:aaaa","url":"https://example.org/foo-0.0.1-cp39.whl"}, + {"hash":"sha256:bbbb","url":"https://example.org/foo-0.0.1-py3-none-any.whl"} + ]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 \\ + --hash=sha256:aaaa \\ + --hash=sha256:bbbb +""") + +_tests.append(_test_multiple_hashes_from_wheels) + +def _test_package_no_hashes_no_deps(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"}} + ]}""")) + env.expect.that_str(got).equals("""\ +foo==0.0.1 +""") + +_tests.append(_test_package_no_hashes_no_deps) + +def _test_package_with_multiple_optional_deps(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"bar","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:baadbeef","url":"https://example.org/bar.whl"}]}, + {"name":"baz","version":"0.0.2","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadc0de","url":"https://example.org/baz.whl"}]}, + {"name":"foo","version":"0.0.3","source":{"registry":"https://pypi.org/simple"},"optional-dependencies":{"extra1":[{"name":"bar"}],"extra2":[{"name":"baz"}]},"wheels":[{"hash":"sha256:feedcafe","url":"https://example.org/foo.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +bar==0.0.1 \\ + --hash=sha256:baadbeef + # via foo + +baz==0.0.2 \\ + --hash=sha256:deadc0de + # via foo + +foo[extra1,extra2]==0.0.3 \\ + --hash=sha256:feedcafe +""") + +_tests.append(_test_package_with_multiple_optional_deps) + +def _test_dep_with_multiple_extras(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://example.org/foo.whl"}]}, + {"name":"bar","version":"0.0.2","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"foo","extra":["extra1","extra2"]}],"wheels":[{"hash":"sha256:baadbeef","url":"https://example.org/bar.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +foo[extra1,extra2]==0.0.1 \\ + --hash=sha256:deadbeef + # via bar + +bar==0.0.2 \\ + --hash=sha256:baadbeef +""") + +_tests.append(_test_dep_with_multiple_extras) + +def _test_extras_from_multiple_dependents(env): + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"common","version":"1.0.0","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:aaaa","url":"https://example.org/common.whl"}]}, + {"name":"pkg_a","version":"0.1.0","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"common","extra":["extra1"]}],"wheels":[{"hash":"sha256:bbbb","url":"https://example.org/a.whl"}]}, + {"name":"pkg_b","version":"0.2.0","source":{"registry":"https://pypi.org/simple"},"dependencies":[{"name":"common","extra":["extra2"]}],"wheels":[{"hash":"sha256:cccc","url":"https://example.org/b.whl"}]} + ]}""")) + env.expect.that_str(got).equals("""\ +common[extra1,extra2]==1.0.0 \\ + --hash=sha256:aaaa + # via + # pkg_a + # pkg_b + +pkg_a==0.1.0 \\ + --hash=sha256:bbbb + +pkg_b==0.2.0 \\ + --hash=sha256:cccc +""") + +_tests.append(_test_extras_from_multiple_dependents) + +def _test_requires_dist_extras(env): + """Test that extras from metadata.requires-dist are included.""" + got = uv_lock_to_requirements(json.decode("""{"package":[ + {"name":"pytest-bazel","version":"0.1.6","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21","url":"https://example.org/pytest_bazel-0.1.6.whl"}]}, + {"name":"root-pkg","version":"0.0.0","source":{"virtual":"."},"dependencies":[{"name":"pytest-bazel"}],"metadata":{"requires-dist":[{"name":"pytest-bazel","extras":["all"]}]}} + ]}""")) + env.expect.that_str(got).equals("""\ +pytest-bazel[all]==0.1.6 \\ + --hash=sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21 + # via root-pkg +""") + +_tests.append(_test_requires_dist_extras) + +def uv_lock_to_requirements_test_suite(name): + test_suite(name = name, basic_tests = _tests) From ee53e46d38927fbccfc3436bb8cf19ad2ec033f3 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 26 Jun 2026 00:49:09 -0700 Subject: [PATCH 030/179] chore: add agent rule to base new branches on upstream/main (#3849) Add a workspace-specific agent rule to ensure that new conversations/sessions and branches are always based on the latest upstream code to avoid using outdated code states. This comes about after having several sessions go haywire at the start because they didn't see the latest changes. --- .agents/rules/workspace.md | 18 +++++++++++++++++ .agents/scripts/setup_triangle_branch.sh | 25 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 .agents/rules/workspace.md create mode 100755 .agents/scripts/setup_triangle_branch.sh diff --git a/.agents/rules/workspace.md b/.agents/rules/workspace.md new file mode 100644 index 0000000000..b819925f83 --- /dev/null +++ b/.agents/rules/workspace.md @@ -0,0 +1,18 @@ +--- +trigger: always_on +--- + +# Workspace Rules + +To avoid confusion from using outdated code states, when starting a new +conversation/session or when first starting a new branch or worktree, unless +explicitly instructed otherwise, ensure the latest upstream code is used as the +basis: +* Fetch `upstream/main` (`git fetch upstream main`). +* Base any new branch or worktree upon `upstream/main` (e.g., + `git checkout -b upstream/main`). +* Run the workspace helper script to configure upstream tracking and safe + pushing: + ```bash + .agents/scripts/setup_triangle_branch.sh + ``` diff --git a/.agents/scripts/setup_triangle_branch.sh b/.agents/scripts/setup_triangle_branch.sh new file mode 100755 index 0000000000..b6bdc2bb20 --- /dev/null +++ b/.agents/scripts/setup_triangle_branch.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Helper script to set up upstream tracking for fork-and-PR workflow. +set -e + +# Detect current branch if not provided as argument +BRANCH_NAME="${1:-$(git symbolic-ref --short HEAD)}" + +if [ -z "$BRANCH_NAME" ]; then + echo "Error: Could not detect current branch name." >&2 + exit 1 +fi + +echo "Setting up upstream tracking for branch: $BRANCH_NAME" + +# 1. Set the pull/fetch behavior to track the canonical repo's main branch +git config --local "branch.${BRANCH_NAME}.remote" upstream +git config --local "branch.${BRANCH_NAME}.merge" refs/heads/main + +# 2. Override the destination remote for pushing +git config --local "branch.${BRANCH_NAME}.pushRemote" origin + +# 3. Defend against global push.default settings (ensure it pushes to current branch name) +git config --local push.default current + +echo "Successfully configured upstream/main tracking and origin pushRemote for $BRANCH_NAME!" From 9ed764141b8c5b5a389800a72517af12090ac72d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 27 Jun 2026 23:56:58 -0700 Subject: [PATCH 031/179] build(ci): consolidate PR guardrails and block draft files (#3859) Create a workflow for lighter weight PR checks. These just check basic things about a PR, e.g. markers to prevent submit, blocking files that shouldn't be checked in. --- .../workflows/check_do_not_merge_label.yml | 20 ------- .github/workflows/pr-metadata-checks.yaml | 57 +++++++++++++++++++ 2 files changed, 57 insertions(+), 20 deletions(-) delete mode 100644 .github/workflows/check_do_not_merge_label.yml create mode 100644 .github/workflows/pr-metadata-checks.yaml diff --git a/.github/workflows/check_do_not_merge_label.yml b/.github/workflows/check_do_not_merge_label.yml deleted file mode 100644 index 97b91b156a..0000000000 --- a/.github/workflows/check_do_not_merge_label.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: "Check 'do not merge' label" - -on: - pull_request_target: - types: - - opened - - synchronize - - reopened - - labeled - - unlabeled - -jobs: - block-do-not-merge: - runs-on: ubuntu-latest - steps: - - name: Check for "do not merge" label - if: "contains(github.event.pull_request.labels.*.name, 'do not merge')" - run: | - echo "This PR has the 'do not merge' label and cannot be merged." - exit 1 diff --git a/.github/workflows/pr-metadata-checks.yaml b/.github/workflows/pr-metadata-checks.yaml new file mode 100644 index 0000000000..003b4d99a5 --- /dev/null +++ b/.github/workflows/pr-metadata-checks.yaml @@ -0,0 +1,57 @@ +name: "PR Metadata Checks" + +on: + pull_request_target: + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + +jobs: + blocks-do-not-merge: + runs-on: ubuntu-latest + steps: + - name: Check for "do not merge" label + if: "contains(github.event.pull_request.labels.*.name, 'do not merge')" + run: | + echo "::error::This PR has the 'do not merge' label" \ + "and cannot be merged." + exit 1 + + - name: Check PR description for DO NOT SUBMIT/MERGE + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + echo "Checking PR description..." + if echo "$PR_BODY" | grep -Ei "DO NOT SUBMIT|DO NOT MERGE"; then + echo "::error::This PR description contains" \ + "'DO NOT SUBMIT' or 'DO NOT MERGE'" \ + "and cannot be merged." + exit 1 + fi + echo "PR description is clean." + + block-transient-agent-files: + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - name: Check for blocked files + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + echo "Checking for blocked files..." + changed_files=$(gh pr diff "$PR_NUMBER" --name-only) + blocked_files=$(echo "$changed_files" | grep -E '^.agents/(plans|scratch)(/|$)' || true) + if [ -n "$blocked_files" ]; then + echo "::error::Files in .agents/plans and" \ + ".agents/scratch are permitted in PRs to" \ + "facilitate discussion, but are not allowed" \ + "to be merged. Please remove them before merging:" + echo "$blocked_files" + exit 1 + fi + echo "No blocked files found." From de6ca1baa21ff295ef63c04ee8e3c2a21993af99 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 28 Jun 2026 01:07:31 -0700 Subject: [PATCH 032/179] refactor: use gazelle for bzl_library management (#3852) To make it easier to manage the many bzl_library targets we have, switch to using gazelle to do so. Unfortunately, gazelle has strong opinions about target names: it forces `{foo}` names and doesn't allow `{foo}_bzl` names. Trying to make it do so requires quite a bit of gazelle directives. Instead, rename all the internal targets, but create aliases for public targets. Along the way... * Delete unused py_args.bzl * Add agent rule to avoid inappropriate copyright * Add a skill for creating rules --- .agents/rules/when-to-use-copyright.md | 8 + .agents/skills/rule-creator/SKILL.md | 56 + .pre-commit-config.yaml | 6 + BUILD.bazel | 47 +- MODULE.bazel | 2 + WORKSPACE | 11 +- docs/BUILD.bazel | 104 +- gazelle/BUILD.bazel | 9 + gazelle/manifest/BUILD.bazel | 16 +- gazelle/modules_mapping/BUILD.bazel | 7 + gazelle/python/BUILD.bazel | 25 +- gazelle/python/private/BUILD.bazel | 1 + gazelle/pythonconfig/BUILD.bazel | 4 +- news/gazelle-bzl-library.changed.md | 3 + python/BUILD.bazel | 465 ++++--- python/api/BUILD.bazel | 39 +- python/cc/BUILD.bazel | 28 +- python/config_settings/BUILD.bazel | 5 + python/config_settings/private/py_args.bzl | 42 - python/entry_points/BUILD.bazel | 23 +- python/extensions/BUILD.bazel | 45 +- python/features.bzl | 44 +- python/local_toolchains/BUILD.bazel | 19 +- python/pip_install/BUILD.bazel | 40 +- python/private/BUILD.bazel | 1075 +++++++++-------- python/private/api/BUILD.bazel | 17 +- python/private/api/api.bzl | 2 +- python/private/internal_config_repo.bzl | 4 +- python/private/interpreter.bzl | 2 +- python/private/py_cc_toolchain_rule.bzl | 2 +- python/private/py_exec_tools_toolchain.bzl | 2 +- python/private/py_interpreter_program.bzl | 2 +- python/private/py_wheel.bzl | 2 +- python/private/pypi/BUILD.bazel | 540 +++++---- python/private/pythons_hub.bzl | 4 +- .../{sentinel.bzl => sentinel_impl.bzl} | 0 python/private/{stamp.bzl => stamp_impl.bzl} | 0 python/private/whl_filegroup/BUILD.bazel | 12 +- python/private/zipapp/BUILD.bazel | 37 +- python/uv/BUILD.bazel | 24 +- python/uv/private/BUILD.bazel | 129 +- python/zipapp/BUILD.bazel | 13 +- sphinxdocs/docs/BUILD.bazel | 10 +- sphinxdocs/sphinxdocs/BUILD.bazel | 42 +- sphinxdocs/sphinxdocs/private/BUILD.bazel | 32 +- .../sphinxdocs/private/sphinx_stardoc.bzl | 4 +- sphinxdocs/tests/sphinx_stardoc/BUILD.bazel | 2 +- tests/config_settings/transition/BUILD.bazel | 3 - .../transition/py_args_tests.bzl | 68 -- tools/build_defs/python/private/BUILD.bazel | 5 +- tools/private/BUILD.bazel | 8 + tools/private/gazelle/BUILD.bazel | 21 + tools/private/update_deps/BUILD.bazel | 4 +- workspace_bazel9.bzl | 10 + 54 files changed, 1741 insertions(+), 1384 deletions(-) create mode 100644 .agents/rules/when-to-use-copyright.md create mode 100644 .agents/skills/rule-creator/SKILL.md create mode 100644 news/gazelle-bzl-library.changed.md delete mode 100644 python/config_settings/private/py_args.bzl rename python/private/{sentinel.bzl => sentinel_impl.bzl} (100%) rename python/private/{stamp.bzl => stamp_impl.bzl} (100%) delete mode 100644 tests/config_settings/transition/py_args_tests.bzl create mode 100644 tools/private/gazelle/BUILD.bazel create mode 100644 workspace_bazel9.bzl diff --git a/.agents/rules/when-to-use-copyright.md b/.agents/rules/when-to-use-copyright.md new file mode 100644 index 0000000000..a73cbbfd70 --- /dev/null +++ b/.agents/rules/when-to-use-copyright.md @@ -0,0 +1,8 @@ +--- +trigger: always_on +--- + +# When to Use Copyright + +Unless directed by the user otherwise, do not add Bazel copyright to new or +existing files. diff --git a/.agents/skills/rule-creator/SKILL.md b/.agents/skills/rule-creator/SKILL.md new file mode 100644 index 0000000000..9ed7978120 --- /dev/null +++ b/.agents/skills/rule-creator/SKILL.md @@ -0,0 +1,56 @@ +--- +name: rule-creator +description: Create and format agent rules with proper front matter in the workspace +--- + +Use this skill when you need to create a new rule for the agent in the +workspace. + +### Rule File Location + +All workspace-specific rules must be created as individual Markdown files under +the `.agents/rules/` directory: +``` +.agents/rules/.md +``` + +### Rule Format + +Every rule file must start with a YAML front matter block defining the trigger +condition, followed by the rule content in Markdown. + +```yaml +--- +trigger: +--- + +# + + +``` + +#### Trigger Conditions +* `always_on`: The rule is always active and must be followed for all tasks. +* Custom triggers: You can specify other trigger conditions if the rule only + applies in certain contexts. + +### Formatting Guidelines +* **Line Wrapping:** Always wrap all text in the rule file (including the + title and description) to **80 columns** to ensure readability and + compatibility. +* **Clarity:** Write clear, actionable directives. + +### Example + +To create a rule that prevents adding copyrights: + +```markdown +--- +trigger: always_on +--- + +# No Copyrights Rule + +Unless directed by the user otherwise, do not add Bazel copyright to new or +existing files. +``` diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4d65c8bb6..f4107880b2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,3 +65,9 @@ repos: entry: ./tools/private/sync_downloader_configs.py files: downloader_config\.cfg$ pass_filenames: false + - id: gazelle + name: Run Gazelle + language: system + entry: bazel run //tools/private/gazelle + files: (\.bzl|\.bazel|BUILD|WORKSPACE(\.bzlmod)?)$ + pass_filenames: false diff --git a/BUILD.bazel b/BUILD.bazel index 7da18ebaa4..a52251393d 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -14,6 +14,40 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +# Resolve rules_cc Starlark libraries to their correct targets +# gazelle:resolve starlark @rules_cc//cc/common:cc_info.bzl @rules_cc//cc/common:common +# gazelle:resolve starlark @rules_cc//cc/common:cc_common.bzl @rules_cc//cc/common:common + +# Resolve protobuf Starlark libraries +# gazelle:resolve starlark @com_google_protobuf//bazel:py_proto_library.bzl @com_google_protobuf//bazel:py_proto_library_bzl + +# Resolve bazel_tools repo rules to our internal wrapper to avoid transitive dependencies +# gazelle:resolve starlark @bazel_tools//tools/build_defs/repo:http.bzl //python/private:bazel_tools +# gazelle:resolve starlark @bazel_tools//tools/build_defs/repo:utils.bzl //python/private:bazel_tools + +# Prevent Gazelle from incorrectly stripping the .bzl suffix from the toml.bzl repo name +# gazelle:resolve starlark @toml.bzl//:toml.bzl @toml.bzl//:toml + +# Override Gazelle's incorrect default resolution for platforms host constraints Starlark library +# gazelle:resolve starlark @platforms//host:constraints.bzl @platforms//host:constraints_lib + +# Override Gazelle's incorrect default resolution for rules_cc Starlark libraries +# gazelle:resolve starlark @rules_cc//cc:cc_import.bzl @rules_cc//cc:core_rules +# gazelle:resolve starlark @rules_cc//cc:cc_library.bzl @rules_cc//cc:core_rules + +# Exclude directories that are separate packages/workspaces or only for testing/examples +# gazelle:exclude tests +# gazelle:exclude examples + +# Exclude internal development tools and dependencies that users don't need +# gazelle:exclude internal_dev_setup.bzl +# gazelle:exclude internal_dev_deps.bzl +# gazelle:exclude python/private/internal_dev_deps.bzl +# gazelle:exclude workspace_bazel9.bzl + +# Exclude legacy paths that are only kept for backwards compatibility +# gazelle:exclude python/private/common + package(default_visibility = ["//visibility:public"]) licenses(["notice"]) @@ -52,12 +86,6 @@ filegroup( ], ) -bzl_library( - name = "version_bzl", - srcs = ["version.bzl"], - visibility = ["//:__subpackages__"], -) - # Reexport of all bzl files used to allow downstream rules to generate docs # without shipping with a dependency on Skylib filegroup( @@ -73,3 +101,10 @@ filegroup( ], visibility = ["//visibility:public"], ) + +# keep +bzl_library( + name = "version", + srcs = ["version.bzl"], + visibility = ["//:__subpackages__"], +) diff --git a/MODULE.bazel b/MODULE.bazel index 4de83f31be..c4afa60640 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -96,6 +96,8 @@ bazel_dep(name = "another_module", version = "0", dev_dependency = True) # Extra gazelle plugin deps so that WORKSPACE.bzlmod can continue including it for e2e tests. # We use `WORKSPACE.bzlmod` because it is impossible to have dev-only local overrides. bazel_dep(name = "rules_go", version = "0.60.0", dev_dependency = True, repo_name = "io_bazel_rules_go") +bazel_dep(name = "gazelle", version = "0.40.0", dev_dependency = True, repo_name = "bazel_gazelle") +bazel_dep(name = "bazel_skylib_gazelle_plugin", version = "1.8.2", dev_dependency = True) internal_dev_deps = use_extension( "//python/private:internal_dev_deps.bzl", diff --git a/WORKSPACE b/WORKSPACE index 077ddb5e68..5a31274b21 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -17,13 +17,10 @@ workspace(name = "rules_python") # Everything below this line is used only for developing rules_python. Users # should not copy it to their WORKSPACE. -# Necessary so that Bazel 9 recognizes this as rules_python and doesn't try -# to load the version Bazel itself uses by default. -# buildifier: disable=duplicated-name -local_repository( - name = "rules_python", - path = ".", -) +# Workaround for Bazel 9 duplicate name issue in Gazelle. +load("//:workspace_bazel9.bzl", "bazel_9_workaround") + +bazel_9_workaround() load("//:internal_dev_deps.bzl", "rules_python_internal_deps") diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 043f3e4de7..2e13da1296 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -106,59 +106,59 @@ build_test( sphinx_stardocs( name = "bzl_api_docs", srcs = [ - "//python:defs_bzl", - "//python:features_bzl", - "//python:packaging_bzl", - "//python:pip_bzl", - "//python:proto_bzl", - "//python:py_binary_bzl", - "//python:py_cc_link_params_info_bzl", - "//python:py_exec_tools_info_bzl", - "//python:py_exec_tools_toolchain_bzl", - "//python:py_executable_info_bzl", - "//python:py_info_bzl", - "//python:py_library_bzl", - "//python:py_runtime_bzl", - "//python:py_runtime_info_bzl", - "//python:py_test_bzl", - "//python:repositories_bzl", - "//python/api:api_bzl", - "//python/api:attr_builders_bzl", - "//python/api:executables_bzl", - "//python/api:libraries_bzl", - "//python/api:rule_builders_bzl", - "//python/cc:py_cc_toolchain_bzl", - "//python/cc:py_cc_toolchain_info_bzl", - "//python/entry_points:py_console_script_binary_bzl", - "//python/extensions:config_bzl", - "//python/extensions:python_bzl", - "//python/local_toolchains:repos_bzl", - "//python/private:attr_builders_bzl", - "//python/private:builders_bzl", - "//python/private:builders_util_bzl", - "//python/private:py_binary_rule_bzl", - "//python/private:py_cc_toolchain_rule_bzl", - "//python/private:py_info_bzl", - "//python/private:py_library_rule_bzl", - "//python/private:py_runtime_rule_bzl", - "//python/private:py_test_rule_bzl", - "//python/private:rule_builders_bzl", - "//python/private/api:py_common_api_bzl", - "//python/private/pypi:config_settings_bzl", - "//python/private/pypi:env_marker_info_bzl", - "//python/private/pypi:pkg_aliases_bzl", - "//python/private/pypi:whl_config_setting_bzl", - "//python/private/pypi:whl_library_bzl", - "//python/private/zipapp:py_zipapp_rule_bzl", - "//python/uv:lock_bzl", - "//python/uv:uv_bzl", - "//python/uv:uv_toolchain_bzl", - "//python/uv:uv_toolchain_info_bzl", - "//python/zipapp:py_zipapp_binary_bzl", - "//python/zipapp:py_zipapp_test_bzl", + "//python:defs", + "//python:features", + "//python:packaging", + "//python:pip", + "//python:proto", + "//python:py_binary", + "//python:py_cc_link_params_info", + "//python:py_exec_tools_info", + "//python:py_exec_tools_toolchain", + "//python:py_executable_info", + "//python:py_info", + "//python:py_library", + "//python:py_runtime", + "//python:py_runtime_info", + "//python:py_test", + "//python:repositories", + "//python/api", + "//python/api:attr_builders", + "//python/api:executables", + "//python/api:libraries", + "//python/api:rule_builders", + "//python/cc:py_cc_toolchain", + "//python/cc:py_cc_toolchain_info", + "//python/entry_points:py_console_script_binary", + "//python/extensions:config", + "//python/extensions:python", + "//python/local_toolchains:repos", + "//python/private:attr_builders", + "//python/private:builders", + "//python/private:builders_util", + "//python/private:py_binary_rule", + "//python/private:py_cc_toolchain_rule", + "//python/private:py_info", + "//python/private:py_library_rule", + "//python/private:py_runtime_rule", + "//python/private:py_test_rule", + "//python/private:rule_builders", + "//python/private/api:py_common_api", + "//python/private/pypi:config_settings", + "//python/private/pypi:env_marker_info", + "//python/private/pypi:pkg_aliases", + "//python/private/pypi:whl_config_setting", + "//python/private/pypi:whl_library", + "//python/private/zipapp:py_zipapp_rule", + "//python/uv", + "//python/uv:lock", + "//python/uv:uv_toolchain", + "//python/uv:uv_toolchain_info", + "//python/zipapp:py_zipapp_binary", + "//python/zipapp:py_zipapp_test", ] + ([ # This depends on @pythons_hub, which is only created under bzlmod, - "//python/extensions:pip_bzl", + "//python/extensions:pip", ] if BZLMOD_ENABLED else []), prefix = "api/rules_python/", tags = ["docs"], @@ -167,7 +167,7 @@ sphinx_stardocs( sphinx_stardoc( name = "py_runtime_pair", - src = "//python/private:py_runtime_pair_rule_bzl", + src = "//python/private:py_runtime_pair_rule", prefix = "api/rules_python/", tags = ["docs"], target_compatible_with = _TARGET_COMPATIBLE_WITH, diff --git a/gazelle/BUILD.bazel b/gazelle/BUILD.bazel index 0938be3dfc..30758181ed 100644 --- a/gazelle/BUILD.bazel +++ b/gazelle/BUILD.bazel @@ -1,9 +1,12 @@ load("@bazel_gazelle//:def.bzl", "gazelle") +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") # Gazelle configuration options. # See https://github.com/bazelbuild/bazel-gazelle#running-gazelle-with-bazel # gazelle:prefix github.com/bazel-contrib/rules_python/gazelle # gazelle:exclude bazel-out +# gazelle:exclude deps.bzl +# gazelle:exclude internal_dev_deps.bzl gazelle( name = "gazelle", ) @@ -36,3 +39,9 @@ filegroup( ], visibility = ["@rules_python//:__pkg__"], ) + +bzl_library( + name = "def", + srcs = ["def.bzl"], + visibility = ["//visibility:public"], +) diff --git a/gazelle/manifest/BUILD.bazel b/gazelle/manifest/BUILD.bazel index ea81d85fbe..68f4e1be30 100644 --- a/gazelle/manifest/BUILD.bazel +++ b/gazelle/manifest/BUILD.bazel @@ -1,3 +1,4 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") exports_files([ @@ -11,8 +12,8 @@ go_library( importpath = "github.com/bazel-contrib/rules_python/gazelle/manifest", visibility = ["//visibility:public"], deps = [ - "@com_github_emirpasic_gods//sets/treeset", - "@in_gopkg_yaml_v2//:yaml_v2", + "@com_github_emirpasic_gods//sets/treeset:go_default_library", + "@in_gopkg_yaml_v2//:go_default_library", ], ) @@ -32,3 +33,14 @@ filegroup( ], visibility = ["//:__pkg__"], ) + +bzl_library( + name = "defs", + srcs = ["defs.bzl"], + visibility = ["//visibility:public"], + deps = [ + "@bazel_skylib//rules:diff_test", + "@io_bazel_rules_go//go:def", + "@rules_python//python:defs_bzl", + ], +) diff --git a/gazelle/modules_mapping/BUILD.bazel b/gazelle/modules_mapping/BUILD.bazel index 3423f34e51..0e740f2636 100644 --- a/gazelle/modules_mapping/BUILD.bazel +++ b/gazelle/modules_mapping/BUILD.bazel @@ -1,3 +1,4 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load("@bazel_skylib//rules:copy_file.bzl", "copy_file") load("@rules_python//python:defs.bzl", "py_binary", "py_test") @@ -56,3 +57,9 @@ filegroup( srcs = glob(["**"]), visibility = ["//:__pkg__"], ) + +bzl_library( + name = "def", + srcs = ["def.bzl"], + visibility = ["//visibility:public"], +) diff --git a/gazelle/python/BUILD.bazel b/gazelle/python/BUILD.bazel index b988e493c7..8218d0b6d9 100644 --- a/gazelle/python/BUILD.bazel +++ b/gazelle/python/BUILD.bazel @@ -30,19 +30,19 @@ go_library( visibility = ["//visibility:public"], deps = [ "//pythonconfig", - "@bazel_gazelle//config:go_default_library", - "@bazel_gazelle//label:go_default_library", - "@bazel_gazelle//language:go_default_library", - "@bazel_gazelle//repo:go_default_library", - "@bazel_gazelle//resolve:go_default_library", - "@bazel_gazelle//rule:go_default_library", + "@bazel_gazelle//config", + "@bazel_gazelle//label", + "@bazel_gazelle//language", + "@bazel_gazelle//repo", + "@bazel_gazelle//resolve", + "@bazel_gazelle//rule", "@com_github_bazelbuild_buildtools//build", "@com_github_bmatcuk_doublestar_v4//:doublestar", - "@com_github_emirpasic_gods//lists/singlylinkedlist", - "@com_github_emirpasic_gods//sets/treeset", - "@com_github_emirpasic_gods//utils", - "@com_github_smacker_go_tree_sitter//:go-tree-sitter", - "@com_github_smacker_go_tree_sitter//python", + "@com_github_emirpasic_gods//lists/singlylinkedlist:go_default_library", + "@com_github_emirpasic_gods//sets/treeset:go_default_library", + "@com_github_emirpasic_gods//utils:go_default_library", + "@com_github_smacker_go_tree_sitter//:go_default_library", + "@com_github_smacker_go_tree_sitter//python:go_default_library", "@org_golang_x_sync//errgroup", ], ) @@ -65,6 +65,9 @@ copy_file( ) # gazelle:exclude testdata/ +# gazelle:exclude extensions.bzl +# Exclude test-only Starlark helper from Gazelle to avoid generating an unnecessary bzl_library +# gazelle:exclude gazelle_test.bzl gazelle_test( name = "python_test", diff --git a/gazelle/python/private/BUILD.bazel b/gazelle/python/private/BUILD.bazel index e69de29bb2..ccdc080664 100644 --- a/gazelle/python/private/BUILD.bazel +++ b/gazelle/python/private/BUILD.bazel @@ -0,0 +1 @@ +# gazelle:exclude extensions.bzl diff --git a/gazelle/pythonconfig/BUILD.bazel b/gazelle/pythonconfig/BUILD.bazel index 711bf2eb42..a82a7f19ca 100644 --- a/gazelle/pythonconfig/BUILD.bazel +++ b/gazelle/pythonconfig/BUILD.bazel @@ -10,8 +10,8 @@ go_library( visibility = ["//visibility:public"], deps = [ "//manifest", - "@bazel_gazelle//label:go_default_library", - "@com_github_emirpasic_gods//lists/singlylinkedlist", + "@bazel_gazelle//label", + "@com_github_emirpasic_gods//lists/singlylinkedlist:go_default_library", ], ) diff --git a/news/gazelle-bzl-library.changed.md b/news/gazelle-bzl-library.changed.md new file mode 100644 index 0000000000..0d602110e3 --- /dev/null +++ b/news/gazelle-bzl-library.changed.md @@ -0,0 +1,3 @@ +Renamed most public bzl_library targets from `{foo}_bzl` to `{foo}` to follow +gazelle naming conventions. Deprecated aliases are left for backwards +compatibility. diff --git a/python/BUILD.bazel b/python/BUILD.bazel index e940e9e94b..a4dd98c572 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -24,6 +24,10 @@ In an ideal renaming, we'd move the packaging rules to a different package so that @rules_python//python is only concerned with the core rules. """ +# TODO: make current_py_toolchain.bzl private +# gazelle:resolve starlark @rules_python//python:current_py_toolchain.bzl //python:current_py_toolchain_bzl +# gazelle:resolve starlark //python:current_py_toolchain.bzl //python:current_py_toolchain_bzl + load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load(":current_py_toolchain.bzl", "current_py_toolchain") @@ -54,184 +58,6 @@ filegroup( # ========= bzl_library targets end ========= -bzl_library( - name = "current_py_toolchain_bzl", - srcs = ["current_py_toolchain.bzl"], - deps = ["//python/private:toolchain_types_bzl"], -) - -bzl_library( - name = "defs_bzl", - srcs = [ - "defs.bzl", - ], - visibility = ["//visibility:public"], - deps = [ - ":current_py_toolchain_bzl", - ":py_binary_bzl", - ":py_import_bzl", - ":py_info_bzl", - ":py_library_bzl", - ":py_runtime_bzl", - ":py_runtime_info_bzl", - ":py_runtime_pair_bzl", - ":py_test_bzl", - ], -) - -bzl_library( - name = "features_bzl", - srcs = ["features.bzl"], -) - -bzl_library( - name = "packaging_bzl", - srcs = ["packaging.bzl"], - deps = [ - "//python/entry_points:py_console_script_binary_bzl", - "//python/private:bzlmod_enabled_bzl", - "//python/private:py_package_bzl", - "//python/private:py_wheel_bzl", - "//python/private:util_bzl", - "@bazel_skylib//rules:native_binary", - ], -) - -bzl_library( - name = "pip_bzl", - srcs = ["pip.bzl"], - deps = [ - "//python/private:normalize_name_bzl", - "//python/private/pypi:multi_pip_parse_bzl", - "//python/private/pypi:package_annotation_bzl", - "//python/private/pypi:pip_compile_bzl", - "//python/private/pypi:pip_repository_bzl", - "//python/private/pypi:whl_library_alias_bzl", - "//python/private/whl_filegroup:whl_filegroup_bzl", - ], -) - -bzl_library( - name = "proto_bzl", - srcs = [ - "proto.bzl", - ], - visibility = ["//visibility:public"], - deps = [ - "@com_google_protobuf//bazel:py_proto_library_bzl", - ], -) - -bzl_library( - name = "py_binary_bzl", - srcs = ["py_binary.bzl"], - deps = [ - "//python/private:py_binary_macro_bzl", - ], -) - -bzl_library( - name = "py_cc_link_params_info_bzl", - srcs = ["py_cc_link_params_info.bzl"], - deps = [ - "//python/private:py_cc_link_params_info_bzl", - ], -) - -bzl_library( - name = "py_exec_tools_info_bzl", - srcs = ["py_exec_tools_info.bzl"], - deps = ["//python/private:py_exec_tools_info_bzl"], -) - -bzl_library( - name = "py_exec_tools_toolchain_bzl", - srcs = ["py_exec_tools_toolchain.bzl"], - deps = ["//python/private:py_exec_tools_toolchain_bzl"], -) - -bzl_library( - name = "py_executable_info_bzl", - srcs = ["py_executable_info.bzl"], - deps = ["//python/private:py_executable_info_bzl"], -) - -bzl_library( - name = "py_import_bzl", - srcs = ["py_import.bzl"], - deps = [":py_info_bzl"], -) - -bzl_library( - name = "py_info_bzl", - srcs = ["py_info.bzl"], - deps = [ - "//python/private:py_info_bzl", - ], -) - -bzl_library( - name = "py_library_bzl", - srcs = ["py_library.bzl"], - deps = [ - "//python/private:py_library_macro_bzl", - ], -) - -bzl_library( - name = "py_runtime_bzl", - srcs = ["py_runtime.bzl"], - deps = [ - "//python/private:py_runtime_macro_bzl", - ], -) - -bzl_library( - name = "py_runtime_pair_bzl", - srcs = ["py_runtime_pair.bzl"], - deps = [ - "//python/private:py_runtime_pair_macro_bzl", - ], -) - -bzl_library( - name = "py_runtime_info_bzl", - srcs = ["py_runtime_info.bzl"], - deps = [ - "//python/private:py_runtime_info_bzl", - ], -) - -bzl_library( - name = "py_test_bzl", - srcs = ["py_test.bzl"], - deps = [ - "//python/private:py_test_macro_bzl", - ], -) - -bzl_library( - name = "repositories_bzl", - srcs = ["repositories.bzl"], - deps = [ - "//python/private:is_standalone_interpreter_bzl", - "//python/private:py_repositories_bzl", - "//python/private:python_register_multi_toolchains_bzl", - "//python/private:python_register_toolchains_bzl", - "//python/private:python_repository_bzl", - ], -) - -bzl_library( - name = "versions_bzl", - srcs = ["versions.bzl"], - visibility = ["//:__subpackages__"], - deps = [ - "//python/private:platform_info_bzl", - "//python/private:runtimes_manifest_workspace_bzl", - ], -) - # NOTE: Remember to add bzl_library targets to //tests:bzl_libraries # ========= bzl_library targets end ========= @@ -351,3 +177,286 @@ exports_files([ current_py_toolchain( name = "current_py_toolchain", ) + +# Keep this target because it is a public API and Gazelle might otherwise +# remove it if it is not loaded by other bzl_library targets. +# keep +bzl_library( + name = "current_py_toolchain_bzl", + srcs = ["current_py_toolchain.bzl"], + visibility = ["//visibility:public"], +) + +bzl_library( + name = "defs", + srcs = ["defs.bzl"], + deps = [ + ":py_binary", + ":py_import", + ":py_info", + ":py_library", + ":py_runtime", + ":py_runtime_info", + ":py_runtime_pair", + ":py_test", + "//python:current_py_toolchain_bzl", + ], +) + +alias( + name = "defs_bzl", + actual = ":defs", + deprecation = "Use //python:defs instead", +) + +alias( + name = "features_bzl", + actual = ":features", + deprecation = "Use //python:features instead", +) + +bzl_library( + name = "packaging", + srcs = ["packaging.bzl"], + deps = [ + ":py_binary", + "//python/private:bzlmod_enabled", + "//python/private:py_package", + "//python/private:py_wheel", + "//python/private:util", + "@bazel_skylib//rules:native_binary", + ], +) + +alias( + name = "packaging_bzl", + actual = ":packaging", + deprecation = "Use //python:packaging instead", +) + +bzl_library( + name = "pip", + srcs = ["pip.bzl"], + deps = [ + "//python/private:normalize_name", + "//python/private/pypi:multi_pip_parse", + "//python/private/pypi:package_annotation", + "//python/private/pypi:pip_compile", + "//python/private/pypi:pip_repository", + "//python/private/pypi:whl_library_alias", + "//python/private/whl_filegroup", + ], +) + +alias( + name = "pip_bzl", + actual = ":pip", + deprecation = "Use //python:pip instead", +) + +bzl_library( + name = "proto", + srcs = ["proto.bzl"], + deps = ["@com_google_protobuf//bazel:py_proto_library_bzl"], +) + +alias( + name = "proto_bzl", + actual = ":proto", + deprecation = "Use //python:proto instead", +) + +bzl_library( + name = "py_binary", + srcs = ["py_binary.bzl"], + deps = ["//python/private:py_binary_macro"], +) + +alias( + name = "py_binary_bzl", + actual = ":py_binary", + deprecation = "Use //python:py_binary instead", +) + +bzl_library( + name = "py_cc_link_params_info", + srcs = ["py_cc_link_params_info.bzl"], + deps = ["//python/private:py_cc_link_params_info"], +) + +alias( + name = "py_cc_link_params_info_bzl", + actual = ":py_cc_link_params_info", + deprecation = "Use //python:py_cc_link_params_info instead", +) + +bzl_library( + name = "py_exec_tools_info", + srcs = ["py_exec_tools_info.bzl"], + deps = ["//python/private:py_exec_tools_info"], +) + +alias( + name = "py_exec_tools_info_bzl", + actual = ":py_exec_tools_info", + deprecation = "Use //python:py_exec_tools_info instead", +) + +bzl_library( + name = "py_exec_tools_toolchain", + srcs = ["py_exec_tools_toolchain.bzl"], + deps = ["//python/private:py_exec_tools_toolchain"], +) + +alias( + name = "py_exec_tools_toolchain_bzl", + actual = ":py_exec_tools_toolchain", + deprecation = "Use //python:py_exec_tools_toolchain instead", +) + +bzl_library( + name = "py_executable_info", + srcs = ["py_executable_info.bzl"], + deps = ["//python/private:py_executable_info"], +) + +alias( + name = "py_executable_info_bzl", + actual = ":py_executable_info", + deprecation = "Use //python:py_executable_info instead", +) + +bzl_library( + name = "py_import", + srcs = ["py_import.bzl"], + deps = [":py_info"], +) + +alias( + name = "py_import_bzl", + actual = ":py_import", + deprecation = "Use //python:py_import instead", +) + +bzl_library( + name = "py_info", + srcs = ["py_info.bzl"], + deps = ["//python/private:py_info"], +) + +alias( + name = "py_info_bzl", + actual = ":py_info", + deprecation = "Use //python:py_info instead", +) + +bzl_library( + name = "py_library", + srcs = ["py_library.bzl"], + deps = ["//python/private:py_library_macro"], +) + +alias( + name = "py_library_bzl", + actual = ":py_library", + deprecation = "Use //python:py_library instead", +) + +bzl_library( + name = "py_runtime", + srcs = ["py_runtime.bzl"], + deps = ["//python/private:py_runtime_macro"], +) + +alias( + name = "py_runtime_bzl", + actual = ":py_runtime", + deprecation = "Use //python:py_runtime instead", +) + +bzl_library( + name = "py_runtime_info", + srcs = ["py_runtime_info.bzl"], + deps = ["//python/private:py_runtime_info"], +) + +alias( + name = "py_runtime_info_bzl", + actual = ":py_runtime_info", + deprecation = "Use //python:py_runtime_info instead", +) + +bzl_library( + name = "py_runtime_pair", + srcs = ["py_runtime_pair.bzl"], + deps = ["//python/private:py_runtime_pair_macro"], +) + +alias( + name = "py_runtime_pair_bzl", + actual = ":py_runtime_pair", + deprecation = "Use //python:py_runtime_pair instead", +) + +# keep +bzl_library( + name = "py_test", + srcs = ["py_test.bzl"], + deps = ["//python/private:py_test_macro"], +) + +alias( + name = "py_test_bzl", + actual = ":py_test", + deprecation = "Use //python:py_test instead", +) + +alias( + name = "python_bzl", + actual = ":python", + deprecation = "Use //python:python instead", +) + +bzl_library( + name = "repositories", + srcs = ["repositories.bzl"], + deps = [ + "//python/private:is_standalone_interpreter", + "//python/private:py_repositories", + "//python/private:python_register_multi_toolchains", + "//python/private:python_register_toolchains", + "//python/private:python_repository", + ], +) + +alias( + name = "repositories_bzl", + actual = ":repositories", + deprecation = "Use //python:repositories instead", +) + +bzl_library( + name = "versions", + srcs = ["versions.bzl"], + deps = [ + "//python/private:pbs_manifest", + "//python/private:platform_info", + "//python/private:runtimes_manifest_workspace", + ], +) + +alias( + name = "versions_bzl", + actual = ":versions", + deprecation = "Use //python:versions instead", +) + +bzl_library( + name = "features", + srcs = ["features.bzl"], +) + +bzl_library( + name = "python", + srcs = ["python.bzl"], +) diff --git a/python/api/BUILD.bazel b/python/api/BUILD.bazel index 11fee103cb..93c70f4c8b 100644 --- a/python/api/BUILD.bazel +++ b/python/api/BUILD.bazel @@ -18,46 +18,41 @@ package( default_visibility = ["//:__subpackages__"], ) +filegroup( + name = "distribution", + srcs = glob(["**"]), +) + bzl_library( - name = "api_bzl", + name = "api", srcs = ["api.bzl"], - visibility = ["//visibility:public"], - deps = ["//python/private/api:api_bzl"], + deps = ["//python/private/api"], ) bzl_library( - name = "attr_builders_bzl", + name = "attr_builders", srcs = ["attr_builders.bzl"], - deps = ["//python/private:attr_builders_bzl"], + deps = ["//python/private:attr_builders"], ) bzl_library( - name = "executables_bzl", + name = "executables", srcs = ["executables.bzl"], - visibility = ["//visibility:public"], deps = [ - "//python/private:py_binary_rule_bzl", - "//python/private:py_executable_bzl", - "//python/private:py_test_rule_bzl", + "//python/private:py_binary_rule", + "//python/private:py_executable", + "//python/private:py_test_rule", ], ) bzl_library( - name = "libraries_bzl", + name = "libraries", srcs = ["libraries.bzl"], - visibility = ["//visibility:public"], - deps = [ - "//python/private:py_library_bzl", - ], + deps = ["//python/private:py_library"], ) bzl_library( - name = "rule_builders_bzl", + name = "rule_builders", srcs = ["rule_builders.bzl"], - deps = ["//python/private:rule_builders_bzl"], -) - -filegroup( - name = "distribution", - srcs = glob(["**"]), + deps = ["//python/private:rule_builders"], ) diff --git a/python/cc/BUILD.bazel b/python/cc/BUILD.bazel index f7686c41f6..f17805d92f 100644 --- a/python/cc/BUILD.bazel +++ b/python/cc/BUILD.bazel @@ -47,21 +47,35 @@ toolchain_type( visibility = ["//visibility:public"], ) +filegroup( + name = "distribution", + srcs = glob(["**"]), +) + bzl_library( - name = "py_cc_toolchain_bzl", + name = "py_cc_toolchain", srcs = ["py_cc_toolchain.bzl"], visibility = ["//visibility:public"], - deps = ["//python/private:py_cc_toolchain_macro_bzl"], + deps = ["//python/private:py_cc_toolchain_macro"], ) bzl_library( - name = "py_cc_toolchain_info_bzl", + name = "py_cc_toolchain_info", srcs = ["py_cc_toolchain_info.bzl"], visibility = ["//visibility:public"], - deps = ["//python/private:py_cc_toolchain_info_bzl"], + deps = ["//python/private:py_cc_toolchain_info"], ) -filegroup( - name = "distribution", - srcs = glob(["**"]), +alias( + name = "py_cc_toolchain_bzl", + actual = ":py_cc_toolchain", + deprecation = "Use //python/cc:py_cc_toolchain instead", + visibility = ["//visibility:public"], +) + +alias( + name = "py_cc_toolchain_info_bzl", + actual = ":py_cc_toolchain_info", + deprecation = "Use //python/cc:py_cc_toolchain_info instead", + visibility = ["//visibility:public"], ) diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel index 369ce6de55..97761bea83 100644 --- a/python/config_settings/BUILD.bazel +++ b/python/config_settings/BUILD.bazel @@ -17,6 +17,11 @@ load( load("//python/private/pypi:flags.bzl", "define_pypi_internal_flags") load(":config_settings.bzl", "construct_config_settings") +# We don't generate bzl_library for these because they aren't public targets +# and should be moved +# gazelle:exclude config_settings.bzl +# gazelle:exclude transition.bzl + filegroup( name = "distribution", srcs = glob(["**"]) + [ diff --git a/python/config_settings/private/py_args.bzl b/python/config_settings/private/py_args.bzl deleted file mode 100644 index 09a26461b7..0000000000 --- a/python/config_settings/private/py_args.bzl +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2023 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. - -"""A helper to extract default args for the transition rule.""" - -def py_args(name, kwargs): - """A helper to extract common py_binary and py_test args - - See https://bazel.build/reference/be/python#py_binary and - https://bazel.build/reference/be/python#py_test for the list - that should be returned - - Args: - name: The name of the target. - kwargs: The kwargs to be extracted from; MODIFIED IN-PLACE. - - Returns: - A dict with the extracted arguments - """ - return dict( - args = kwargs.pop("args", None), - data = kwargs.pop("data", None), - env = kwargs.pop("env", None), - srcs = kwargs.pop("srcs", None), - deps = kwargs.pop("deps", None), - # See https://bazel.build/reference/be/python#py_binary.main - # for default logic. - # NOTE: This doesn't match the exact way a regular py_binary searches for - # it's main amongst the srcs, but is close enough for most cases. - main = kwargs.pop("main", name + ".py"), - ) diff --git a/python/entry_points/BUILD.bazel b/python/entry_points/BUILD.bazel index 46dbd9298b..7cfd0cdf25 100644 --- a/python/entry_points/BUILD.bazel +++ b/python/entry_points/BUILD.bazel @@ -21,17 +21,22 @@ exports_files( visibility = ["//docs:__subpackages__"], ) -bzl_library( - name = "py_console_script_binary_bzl", - srcs = [":py_console_script_binary.bzl"], - visibility = ["//visibility:public"], - deps = [ - "//python/private:py_console_script_binary_bzl", - ], -) - filegroup( name = "distribution", srcs = glob(["**"]), visibility = ["//python:__subpackages__"], ) + +bzl_library( + name = "py_console_script_binary", + srcs = ["py_console_script_binary.bzl"], + visibility = ["//visibility:public"], + deps = ["//python/private:py_console_script_binary"], +) + +alias( + name = "py_console_script_binary_bzl", + actual = ":py_console_script_binary", + deprecation = "Use //python/entry_points:py_console_script_binary instead", + visibility = ["//visibility:public"], +) diff --git a/python/extensions/BUILD.bazel b/python/extensions/BUILD.bazel index 50d2b3bbdf..af18fa15b7 100644 --- a/python/extensions/BUILD.bazel +++ b/python/extensions/BUILD.bazel @@ -25,28 +25,47 @@ filegroup( ) bzl_library( - name = "pip_bzl", + name = "config", + srcs = ["config.bzl"], + visibility = ["//:__subpackages__"], + deps = [ + "//python/private:internal_config_repo", + "//python/private/pypi:deps", + "@bazel_features//:features", + ], +) + +bzl_library( + name = "pip", srcs = ["pip.bzl"], visibility = ["//:__subpackages__"], - deps = ["//python/private/pypi:pip_bzl"], + deps = ["//python/private/pypi:pip"], ) bzl_library( - name = "python_bzl", + name = "python", srcs = ["python.bzl"], visibility = ["//:__subpackages__"], - deps = [ - "//python/private:python_bzl", - ], + deps = ["//python/private:python"], ) -bzl_library( +alias( name = "config_bzl", - srcs = ["config.bzl"], + actual = ":config", + deprecation = "Use //python/extensions:config instead", + visibility = ["//:__subpackages__"], +) + +alias( + name = "pip_bzl", + actual = ":pip", + deprecation = "Use //python/extensions:pip instead", + visibility = ["//:__subpackages__"], +) + +alias( + name = "python_bzl", + actual = ":python", + deprecation = "Use //python/extensions:python instead", visibility = ["//:__subpackages__"], - deps = [ - "//python/private:internal_config_repo_bzl", - "//python/private/pypi:deps_bzl", - "@bazel_features//:features", - ], ) diff --git a/python/features.bzl b/python/features.bzl index f5d2c315cd..ee850ade9e 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -49,6 +49,15 @@ def _features_typedef(): ::: :::: + ::::{field} loadable_symbols + :type: dict[str, list[str]] + + A map of bzl paths to the list of public symbols they export. + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + :::: + ::::{field} py_info_venv_symlinks True if the `PyInfo.venv_symlinks` field is available. @@ -81,23 +90,40 @@ def _features_typedef(): :::{versionadded} 1.9.0 :::: - - ::::{field} loadable_symbols - :type: dict[str, list[str]] - - A map of bzl paths to the list of public symbols they export. - - :::{versionadded} VERSION_NEXT_FEATURE - ::: - :::: """ _TARGETS = { "//command_line_option:build_runfile_links": True, "//command_line_option:enable_runfiles": True, "//command_line_option:extra_toolchains": True, + "//python/api:api": True, + "//python/api:executables": True, + "//python/api:libraries": True, "//python/cc:current_py_cc_headers_abi3": True, + "//python/cc:py_cc_toolchain": True, + "//python/cc:py_cc_toolchain_info": True, "//python/config_settings:venv": True, + "//python/entry_points:py_console_script_binary": True, + "//python/local_toolchains:repos": True, + "//python:defs": True, + "//python:features": True, + "//python:packaging": True, + "//python:pip": True, + "//python:proto": True, + "//python:py_binary": True, + "//python:py_cc_link_params_info": True, + "//python:py_exec_tools_info": True, + "//python:py_exec_tools_toolchain": True, + "//python:py_executable_info": True, + "//python:py_import": True, + "//python:py_info": True, + "//python:py_library": True, + "//python:py_runtime": True, + "//python:py_runtime_info": True, + "//python:py_runtime_pair": True, + "//python:py_test": True, + "//python:repositories": True, + "//python:versions": True, } _LOADABLE_SYMBOLS = { diff --git a/python/local_toolchains/BUILD.bazel b/python/local_toolchains/BUILD.bazel index 211f3e21a7..256dffb337 100644 --- a/python/local_toolchains/BUILD.bazel +++ b/python/local_toolchains/BUILD.bazel @@ -2,17 +2,24 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") package(default_visibility = ["//:__subpackages__"]) +filegroup( + name = "distribution", + srcs = glob(["**"]), +) + bzl_library( - name = "repos_bzl", + name = "repos", srcs = ["repos.bzl"], visibility = ["//visibility:public"], deps = [ - "//python/private:local_runtime_repo_bzl", - "//python/private:local_runtime_toolchains_repo_bzl", + "//python/private:local_runtime_repo", + "//python/private:local_runtime_toolchains_repo", ], ) -filegroup( - name = "distribution", - srcs = glob(["**"]), +alias( + name = "repos_bzl", + actual = ":repos", + deprecation = "Use //python/local_toolchains:repos instead", + visibility = ["//visibility:public"], ) diff --git a/python/pip_install/BUILD.bazel b/python/pip_install/BUILD.bazel index 665375cc5b..b3c1ab1c03 100644 --- a/python/pip_install/BUILD.bazel +++ b/python/pip_install/BUILD.bazel @@ -18,23 +18,6 @@ package( default_visibility = ["//:__subpackages__"], ) -bzl_library( - name = "pip_repository_bzl", - srcs = ["pip_repository.bzl"], - deps = [ - "//python/private/pypi:package_annotation_bzl", - "//python/private/pypi:pip_repository_bzl", - "//python/private/pypi:whl_config_repo_bzl", - "//python/private/pypi:whl_library_bzl", - ], -) - -bzl_library( - name = "requirements_bzl", - srcs = ["requirements.bzl"], - deps = ["//python/private/pypi:pip_compile_bzl"], -) - filegroup( name = "distribution", srcs = glob(["**"]), @@ -51,3 +34,26 @@ exports_files( glob(["*.bzl"]), visibility = ["//docs:__pkg__"], ) + +bzl_library( + name = "pip_repository", + srcs = ["pip_repository.bzl"], + deps = [ + "//python/private/pypi:package_annotation", + "//python/private/pypi:pip_repository", + "//python/private/pypi:whl_config_repo", + "//python/private/pypi:whl_library", + ], +) + +bzl_library( + name = "requirements", + srcs = ["requirements.bzl"], + deps = ["//python/private/pypi:pip_compile"], +) + +bzl_library( + name = "requirements_parser", + srcs = ["requirements_parser.bzl"], + deps = ["//python/private/pypi:parse_requirements_txt"], +) diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index 133c9c6444..c3d60ffaf1 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -18,8 +18,8 @@ load("//python:py_binary.bzl", "py_binary") load("//python:py_library.bzl", "py_library") load(":bazel_config_mode.bzl", "bazel_config_mode") load(":py_exec_tools_toolchain.bzl", "current_interpreter_executable") -load(":sentinel.bzl", "sentinel") -load(":stamp.bzl", "stamp_build_setting") +load(":sentinel_impl.bzl", "sentinel") +load(":stamp_impl.bzl", "stamp_build_setting") load(":uncachable_version_file.bzl", "define_uncachable_version_file") package( @@ -50,7 +50,7 @@ filegroup( ) filegroup( - name = "coverage_deps", + name = "coverage_deps_filegroup", srcs = ["coverage_deps.bzl"], visibility = ["//tools/private/update_deps:__pkg__"], ) @@ -62,853 +62,926 @@ filegroup( visibility = ["//python:__pkg__"], ) +alias( + name = "build_data_writer", + actual = select({ + "@platforms//os:windows": ":build_data_writer.ps1", + "//conditions:default": ":build_data_writer.sh", + }), +) + +define_uncachable_version_file( + name = "uncachable_version_file", +) + +# Needed to define bzl_library targets for docgen. (We don't define the +# bzl_library target here because it'd give our users a transitive dependency +# on Skylib.) +exports_files( + [ + "coverage.patch", + "py_package.bzl", + "py_wheel.bzl", + "version.bzl", + "reexports.bzl", + "stamp_impl.bzl", + "util.bzl", + ], + visibility = ["//:__subpackages__"], +) + +exports_files( + ["python_bootstrap_template.txt"], + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +filegroup( + name = "stage1_bootstrap_template", + srcs = ["stage1_bootstrap_template.sh"], + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +filegroup( + name = "stage2_bootstrap_template", + srcs = ["stage2_bootstrap_template.py"], + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +filegroup( + name = "site_init_template", + srcs = ["site_init_template.py"], + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +# NOTE: Windows builds don't use this bootstrap. Instead, a native Windows +# program locates some Python exe and runs `python.exe foo.zip` which +# runs the __main__.py in the zip file. +alias( + name = "bootstrap_template", + actual = select({ + ":is_script_bootstrap_enabled": "stage1_bootstrap_template.sh", + "//conditions:default": "python_bootstrap_template.txt", + }), + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +# Used to determine the use of `--stamp` in Starlark rules +stamp_build_setting(name = "stamp") + +config_setting( + name = "is_script_bootstrap_enabled", + flag_values = { + "//python/config_settings:bootstrap_impl": "script", + }, +) + +config_setting( + name = "is_bazel_config_mode_target", + flag_values = { + "//python/private:bazel_config_mode": "target", + }, +) + +alias( + name = "debugger_if_target_config", + actual = select({ + ":is_bazel_config_mode_target": "//python/config_settings:debugger", + "//conditions:default": "//python/private:empty", + }), +) + +bazel_config_mode(name = "bazel_config_mode") + +# This should only be set by analysis tests to expose additional metadata to +# aid testing, so a setting instead of a flag. +bool_flag( + name = "visible_for_testing", + build_setting_default = False, + # This is only because it is an implicit dependency by the toolchains. + visibility = ["//visibility:public"], +) + +# Used for py_console_script_gen rule +py_binary( + name = "py_console_script_gen_py", + srcs = ["py_console_script_gen.py"], + main = "py_console_script_gen.py", + visibility = [ + "//visibility:public", + ], +) + +py_binary( + name = "py_wheel_dist", + srcs = ["py_wheel_dist.py"], + visibility = ["//visibility:public"], +) + +py_library( + name = "py_console_script_gen_lib", + srcs = ["py_console_script_gen.py"], + imports = ["../.."], + visibility = [ + "//tests/entry_points:__pkg__", + ], +) + +# The current toolchain's interpreter as an excutable, usable with +# executable=True attributes. +current_interpreter_executable( + name = "current_interpreter_executable", + # Not actually public. Only public because it's an implicit dependency of + # py_exec_tools_toolchain. + visibility = ["//visibility:public"], +) + +py_library( + name = "empty", +) + +sentinel( + name = "sentinel", +) + +py_binary( + name = "sync_runtimes_manifest_workspace", + srcs = ["tools/sync_runtimes_manifest_workspace.py"], + visibility = ["//:__subpackages__"], +) + bzl_library( - name = "attr_builders_bzl", + name = "attr_builders", srcs = ["attr_builders.bzl"], deps = [ - ":builders_util_bzl", + ":builders_util", "@bazel_skylib//lib:types", ], ) bzl_library( - name = "attributes_bzl", + name = "attributes", srcs = ["attributes.bzl"], deps = [ - ":attr_builders_bzl", - ":common_bzl", - ":common_labels_bzl", - ":enum_bzl", - ":flags_bzl", - ":py_info_bzl", - ":py_internal_bzl", - ":reexports_bzl", - ":rule_builders_bzl", - ":rules_cc_srcs_bzl", + ":attr_builders", + ":common_labels", + ":enum", + ":flags", + ":py_info", + ":reexports", + ":rule_builders", "@bazel_skylib//lib:dicts", "@bazel_skylib//rules:common_settings", + "@rules_cc//cc/common", ], ) bzl_library( - name = "auth_bzl", + name = "auth", srcs = ["auth.bzl"], - deps = [":bazel_tools_bzl"], + deps = ["//python/private:bazel_tools"], ) +# @bazel_tools can't define bzl_library itself, so we just put a wrapper around it. +# Keep this target because Gazelle does not automatically generate +# bzl_library targets for external repositories like @bazel_tools. +# keep bzl_library( - name = "runtime_env_toolchain_bzl", - srcs = ["runtime_env_toolchain.bzl"], - deps = [ - ":config_settings_bzl", - ":py_exec_tools_toolchain_bzl", - ":toolchain_types_bzl", - "//python:py_runtime_bzl", - "//python:py_runtime_pair_bzl", + name = "bazel_tools", + srcs = [ + # This set of sources is overly broad, but it's the only public + # target available across Bazel versions that has all the necessary + # sources. + "@bazel_tools//tools:bzl_srcs", ], ) -alias( - name = "build_data_writer", - actual = select({ - "@platforms//os:windows": ":build_data_writer.ps1", - "//conditions:default": ":build_data_writer.sh", - }), -) - bzl_library( - name = "builders_bzl", + name = "builders", srcs = ["builders.bzl"], - deps = [ - "@bazel_skylib//lib:types", - ], + deps = ["@bazel_skylib//lib:types"], ) bzl_library( - name = "builders_util_bzl", + name = "builders_util", srcs = ["builders_util.bzl"], deps = [ - ":bzlmod_enabled_bzl", + ":bzlmod_enabled", "@bazel_skylib//lib:types", ], ) bzl_library( - name = "bzlmod_enabled_bzl", - srcs = ["bzlmod_enabled.bzl"], -) - -bzl_library( - name = "cc_helper_bzl", + name = "cc_helper", srcs = ["cc_helper.bzl"], - deps = [":py_internal_bzl"], + deps = [":py_internal"], ) bzl_library( - name = "common_bzl", + name = "common", srcs = ["common.bzl"], deps = [ - ":cc_helper_bzl", - ":py_cc_link_params_info_bzl", - ":py_info_bzl", - ":py_internal_bzl", - ":reexports_bzl", - ":rules_cc_srcs_bzl", + ":builders", + ":cc_helper", + ":py_cc_link_params_info", + ":py_info", + ":py_internal", + ":py_interpreter_program", + ":reexports", + ":toolchain_types", "@bazel_skylib//lib:paths", + "@rules_cc//cc/common", + "@rules_python_internal//:rules_python_config", ], ) bzl_library( - name = "common_labels_bzl", - srcs = ["common_labels.bzl"], -) - -bzl_library( - name = "config_settings_bzl", + name = "config_settings", srcs = ["config_settings.bzl"], deps = [ - ":version_bzl", + ":text_util", + ":version", "@bazel_skylib//lib:selects", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "coverage_deps_bzl", + name = "coverage_deps", srcs = ["coverage_deps.bzl"], deps = [ - ":bazel_tools_bzl", - ":repo_utils_bzl", - ":version_label_bzl", + ":repo_utils", + ":version_label", + "//python/private:bazel_tools", ], ) bzl_library( - name = "deprecation_bzl", - srcs = ["deprecation.bzl"], + name = "current_py_cc_headers", + srcs = ["current_py_cc_headers.bzl"], deps = [ - "@rules_python_internal//:rules_python_config_bzl", + ":toolchain_types", + "@rules_cc//cc/common", ], ) bzl_library( - name = "enum_bzl", - srcs = ["enum.bzl"], + name = "current_py_cc_libs", + srcs = ["current_py_cc_libs.bzl"], + deps = ["@rules_cc//cc/common"], ) bzl_library( - name = "envsubst_bzl", - srcs = ["envsubst.bzl"], + name = "deprecation", + srcs = ["deprecation.bzl"], + deps = ["@rules_python_internal//:rules_python_config"], ) bzl_library( - name = "flags_bzl", + name = "flags", srcs = ["flags.bzl"], deps = [ - ":enum_bzl", + ":enum", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "full_version_bzl", - srcs = ["full_version.bzl"], -) - -bzl_library( - name = "internal_config_repo_bzl", - srcs = ["internal_config_repo.bzl"], - deps = [ - ":pbs_manifest_bzl", - ":repo_utils_bzl", - ":text_util_bzl", - ], -) - -bzl_library( - name = "is_standalone_interpreter_bzl", - srcs = ["is_standalone_interpreter.bzl"], - deps = [ - ":repo_utils_bzl", - ], -) - -bzl_library( - name = "local_runtime_repo_bzl", - srcs = ["local_runtime_repo.bzl"], + name = "hermetic_runtime_repo_setup", + srcs = ["hermetic_runtime_repo_setup.bzl"], deps = [ - ":enum_bzl", - ":repo_utils.bzl", + ":py_exec_tools_toolchain", + ":version", + "//python:py_runtime", + "//python:py_runtime_pair", + "//python/cc:py_cc_toolchain", + "@rules_cc//cc:core_rules", ], ) bzl_library( - name = "local_runtime_toolchains_repo_bzl", - srcs = ["local_runtime_toolchains_repo.bzl"], + name = "internal_config_repo", + srcs = ["internal_config_repo.bzl"], deps = [ - ":repo_utils.bzl", - ":text_util_bzl", + ":repo_utils", + ":text_util", ], ) bzl_library( - name = "normalize_name_bzl", - srcs = ["normalize_name.bzl"], -) - -bzl_library( - name = "pbs_manifest_bzl", - srcs = ["pbs_manifest.bzl"], -) - -bzl_library( - name = "precompile_bzl", - srcs = ["precompile.bzl"], + name = "interpreter", + srcs = ["interpreter.bzl"], deps = [ - ":attributes_bzl", - ":py_internal_bzl", - ":py_interpreter_program_bzl", - ":toolchain_types_bzl", + ":common", + ":sentinel_impl", + ":toolchain_types", + "//python:py_runtime_info", "@bazel_skylib//lib:paths", ], ) bzl_library( - name = "platform_info_bzl", - srcs = ["platform_info.bzl"], -) - -bzl_library( - name = "python_bzl", - srcs = ["python.bzl"], - deps = [ - ":full_version_bzl", - ":pbs_manifest_bzl", - ":platform_info_bzl", - ":python_register_toolchains_bzl", - ":pythons_hub_bzl", - ":repo_utils_bzl", - ":toolchains_repo_bzl", - ":version_bzl", - "@bazel_features//:features", - ], + name = "is_standalone_interpreter", + srcs = ["is_standalone_interpreter.bzl"], + deps = [":repo_utils"], ) bzl_library( - name = "python_register_toolchains_bzl", - srcs = ["python_register_toolchains.bzl"], + name = "local_runtime_repo", + srcs = ["local_runtime_repo.bzl"], deps = [ - ":auth_bzl", - ":bazel_tools_bzl", - ":coverage_deps_bzl", - ":full_version_bzl", - ":python_repository_bzl", - ":repo_utils_bzl", - ":toolchains_repo_bzl", - "//python:versions_bzl", - "//python/private/pypi:deps_bzl", + ":enum", + ":repo_utils", ], ) bzl_library( - name = "python_repository_bzl", - srcs = ["python_repository.bzl"], + name = "local_runtime_repo_setup", + srcs = ["local_runtime_repo_setup.bzl"], deps = [ - ":auth_bzl", - ":repo_utils_bzl", - ":text_util_bzl", - "//python:versions_bzl", + "@bazel_skylib//lib:selects", + "@rules_cc//cc:core_rules", + "@rules_python//python:py_runtime", + "@rules_python//python:py_runtime_pair", + "@rules_python//python/cc:py_cc_toolchain", + "@rules_python//python/private:py_exec_tools_toolchain", ], ) bzl_library( - name = "python_register_multi_toolchains_bzl", - srcs = ["python_register_multi_toolchains.bzl"], + name = "local_runtime_toolchains_repo", + srcs = ["local_runtime_toolchains_repo.bzl"], deps = [ - ":python_register_toolchains_bzl", - ":toolchains_repo_bzl", - "//python:versions_bzl", + ":repo_utils", + ":text_util", ], ) bzl_library( - name = "pythons_hub_bzl", - srcs = ["pythons_hub.bzl"], + name = "precompile", + srcs = ["precompile.bzl"], deps = [ - ":pbs_manifest_bzl", - ":py_toolchain_suite_bzl", - ":text_util_bzl", - "//python:versions_bzl", + ":attributes", + ":flags", + ":py_interpreter_program", + ":toolchain_types", + "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "py_binary_macro_bzl", + name = "py_binary_macro", srcs = ["py_binary_macro.bzl"], deps = [ - ":py_binary_rule_bzl", - ":py_executable_bzl", + ":py_binary_rule", + ":py_executable", ], ) bzl_library( - name = "py_binary_rule_bzl", + name = "py_binary_rule", srcs = ["py_binary_rule.bzl"], deps = [ - ":attributes_bzl", - ":py_executable_bzl", - ":rule_builders_bzl", - "@bazel_skylib//lib:dicts", + ":attributes", + ":py_executable", ], ) bzl_library( - name = "py_cc_link_params_info_bzl", + name = "py_cc_link_params_info", srcs = ["py_cc_link_params_info.bzl"], - deps = [ - ":rules_cc_srcs_bzl", - ":util_bzl", - ], + deps = ["@rules_cc//cc/common"], ) bzl_library( - name = "py_cc_toolchain_macro_bzl", + name = "py_cc_toolchain_macro", srcs = ["py_cc_toolchain_macro.bzl"], deps = [ - ":py_cc_toolchain_rule_bzl", + ":py_cc_toolchain_rule", + ":util", ], ) bzl_library( - name = "py_cc_toolchain_rule_bzl", + name = "py_cc_toolchain_rule", srcs = ["py_cc_toolchain_rule.bzl"], deps = [ - ":common_labels_bzl", - ":py_cc_toolchain_info_bzl", - ":rules_cc_srcs_bzl", - ":sentinel_bzl", - ":util_bzl", + ":common_labels", + ":py_cc_toolchain_info", + ":sentinel_impl", "@bazel_skylib//rules:common_settings", + "@rules_cc//cc/common", ], ) bzl_library( - name = "py_cc_toolchain_info_bzl", - srcs = ["py_cc_toolchain_info.bzl"], -) - -bzl_library( - name = "py_console_script_binary_bzl", - srcs = [ - "py_console_script_binary.bzl", - "py_console_script_gen.bzl", - ], - visibility = ["//python/entry_points:__pkg__"], + name = "py_console_script_binary", + srcs = ["py_console_script_binary.bzl"], deps = [ - "//python:py_binary_bzl", + ":py_console_script_gen", + "//python:py_binary", ], ) bzl_library( - name = "py_exec_tools_info_bzl", - srcs = ["py_exec_tools_info.bzl"], -) - -bzl_library( - name = "py_exec_tools_toolchain_bzl", + name = "py_exec_tools_toolchain", srcs = ["py_exec_tools_toolchain.bzl"], deps = [ - ":common_bzl", - ":common_labels_bzl", - ":py_exec_tools_info_bzl", - ":sentinel_bzl", - ":toolchain_types_bzl", + ":common_labels", + ":py_exec_tools_info", + ":sentinel_impl", + ":toolchain_types", "@bazel_skylib//lib:paths", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "py_executable_bzl", + name = "py_executable", srcs = ["py_executable.bzl"], deps = [ - ":attributes_bzl", - ":cc_helper_bzl", - ":common_bzl", - ":common_labels_bzl", - ":flags_bzl", - ":precompile_bzl", - ":py_cc_link_params_info_bzl", - ":py_executable_info_bzl", - ":py_info_bzl", - ":py_internal_bzl", - ":py_runtime_info_bzl", - ":rules_cc_srcs_bzl", - ":toolchain_types_bzl", - ":transition_labels_bzl", - ":venv_runfiles_bzl", + ":attr_builders", + ":attributes", + ":builders", + ":cc_helper", + ":common", + ":common_labels", + ":flags", + ":precompile", + ":py_cc_link_params_info", + ":py_executable_info", + ":py_info", + ":py_internal", + ":py_runtime_info", + ":reexports", + ":rule_builders", + ":toolchain_types", + ":transition_labels", + ":venv_runfiles", "@bazel_skylib//lib:dicts", "@bazel_skylib//lib:paths", "@bazel_skylib//lib:structs", "@bazel_skylib//rules:common_settings", + "@rules_cc//cc/common", + "@rules_python_internal//:rules_python_config", ], ) bzl_library( - name = "py_executable_info_bzl", - srcs = ["py_executable_info.bzl"], -) - -bzl_library( - name = "py_info_bzl", + name = "py_info", srcs = ["py_info.bzl"], deps = [ - ":builders_bzl", - ":reexports_bzl", + ":builders", + ":reexports", ], ) bzl_library( - name = "py_internal_bzl", + name = "py_internal", srcs = ["py_internal.bzl"], - deps = ["//tools/build_defs/python/private:py_internal_renamed_bzl"], + deps = ["//tools/build_defs/python/private:py_internal_renamed"], ) bzl_library( - name = "py_interpreter_program_bzl", + name = "py_interpreter_program", srcs = ["py_interpreter_program.bzl"], deps = [ - ":sentinel_bzl", + ":sentinel_impl", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "py_library_bzl", + name = "py_library", srcs = ["py_library.bzl"], deps = [ - ":attributes_bzl", - ":common_bzl", - ":common_labels_bzl", - ":flags_bzl", - ":normalize_name_bzl", - ":precompile_bzl", - ":py_cc_link_params_info_bzl", - ":py_internal_bzl", - ":rule_builders_bzl", - ":toolchain_types_bzl", - ":venv_runfiles_bzl", - ":version_bzl", + ":attr_builders", + ":attributes", + ":builders", + ":common", + ":common_labels", + ":flags", + ":normalize_name", + ":precompile", + ":py_cc_link_params_info", + ":py_info", + ":reexports", + ":rule_builders", + ":toolchain_types", + ":venv_runfiles", + ":version", "@bazel_skylib//lib:dicts", + "@bazel_skylib//lib:paths", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "py_library_macro_bzl", + name = "py_library_macro", srcs = ["py_library_macro.bzl"], - deps = [":py_library_rule_bzl"], + deps = [":py_library_rule"], ) bzl_library( - name = "py_library_rule_bzl", + name = "py_library_rule", srcs = ["py_library_rule.bzl"], - deps = [ - ":common_labels_bzl", - ":py_library_bzl", - ], + deps = [":py_library"], ) bzl_library( - name = "py_package_bzl", + name = "py_package", srcs = ["py_package.bzl"], - visibility = ["//:__subpackages__"], deps = [ - ":builders_bzl", - ":py_info_bzl", + ":builders", + ":py_info", ], ) bzl_library( - name = "py_runtime_info_bzl", - srcs = ["py_runtime_info.bzl"], -) - -bzl_library( - name = "py_repositories_bzl", + name = "py_repositories", srcs = ["py_repositories.bzl"], deps = [ - ":bazel_tools_bzl", - ":internal_config_repo_bzl", - ":pythons_hub_bzl", - "//python:versions_bzl", - "//python/private/pypi:deps_bzl", + ":internal_config_repo", + ":pythons_hub", + "//python:versions", + "//python/private:bazel_tools", + "//python/private/pypi:deps", ], ) bzl_library( - name = "py_runtime_macro_bzl", + name = "py_runtime_macro", srcs = ["py_runtime_macro.bzl"], - deps = [":py_runtime_rule_bzl"], + deps = [":py_runtime_rule"], ) bzl_library( - name = "py_runtime_rule_bzl", - srcs = ["py_runtime_rule.bzl"], - deps = [ - ":attributes_bzl", - ":common_labels_bzl", - ":flags_bzl", - ":py_internal_bzl", - ":py_runtime_info_bzl", - ":reexports_bzl", - ":rule_builders_bzl", - ":version_bzl", - "@bazel_skylib//lib:dicts", - "@bazel_skylib//lib:paths", - "@bazel_skylib//rules:common_settings", - ], + name = "py_runtime_pair_macro", + srcs = ["py_runtime_pair_macro.bzl"], + deps = [":py_runtime_pair_rule"], ) bzl_library( - name = "py_runtime_pair_macro_bzl", - srcs = ["py_runtime_pair_macro.bzl"], - visibility = ["//:__subpackages__"], - deps = [":py_runtime_pair_rule_bzl"], + name = "py_runtime_pair_rule", + srcs = ["py_runtime_pair_rule.bzl"], + deps = [ + ":common_labels", + ":reexports", + "//python:py_runtime_info", + "@bazel_skylib//rules:common_settings", + ], ) bzl_library( - name = "py_runtime_pair_rule_bzl", - srcs = ["py_runtime_pair_rule.bzl"], + name = "py_runtime_rule", + srcs = ["py_runtime_rule.bzl"], deps = [ - ":common_labels_bzl", - "//python:py_runtime_bzl", - "//python:py_runtime_info_bzl", + ":common_labels", + ":flags", + ":py_internal", + ":py_runtime_info", + ":reexports", + ":version", + "@bazel_skylib//lib:dicts", + "@bazel_skylib//lib:paths", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "py_test_macro_bzl", + name = "py_test_macro", srcs = ["py_test_macro.bzl"], deps = [ - ":py_executable_bzl", - ":py_test_rule_bzl", + ":py_executable", + ":py_test_rule", ], ) bzl_library( - name = "py_test_rule_bzl", + name = "py_test_rule", srcs = ["py_test_rule.bzl"], deps = [ - ":attributes_bzl", - ":common_bzl", - ":py_executable_bzl", - ":rule_builders_bzl", - "@bazel_skylib//lib:dicts", + ":attributes", + ":common", + ":py_executable", ], ) bzl_library( - name = "py_toolchain_suite_bzl", + name = "py_toolchain_suite", srcs = ["py_toolchain_suite.bzl"], deps = [ - ":config_settings_bzl", - ":text_util_bzl", - ":toolchain_types_bzl", + ":text_util", + ":toolchain_types", "@bazel_skylib//lib:selects", + "@platforms//host:constraints_lib", ], ) bzl_library( - name = "py_wheel_bzl", + name = "py_wheel", srcs = ["py_wheel.bzl"], - visibility = ["//:__subpackages__"], deps = [ - ":py_package_bzl", - ":stamp_bzl", - ":transition_labels_bzl", - ":version_bzl", + ":attributes", + ":py_info", + ":py_package", + ":rule_builders", + ":stamp_impl", + ":transition_labels", + ":version", ], ) bzl_library( - name = "reexports_bzl", - srcs = ["reexports.bzl"], - visibility = [ - "//:__subpackages__", - ], + name = "python", + srcs = ["python.bzl"], deps = [ - ":bazel_tools_bzl", - "@rules_python_internal//:rules_python_config_bzl", + ":auth", + ":full_version", + ":pbs_manifest", + ":platform_info", + ":python_register_toolchains", + ":pythons_hub", + ":repo_utils", + ":toolchains_repo", + ":version", + "//python:versions", + "@bazel_features//:features", ], ) bzl_library( - name = "repo_utils_bzl", - srcs = ["repo_utils.bzl"], + name = "python_register_multi_toolchains", + srcs = ["python_register_multi_toolchains.bzl"], + deps = [ + ":python_register_toolchains", + ":toolchains_repo", + "//python:versions", + ], ) bzl_library( - name = "rule_builders_bzl", - srcs = ["rule_builders.bzl"], + name = "python_register_toolchains", + srcs = ["python_register_toolchains.bzl"], deps = [ - ":builders_bzl", - ":builders_util_bzl", - "@bazel_skylib//lib:types", + ":coverage_deps", + ":full_version", + ":python_repository", + ":repo_utils", + ":toolchains_repo", + "//python:versions", ], ) bzl_library( - name = "runtimes_manifest_workspace_bzl", - srcs = ["runtimes_manifest_workspace.bzl"], + name = "python_repository", + srcs = ["python_repository.bzl"], + deps = [ + ":auth", + ":repo_utils", + ":text_util", + "//python:versions", + ], ) bzl_library( - name = "sentinel_bzl", - srcs = ["sentinel.bzl"], + name = "pythons_hub", + srcs = ["pythons_hub.bzl"], + deps = [ + ":pbs_manifest", + ":text_util", + ":toolchains_repo", + "//python:versions", + ], ) bzl_library( - name = "stamp_bzl", - srcs = ["stamp.bzl"], - visibility = ["//:__subpackages__"], + name = "reexports", + srcs = ["reexports.bzl"], + deps = ["@rules_python_internal//:rules_python_config"], ) bzl_library( - name = "text_util_bzl", - srcs = ["text_util.bzl"], + name = "repl", + srcs = ["repl.bzl"], + deps = ["//python:py_binary"], ) bzl_library( - name = "toolchains_repo_bzl", - srcs = ["toolchains_repo.bzl"], + name = "rule_builders", + srcs = ["rule_builders.bzl"], deps = [ - ":repo_utils_bzl", - ":text_util_bzl", - "//python:versions_bzl", + ":builders_util", + "@bazel_skylib//lib:types", ], ) +# Keep this target because Gazelle does not automatically generate or +# maintain composite bzl_library targets. We need this to group external +# dependency sources. +# keep bzl_library( - name = "toolchain_types_bzl", - srcs = ["toolchain_types.bzl"], + name = "rules_cc_srcs", + srcs = [ + # rules_cc 0.0.13 and earlier load cc_proto_libary (and thus protobuf@), + # but their bzl srcs targets don't transitively refer to protobuf. + "@com_google_protobuf//:bzl_srcs", + # NOTE: As of rules_cc 0.10, cc:bzl_srcs no longer contains + # everything and sub-targets must be used instead + "@rules_cc//cc:bzl_srcs", + "@rules_cc//cc/common", + "@rules_cc//cc/toolchains:toolchain_rules", + ], + deps = [ + ":bazel_tools", + "@rules_cc//cc/common", + ], ) bzl_library( - name = "transition_labels_bzl", - srcs = ["transition_labels.bzl"], - deps = [ - ":common_labels_bzl", - "@bazel_skylib//lib:collections", - "@rules_python_internal//:extra_transition_settings_bzl", - ], + name = "runtime_env_repo", + srcs = ["runtime_env_repo.bzl"], + deps = [":repo_utils"], ) bzl_library( - name = "util_bzl", - srcs = ["util.bzl"], + name = "runtime_env_toolchain", + srcs = ["runtime_env_toolchain.bzl"], deps = [ - ":py_internal_bzl", - "@bazel_skylib//lib:types", + ":config_settings", + ":py_exec_tools_toolchain", + ":toolchain_types", + "//python:py_runtime", + "//python:py_runtime_pair", + "//python/cc:py_cc_toolchain", + "@rules_cc//cc:core_rules", ], ) -define_uncachable_version_file( - name = "uncachable_version_file", -) - bzl_library( - name = "version_bzl", - srcs = ["version.bzl"], + name = "toolchain_aliases", + srcs = ["toolchain_aliases.bzl"], + deps = [ + "//python:versions", + "@bazel_skylib//lib:selects", + ], ) bzl_library( - name = "version_label_bzl", - srcs = ["version_label.bzl"], + name = "toolchains_repo", + srcs = ["toolchains_repo.bzl"], + deps = [ + ":repo_utils", + ":text_util", + "//python:versions", + ], ) -# @bazel_tools can't define bzl_library itself, so we just put a wrapper around it. bzl_library( - name = "bazel_tools_bzl", - srcs = [ - # This set of sources is overly broad, but it's the only public - # target available across Bazel versions that has all the necessary - # sources. - "@bazel_tools//tools:bzl_srcs", + name = "transition_labels", + srcs = ["transition_labels.bzl"], + deps = [ + ":common_labels", + "@bazel_skylib//lib:collections", + "@rules_python_internal//:extra_transition_settings", ], ) bzl_library( - name = "rules_cc_srcs_bzl", - srcs = [ - # rules_cc 0.0.13 and earlier load cc_proto_libary (and thus protobuf@), - # but their bzl srcs targets don't transitively refer to protobuf. - "@com_google_protobuf//:bzl_srcs", - # NOTE: As of rules_cc 0.10, cc:bzl_srcs no longer contains - # everything and sub-targets must be used instead - "@rules_cc//cc:bzl_srcs", - "@rules_cc//cc/common", - "@rules_cc//cc/toolchains:toolchain_rules", - ], + name = "util", + srcs = ["util.bzl"], deps = [ - ":bazel_tools_bzl", - "@rules_cc//cc/common", + ":py_internal", + "@bazel_skylib//lib:types", ], ) bzl_library( - name = "venv_runfiles_bzl", + name = "venv_runfiles", srcs = ["venv_runfiles.bzl"], deps = [ - ":common_bzl", - ":py_info_bzl", - ":py_internal_bzl", + ":common", + ":py_info", + ":util", "@bazel_skylib//lib:paths", ], ) -# Needed to define bzl_library targets for docgen. (We don't define the -# bzl_library target here because it'd give our users a transitive dependency -# on Skylib.) -exports_files( - [ - "coverage.patch", - "py_package.bzl", - "py_wheel.bzl", - "version.bzl", - "reexports.bzl", - "stamp.bzl", - "util.bzl", - ], - visibility = ["//:__subpackages__"], +bzl_library( + name = "bzlmod_enabled", + srcs = ["bzlmod_enabled.bzl"], ) -exports_files( - ["python_bootstrap_template.txt"], - # Not actually public. Only public because it's an implicit dependency of - # py_runtime. - visibility = ["//visibility:public"], +bzl_library( + name = "common_labels", + srcs = ["common_labels.bzl"], ) -filegroup( - name = "stage1_bootstrap_template", - srcs = ["stage1_bootstrap_template.sh"], - # Not actually public. Only public because it's an implicit dependency of - # py_runtime. - visibility = ["//visibility:public"], +bzl_library( + name = "enum", + srcs = ["enum.bzl"], ) -filegroup( - name = "stage2_bootstrap_template", - srcs = ["stage2_bootstrap_template.py"], - # Not actually public. Only public because it's an implicit dependency of - # py_runtime. - visibility = ["//visibility:public"], +bzl_library( + name = "envsubst", + srcs = ["envsubst.bzl"], ) -filegroup( - name = "site_init_template", - srcs = ["site_init_template.py"], - # Not actually public. Only public because it's an implicit dependency of - # py_runtime. - visibility = ["//visibility:public"], +bzl_library( + name = "full_version", + srcs = ["full_version.bzl"], ) -# NOTE: Windows builds don't use this bootstrap. Instead, a native Windows -# program locates some Python exe and runs `python.exe foo.zip` which -# runs the __main__.py in the zip file. -alias( - name = "bootstrap_template", - actual = select({ - ":is_script_bootstrap_enabled": "stage1_bootstrap_template.sh", - "//conditions:default": "python_bootstrap_template.txt", - }), - # Not actually public. Only public because it's an implicit dependency of - # py_runtime. - visibility = ["//visibility:public"], +bzl_library( + name = "normalize_name", + srcs = ["normalize_name.bzl"], ) -# Used to determine the use of `--stamp` in Starlark rules -stamp_build_setting(name = "stamp") +bzl_library( + name = "pbs_manifest", + srcs = ["pbs_manifest.bzl"], +) -config_setting( - name = "is_script_bootstrap_enabled", - flag_values = { - "//python/config_settings:bootstrap_impl": "script", - }, +bzl_library( + name = "platform_info", + srcs = ["platform_info.bzl"], ) -config_setting( - name = "is_bazel_config_mode_target", - flag_values = { - "//python/private:bazel_config_mode": "target", - }, +bzl_library( + name = "py_cc_toolchain_info", + srcs = ["py_cc_toolchain_info.bzl"], ) -alias( - name = "debugger_if_target_config", - actual = select({ - ":is_bazel_config_mode_target": "//python/config_settings:debugger", - "//conditions:default": "//python/private:empty", - }), +bzl_library( + name = "py_console_script_gen", + srcs = ["py_console_script_gen.bzl"], ) -bazel_config_mode(name = "bazel_config_mode") +bzl_library( + name = "py_exec_tools_info", + srcs = ["py_exec_tools_info.bzl"], +) -# This should only be set by analysis tests to expose additional metadata to -# aid testing, so a setting instead of a flag. -bool_flag( - name = "visible_for_testing", - build_setting_default = False, - # This is only because it is an implicit dependency by the toolchains. - visibility = ["//visibility:public"], +bzl_library( + name = "py_executable_info", + srcs = ["py_executable_info.bzl"], ) -# Used for py_console_script_gen rule -py_binary( - name = "py_console_script_gen_py", - srcs = ["py_console_script_gen.py"], - main = "py_console_script_gen.py", - visibility = [ - "//visibility:public", - ], +bzl_library( + name = "py_runtime_info", + srcs = ["py_runtime_info.bzl"], ) -py_binary( - name = "py_wheel_dist", - srcs = ["py_wheel_dist.py"], - visibility = ["//visibility:public"], +bzl_library( + name = "repo_utils", + srcs = ["repo_utils.bzl"], ) -py_library( - name = "py_console_script_gen_lib", - srcs = ["py_console_script_gen.py"], - imports = ["../.."], - visibility = [ - "//tests/entry_points:__pkg__", - ], +bzl_library( + name = "runtimes_manifest_workspace", + srcs = ["runtimes_manifest_workspace.bzl"], ) -# The current toolchain's interpreter as an excutable, usable with -# executable=True attributes. -current_interpreter_executable( - name = "current_interpreter_executable", - # Not actually public. Only public because it's an implicit dependency of - # py_exec_tools_toolchain. - visibility = ["//visibility:public"], +bzl_library( + name = "sentinel_impl", + srcs = ["sentinel_impl.bzl"], ) -py_library( - name = "empty", +bzl_library( + name = "stamp_impl", + srcs = ["stamp_impl.bzl"], ) -sentinel( - name = "sentinel", +bzl_library( + name = "text_util", + srcs = ["text_util.bzl"], ) -py_binary( - name = "sync_runtimes_manifest_workspace", - srcs = ["tools/sync_runtimes_manifest_workspace.py"], - visibility = ["//:__subpackages__"], +bzl_library( + name = "toolchain_types", + srcs = ["toolchain_types.bzl"], +) + +bzl_library( + name = "version", + srcs = ["version.bzl"], +) + +bzl_library( + name = "version_label", + srcs = ["version_label.bzl"], +) + +bzl_library( + name = "visibility", + srcs = ["visibility.bzl"], ) diff --git a/python/private/api/BUILD.bazel b/python/private/api/BUILD.bazel index 0826b85d9b..b3cc7360a7 100644 --- a/python/private/api/BUILD.bazel +++ b/python/private/api/BUILD.bazel @@ -25,24 +25,21 @@ filegroup( ) py_common_api( - name = "py_common_api", + name = "py_common_api_impl", # NOTE: Not actually public. Implicit dependency of public rules. visibility = ["//visibility:public"], ) bzl_library( - name = "api_bzl", - srcs = ["api.bzl"], + name = "py_common_api", + srcs = ["py_common_api.bzl"], deps = [ - "//python/private:py_info_bzl", + ":api", + "//python/private:py_info", ], ) bzl_library( - name = "py_common_api_bzl", - srcs = ["py_common_api.bzl"], - deps = [ - ":api_bzl", - "//python/private:py_info_bzl", - ], + name = "api", + srcs = ["api.bzl"], ) diff --git a/python/private/api/api.bzl b/python/private/api/api.bzl index 44f9ab4e77..2a1f79fdf5 100644 --- a/python/private/api/api.bzl +++ b/python/private/api/api.bzl @@ -13,7 +13,7 @@ # limitations under the License. """Implementation of py_api.""" -_PY_COMMON_API_LABEL = Label("//python/private/api:py_common_api") +_PY_COMMON_API_LABEL = Label("//python/private/api:py_common_api_impl") ApiImplInfo = provider( doc = "Provider to hold an API implementation", diff --git a/python/private/internal_config_repo.bzl b/python/private/internal_config_repo.bzl index 72970cf100..9bae0a5821 100644 --- a/python/private/internal_config_repo.bzl +++ b/python/private/internal_config_repo.bzl @@ -49,12 +49,12 @@ package( ) bzl_library( - name = "extra_transition_settings_bzl", + name = "extra_transition_settings", srcs = ["extra_transition_settings.bzl"], ) bzl_library( - name = "rules_python_config_bzl", + name = "rules_python_config", srcs = ["rules_python_config.bzl"], ) """ diff --git a/python/private/interpreter.bzl b/python/private/interpreter.bzl index c66d3dc21e..b281be9639 100644 --- a/python/private/interpreter.bzl +++ b/python/private/interpreter.bzl @@ -17,7 +17,7 @@ load("@bazel_skylib//lib:paths.bzl", "paths") load("//python:py_runtime_info.bzl", "PyRuntimeInfo") load(":common.bzl", "runfiles_root_path") -load(":sentinel.bzl", "SentinelInfo") +load(":sentinel_impl.bzl", "SentinelInfo") load(":toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE") def _interpreter_binary_impl(ctx): diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl index b89ea0e6b0..315de2d3f2 100644 --- a/python/private/py_cc_toolchain_rule.bzl +++ b/python/private/py_cc_toolchain_rule.bzl @@ -22,7 +22,7 @@ load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") load(":common_labels.bzl", "labels") load(":py_cc_toolchain_info.bzl", "PyCcToolchainInfo") -load(":sentinel.bzl", "SentinelInfo") +load(":sentinel_impl.bzl", "SentinelInfo") def _py_cc_toolchain_impl(ctx): if ctx.attr.libs: diff --git a/python/private/py_exec_tools_toolchain.bzl b/python/private/py_exec_tools_toolchain.bzl index ec8d4e53d0..d126262033 100644 --- a/python/private/py_exec_tools_toolchain.bzl +++ b/python/private/py_exec_tools_toolchain.bzl @@ -18,7 +18,7 @@ load("@bazel_skylib//lib:paths.bzl", "paths") load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load(":common_labels.bzl", "labels") load(":py_exec_tools_info.bzl", "PyExecToolsInfo") -load(":sentinel.bzl", "SentinelInfo") +load(":sentinel_impl.bzl", "SentinelInfo") load(":toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE") def _py_exec_tools_toolchain_impl(ctx): diff --git a/python/private/py_interpreter_program.bzl b/python/private/py_interpreter_program.bzl index 7eb3e28bd9..1f1abb1bfe 100644 --- a/python/private/py_interpreter_program.bzl +++ b/python/private/py_interpreter_program.bzl @@ -15,7 +15,7 @@ """Internal only bootstrap level binary-like rule.""" load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") -load("//python/private:sentinel.bzl", "SentinelInfo") +load("//python/private:sentinel_impl.bzl", "SentinelInfo") PyInterpreterProgramInfo = provider( doc = "Information about how to run a program with an external interpreter.", diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl index 1ca344c086..b622411c56 100644 --- a/python/private/py_wheel.bzl +++ b/python/private/py_wheel.bzl @@ -18,7 +18,7 @@ load(":attributes.bzl", "CONFIG_SETTINGS_ATTR", "apply_config_settings_attr") load(":py_info.bzl", "PyInfo") load(":py_package.bzl", "py_package_lib") load(":rule_builders.bzl", "ruleb") -load(":stamp.bzl", "is_stamping_enabled") +load(":stamp_impl.bzl", "is_stamping_enabled") load(":transition_labels.bzl", "TRANSITION_LABELS") load(":version.bzl", "version") diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 23f636c870..677154ee46 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -74,501 +74,507 @@ filegroup( visibility = ["//tools/private/update_deps:__pkg__"], ) -# Keep sorted by library name and keep the files named by the main symbol they export - -bzl_library( - name = "argparse_bzl", - srcs = ["argparse.bzl"], -) - -bzl_library( - name = "attrs_bzl", - srcs = ["attrs.bzl"], -) - bzl_library( - name = "config_settings_bzl", + name = "config_settings", srcs = ["config_settings.bzl"], - deps = [ - ":flags_bzl", - "//python/private:common_labels_bzl", - "//python/private:flags_bzl", - "@bazel_skylib//lib:selects", - ], + deps = ["@bazel_skylib//lib:selects"], ) bzl_library( - name = "deps_bzl", + name = "deps", srcs = ["deps.bzl"], - deps = [ - "//python/private:bazel_tools_bzl", - ], + deps = ["//python/private:bazel_tools"], ) bzl_library( - name = "env_marker_info_bzl", - srcs = ["env_marker_info.bzl"], -) - -bzl_library( - name = "env_marker_setting_bzl", + name = "env_marker_setting", srcs = ["env_marker_setting.bzl"], deps = [ - ":env_marker_info_bzl", - ":pep508_env_bzl", - ":pep508_evaluate_bzl", - "//python/private:common_labels_bzl", - "//python/private:toolchain_types_bzl", + ":env_marker_info", + ":pep508_env", + ":pep508_evaluate", + "//python/private:common_labels", + "//python/private:toolchain_types", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "evaluate_markers_bzl", + name = "evaluate_markers", srcs = ["evaluate_markers.bzl"], deps = [ - ":deps_bzl", - ":pep508_evaluate_bzl", - ":pep508_requirement_bzl", + ":pep508_evaluate", + ":pep508_requirement", ], ) +# keep bzl_library( - name = "extension_bzl", + name = "extension", srcs = ["extension.bzl"], deps = [ - ":hub_builder_bzl", - ":hub_repository_bzl", - ":parse_whl_name_bzl", - ":pep508_env_bzl", - ":pip_repository_attrs_bzl", - ":platform_bzl", - ":pypi_cache_bzl", - ":simpleapi_download_bzl", - ":unified_hub_repo_bzl", - ":whl_library_bzl", - "//python/private:auth_bzl", - "//python/private:normalize_name_bzl", - "//python/private:repo_utils_bzl", - "@bazel_features//:features", - "@pythons_hub//:interpreters_bzl", - "@pythons_hub//:versions_bzl", - "@rules_python_internal//:rules_python_config_bzl", + ":hub_builder", + ":hub_repository", + ":parse_whl_name", + ":pep508_env", + ":pip_repository_attrs", + ":platform", + ":pypi_cache", + ":simpleapi_download", + ":unified_hub_repo", + ":whl_library", + "//python/private:auth", + "//python/private:normalize_name", + "//python/private:repo_utils", + "@pythons_hub//:interpreters", + "@pythons_hub//:versions", + "@rules_python_internal//:rules_python_config", "@toml.bzl//:toml", ], ) bzl_library( - name = "flags_bzl", + name = "flags", srcs = ["flags.bzl"], deps = [ - ":env_marker_info.bzl", - ":pep508_env_bzl", + ":env_marker_info", + ":pep508_env", + "//python/private:common_labels", "@bazel_skylib//rules:common_settings", ], ) bzl_library( - name = "generate_whl_library_build_bazel_bzl", - srcs = ["generate_whl_library_build_bazel.bzl"], + name = "generate_group_library_build_bazel", + srcs = ["generate_group_library_build_bazel.bzl"], deps = [ - "//python/private:text_util_bzl", + ":labels", + "//python/private:normalize_name", + "//python/private:text_util", ], ) bzl_library( - name = "generate_group_library_build_bazel_bzl", - srcs = ["generate_group_library_build_bazel.bzl"], - deps = [ - ":labels_bzl", - "//python/private:normalize_name_bzl", - ], + name = "generate_whl_library_build_bazel", + srcs = ["generate_whl_library_build_bazel.bzl"], + deps = ["//python/private:text_util"], ) bzl_library( - name = "hub_builder_bzl", + name = "hub_builder", srcs = ["hub_builder.bzl"], - visibility = ["//:__subpackages__"], deps = [ - ":attrs_bzl", - ":parse_requirements_bzl", - ":pep508_env_bzl", - ":pep508_evaluate_bzl", - ":python_tag_bzl", - ":requirements_files_by_platform_bzl", - ":whl_config_setting_bzl", - ":whl_repo_name_bzl", - "//python/private:envsubst_bzl", - "//python/private:full_version_bzl", - "//python/private:normalize_name_bzl", - "//python/private:text_util_bzl", - "//python/private:version_bzl", - "//python/private:version_label_bzl", + ":attrs", + ":parse_requirements", + ":pep508_env", + ":pep508_evaluate", + ":python_tag", + ":requirements_files_by_platform", + ":whl_config_setting", + ":whl_repo_name", + "//python/private:envsubst", + "//python/private:full_version", + "//python/private:normalize_name", + "//python/private:repo_utils", + "//python/private:text_util", + "//python/private:version", + "//python/private:version_label", ], ) bzl_library( - name = "hub_repository_bzl", + name = "hub_repository", srcs = ["hub_repository.bzl"], - visibility = ["//:__subpackages__"], deps = [ - ":render_pkg_aliases_bzl", - "//python/private:text_util_bzl", + ":render_pkg_aliases", + ":whl_config_setting", + "//python/private:text_util", ], ) bzl_library( - name = "index_sources_bzl", - srcs = ["index_sources.bzl"], -) - -bzl_library( - name = "labels_bzl", - srcs = ["labels.bzl"], -) - -bzl_library( - name = "missing_package_bzl", + name = "missing_package", srcs = ["missing_package.bzl"], deps = [ - "//python/private:py_info_bzl", - "//python/private:reexports_bzl", + "//python/private:py_info", + "//python/private:reexports", ], ) bzl_library( - name = "multi_pip_parse_bzl", + name = "multi_pip_parse", srcs = ["multi_pip_parse.bzl"], deps = [ - ":pip_repository_bzl", - "//python/private:text_util_bzl", + ":pip_repository", + "//python/private:text_util", ], ) bzl_library( - name = "package_annotation_bzl", - srcs = ["package_annotation.bzl"], + name = "namespace_pkgs", + srcs = ["namespace_pkgs.bzl"], + deps = ["@bazel_skylib//rules:copy_file"], ) bzl_library( - name = "parse_requirements_bzl", + name = "parse_requirements", srcs = ["parse_requirements.bzl"], deps = [ - ":argparse_bzl", - ":index_sources_bzl", - ":parse_requirements_txt_bzl", - ":pep508_evaluate_bzl", - ":pep508_requirement_bzl", - ":pypi_repo_utils_bzl", - ":requirements_files_by_platform_bzl", - ":select_whl_bzl", - "//python/private:normalize_name_bzl", - "//python/private:repo_utils_bzl", - "//python/uv/private:uv_lock_to_requirements_bzl", + ":argparse", + ":index_sources", + ":parse_requirements_txt", + ":pep508_evaluate", + ":pep508_requirement", + ":select_whl", + "//python/private:normalize_name", + "//python/private:repo_utils", + "//python/uv/private:uv_lock_to_requirements", ], ) bzl_library( - name = "parse_requirements_txt_bzl", - srcs = ["parse_requirements_txt.bzl"], -) - -bzl_library( - name = "parse_simpleapi_html_bzl", + name = "parse_simpleapi_html", srcs = ["parse_simpleapi_html.bzl"], deps = [ - ":version_from_filename_bzl", - "//python/private:normalize_name_bzl", + ":version_from_filename", + "//python/private:normalize_name", ], ) bzl_library( - name = "parse_whl_name_bzl", - srcs = ["parse_whl_name.bzl"], -) - -bzl_library( - name = "patch_whl_bzl", + name = "patch_whl", srcs = ["patch_whl.bzl"], deps = [ - ":parse_whl_name_bzl", - "//python/private:repo_utils_bzl", - "@rules_python_internal//:rules_python_config_bzl", + ":parse_whl_name", + "//python/private:repo_utils", + "@rules_python_internal//:rules_python_config", ], ) bzl_library( - name = "pep508_deps_bzl", + name = "pep508_deps", srcs = ["pep508_deps.bzl"], deps = [ - ":pep508_env_bzl", - ":pep508_evaluate_bzl", - ":pep508_requirement_bzl", - "//python/private:normalize_name_bzl", + ":pep508_env", + ":pep508_evaluate", + ":pep508_requirement", + "//python/private:normalize_name", ], ) bzl_library( - name = "pep508_env_bzl", + name = "pep508_env", srcs = ["pep508_env.bzl"], deps = [ - "//python/private:normalize_name_bzl", - "//python/private:version_bzl", + "//python/private:normalize_name", + "//python/private:version", ], ) bzl_library( - name = "pep508_evaluate_bzl", + name = "pep508_evaluate", srcs = ["pep508_evaluate.bzl"], deps = [ - "//python/private:enum_bzl", - "//python/private:version_bzl", + "//python/private:enum", + "//python/private:version", ], ) bzl_library( - name = "pep508_requirement_bzl", + name = "pep508_requirement", srcs = ["pep508_requirement.bzl"], - deps = [ - "//python/private:normalize_name_bzl", - ], + deps = ["//python/private:normalize_name"], ) bzl_library( - name = "pip_bzl", + name = "pip", srcs = ["pip.bzl"], - deps = [ - ":extension_bzl", - ], + deps = [":extension"], ) bzl_library( - name = "pip_compile_bzl", + name = "pip_compile", srcs = ["pip_compile.bzl"], deps = [ - ":deps_bzl", - "//python:py_binary_bzl", - "//python:py_test_bzl", + "//python:py_binary", + "//python:py_test", ], ) bzl_library( - name = "pip_repository_bzl", + name = "pip_repository", srcs = ["pip_repository.bzl"], deps = [ - ":attrs_bzl", - ":parse_requirements_bzl", - ":pep508_env_bzl", - ":pip_repository_attrs_bzl", - ":pypi_repo_utils_bzl", - ":render_pkg_aliases_bzl", - ":whl_config_setting_bzl", - "//python/private:normalize_name_bzl", - "//python/private:repo_utils_bzl", - "//python/private:text_util_bzl", + ":parse_requirements", + ":pep508_env", + ":pip_repository_attrs", + ":pypi_repo_utils", + ":render_pkg_aliases", + ":requirements_files_by_platform", + "//python/private:normalize_name", + "//python/private:repo_utils", + "//python/private:text_util", "@bazel_skylib//lib:sets", ], ) bzl_library( - name = "pip_repository_attrs_bzl", + name = "pip_repository_attrs", srcs = ["pip_repository_attrs.bzl"], + deps = [":attrs"], ) bzl_library( - name = "pkg_aliases_bzl", + name = "pkg_aliases", srcs = ["pkg_aliases.bzl"], deps = [ - ":labels_bzl", - "//python/private:common_labels_bzl", - "//python/private:text_util_bzl", + ":labels", + "//python/private:common_labels", + "//python/private:text_util", "@bazel_skylib//lib:selects", ], ) bzl_library( - name = "platform_bzl", - srcs = ["platform.bzl"], -) - -bzl_library( - name = "pypi_cache_bzl", + name = "pypi_cache", srcs = ["pypi_cache.bzl"], - deps = [ - ":version_from_filename_bzl", - ], + deps = [":version_from_filename"], ) bzl_library( - name = "pypi_repo_utils_bzl", + name = "pypi_repo_utils", srcs = ["pypi_repo_utils.bzl"], deps = [ - "//python/private:repo_utils_bzl", + "//python/private:repo_utils", + "//python/private:util", "@bazel_skylib//lib:types", ], ) bzl_library( - name = "python_tag_bzl", + name = "python_tag", srcs = ["python_tag.bzl"], - deps = [ - "//python/private:version_bzl", - ], + deps = ["//python/private:version"], ) bzl_library( - name = "render_pkg_aliases_bzl", + name = "render_pkg_aliases", srcs = ["render_pkg_aliases.bzl"], deps = [ - ":generate_group_library_build_bazel_bzl", - ":whl_config_setting_bzl", - "//python/private:normalize_name_bzl", - "//python/private:text_util_bzl", + ":generate_group_library_build_bazel", + "//python/private:normalize_name", + "//python/private:text_util", ], ) bzl_library( - name = "requirements_files_by_platform_bzl", + name = "requirements_files_by_platform", srcs = ["requirements_files_by_platform.bzl"], deps = [ - ":argparse_bzl", - ":whl_target_platforms_bzl", + ":argparse", + ":whl_target_platforms", ], ) bzl_library( - name = "select_whl_bzl", + name = "select_whl", srcs = ["select_whl.bzl"], deps = [ - ":parse_whl_name_bzl", - ":python_tag_bzl", - "//python/private:version_bzl", + ":parse_whl_name", + ":python_tag", + "//python/private:version", ], ) bzl_library( - name = "simpleapi_download_bzl", + name = "simpleapi_download", srcs = ["simpleapi_download.bzl"], deps = [ - ":parse_simpleapi_html_bzl", - ":urllib_bzl", - "//python/private:auth_bzl", - "//python/private:normalize_name_bzl", + ":parse_simpleapi_html", + ":urllib", + "//python/private:auth", + "//python/private:envsubst", + "//python/private:normalize_name", ], ) bzl_library( - name = "unified_hub_repo_bzl", + name = "unified_hub_repo", srcs = ["unified_hub_repo.bzl"], - deps = [ - "//python/private:text_util_bzl", - ], + deps = ["//python/private:text_util"], ) bzl_library( - name = "unified_hub_setup_bzl", + name = "unified_hub_setup", srcs = ["unified_hub_setup.bzl"], deps = [ - ":labels_bzl", - ":missing_package_bzl", + "@rules_python//python/private/pypi:labels", + "@rules_python//python/private/pypi:missing_package", ], ) bzl_library( - name = "urllib_bzl", - srcs = ["urllib.bzl"], + name = "venv_entry_point", + srcs = ["venv_entry_point.bzl"], + deps = [ + "//python/private:attributes", + "//python/private:common", + "//python/private:rule_builders", + ], ) bzl_library( - name = "version_from_filename_bzl", - srcs = ["version_from_filename.bzl"], + name = "venv_rewrite_shebang", + srcs = ["venv_rewrite_shebang.bzl"], + deps = [ + "//python/private:attributes", + "//python/private:common", + "//python/private:py_info", + "//python/private:rule_builders", + ], ) bzl_library( - name = "whl_config_repo_bzl", + name = "whl_config_repo", srcs = ["whl_config_repo.bzl"], deps = [ - ":generate_group_library_build_bazel_bzl", - "//python/private:text_util_bzl", + ":generate_group_library_build_bazel", + "//python/private:text_util", ], ) bzl_library( - name = "whl_config_setting_bzl", - srcs = ["whl_config_setting.bzl"], + name = "whl_extract", + srcs = ["whl_extract.bzl"], + deps = [ + ":whl_metadata", + "//python/private:repo_utils", + "@rules_python_internal//:rules_python_config", + ], ) bzl_library( - name = "whl_extract_bzl", - srcs = ["whl_extract.bzl"], + name = "whl_library", + srcs = ["whl_library.bzl"], deps = [ - ":whl_metadata_bzl", - "//python/private:repo_utils_bzl", - "@rules_python_internal//:rules_python_config_bzl", + ":attrs", + ":deps", + ":generate_whl_library_build_bazel", + ":patch_whl", + ":pep508_requirement", + ":pypi_repo_utils", + ":urllib", + ":whl_extract", + ":whl_metadata", + "//python/private:auth", + "//python/private:envsubst", + "//python/private:is_standalone_interpreter", + "//python/private:normalize_name", + "//python/private:repo_utils", ], ) bzl_library( - name = "whl_library_alias_bzl", + name = "whl_library_alias", srcs = ["whl_library_alias.bzl"], deps = [ - ":render_pkg_aliases_bzl", - "//python/private:full_version_bzl", + ":render_pkg_aliases", + "//python/private:full_version", ], ) bzl_library( - name = "whl_library_bzl", - srcs = ["whl_library.bzl"], + name = "whl_library_targets", + srcs = ["whl_library_targets.bzl"], deps = [ - ":attrs_bzl", - ":deps_bzl", - ":generate_whl_library_build_bazel_bzl", - ":parse_whl_name_bzl", - ":patch_whl_bzl", - ":pep508_requirement_bzl", - ":pypi_repo_utils_bzl", - ":urllib_bzl", - ":whl_extract_bzl", - ":whl_metadata_bzl", - ":whl_target_platforms_bzl", - "//python/private:auth_bzl", - "//python/private:envsubst_bzl", - "//python/private:is_standalone_interpreter_bzl", - "//python/private:normalize_name_bzl", - "//python/private:repo_utils_bzl", - "//python/private:util_bzl", - "@rules_python_internal//:rules_python_config_bzl", - ], -) - -bzl_library( - name = "whl_metadata_bzl", - srcs = ["whl_metadata.bzl"], + ":env_marker_setting", + ":labels", + ":namespace_pkgs", + ":pep508_deps", + ":venv_entry_point", + ":venv_rewrite_shebang", + "//python:py_binary", + "//python:py_library", + "//python/private:normalize_name", + "@bazel_skylib//rules:copy_file", + ], ) bzl_library( - name = "whl_repo_name_bzl", + name = "whl_repo_name", srcs = ["whl_repo_name.bzl"], deps = [ - ":parse_whl_name_bzl", - "//python/private:normalize_name_bzl", + ":parse_whl_name", + "//python/private:normalize_name", ], ) bzl_library( - name = "whl_target_platforms_bzl", - srcs = ["whl_target_platforms.bzl"], + name = "argparse", + srcs = ["argparse.bzl"], ) bzl_library( - name = "venv_entry_point_bzl", - srcs = ["venv_entry_point.bzl"], - visibility = ["//visibility:public"], + name = "attrs", + srcs = ["attrs.bzl"], ) bzl_library( - name = "venv_rewrite_shebang_bzl", - srcs = ["venv_rewrite_shebang.bzl"], - visibility = ["//visibility:public"], + name = "env_marker_info", + srcs = ["env_marker_info.bzl"], +) + +bzl_library( + name = "index_sources", + srcs = ["index_sources.bzl"], +) + +bzl_library( + name = "labels", + srcs = ["labels.bzl"], +) + +bzl_library( + name = "package_annotation", + srcs = ["package_annotation.bzl"], +) + +bzl_library( + name = "parse_requirements_txt", + srcs = ["parse_requirements_txt.bzl"], +) + +bzl_library( + name = "parse_whl_name", + srcs = ["parse_whl_name.bzl"], +) + +bzl_library( + name = "platform", + srcs = ["platform.bzl"], +) + +bzl_library( + name = "urllib", + srcs = ["urllib.bzl"], +) + +bzl_library( + name = "version_from_filename", + srcs = ["version_from_filename.bzl"], +) + +bzl_library( + name = "whl_config_setting", + srcs = ["whl_config_setting.bzl"], +) + +bzl_library( + name = "whl_metadata", + srcs = ["whl_metadata.bzl"], +) + +bzl_library( + name = "whl_target_platforms", + srcs = ["whl_target_platforms.bzl"], ) diff --git a/python/private/pythons_hub.bzl b/python/private/pythons_hub.bzl index 173c811da6..c64abf9887 100644 --- a/python/private/pythons_hub.bzl +++ b/python/private/pythons_hub.bzl @@ -31,13 +31,13 @@ load("@@{rules_python}//python/private:py_toolchain_suite.bzl", "py_toolchain_su load("@bazel_skylib//:bzl_library.bzl", "bzl_library") bzl_library( - name = "interpreters_bzl", + name = "interpreters", srcs = ["interpreters.bzl"], visibility = ["@rules_python//:__subpackages__"], ) bzl_library( - name = "versions_bzl", + name = "versions", srcs = ["versions.bzl"], visibility = ["@rules_python//:__subpackages__"], ) diff --git a/python/private/sentinel.bzl b/python/private/sentinel_impl.bzl similarity index 100% rename from python/private/sentinel.bzl rename to python/private/sentinel_impl.bzl diff --git a/python/private/stamp.bzl b/python/private/stamp_impl.bzl similarity index 100% rename from python/private/stamp.bzl rename to python/private/stamp_impl.bzl diff --git a/python/private/whl_filegroup/BUILD.bazel b/python/private/whl_filegroup/BUILD.bazel index b4246ca080..4d0a096311 100644 --- a/python/private/whl_filegroup/BUILD.bazel +++ b/python/private/whl_filegroup/BUILD.bazel @@ -7,14 +7,14 @@ filegroup( visibility = ["//python/private:__pkg__"], ) -bzl_library( - name = "whl_filegroup_bzl", - srcs = ["whl_filegroup.bzl"], - visibility = ["//:__subpackages__"], -) - py_binary( name = "extract_wheel_files", srcs = ["extract_wheel_files.py"], visibility = ["//visibility:public"], ) + +bzl_library( + name = "whl_filegroup", + srcs = ["whl_filegroup.bzl"], + visibility = ["//python:__subpackages__"], +) diff --git a/python/private/zipapp/BUILD.bazel b/python/private/zipapp/BUILD.bazel index 543fe0a185..66f0b4f667 100644 --- a/python/private/zipapp/BUILD.bazel +++ b/python/private/zipapp/BUILD.bazel @@ -11,25 +11,6 @@ filegroup( srcs = glob(["**"]), ) -bzl_library( - name = "py_zipapp_rule_bzl", - srcs = ["py_zipapp_rule.bzl"], - deps = [ - "//python/private:attributes_bzl", - "//python/private:builders_bzl", - "//python/private:common_bzl", - "//python/private:common_labels_bzl", - "//python/private:py_executable_info_bzl", - "//python/private:py_info_bzl", - "//python/private:py_internal_bzl", - "//python/private:py_interpreter_program_bzl", - "//python/private:py_runtime_info_bzl", - "//python/private:toolchain_types_bzl", - "//python/private:transition_labels_bzl", - "@bazel_skylib//lib:paths", - ], -) - filegroup( name = "zip_main_template", srcs = ["zip_main_template.py"], @@ -47,3 +28,21 @@ filegroup( srcs = ["zipapp_stage2_bootstrap_template.py"], visibility = ["//visibility:public"], ) + +bzl_library( + name = "py_zipapp_rule", + srcs = ["py_zipapp_rule.bzl"], + deps = [ + "//python/private:attributes", + "//python/private:builders", + "//python/private:common", + "//python/private:common_labels", + "//python/private:py_executable_info", + "//python/private:py_internal", + "//python/private:py_runtime_info", + "//python/private:toolchain_types", + "//python/private:transition_labels", + "@bazel_skylib//lib:paths", + "@rules_python_internal//:rules_python_config", + ], +) diff --git a/python/uv/BUILD.bazel b/python/uv/BUILD.bazel index 7ce6ce0523..93aefbfbd7 100644 --- a/python/uv/BUILD.bazel +++ b/python/uv/BUILD.bazel @@ -45,33 +45,25 @@ current_toolchain( ) bzl_library( - name = "lock_bzl", + name = "lock", srcs = ["lock.bzl"], - # EXPERIMENTAL: Visibility is restricted to allow for changes. - visibility = ["//:__subpackages__"], - deps = ["//python/uv/private:lock_bzl"], + deps = ["//python/uv/private:lock"], ) bzl_library( - name = "uv_bzl", + name = "uv", srcs = ["uv.bzl"], - # EXPERIMENTAL: Visibility is restricted to allow for changes. - visibility = ["//:__subpackages__"], - deps = ["//python/uv/private:uv_bzl"], + deps = ["//python/uv/private:uv"], ) bzl_library( - name = "uv_toolchain_bzl", + name = "uv_toolchain", srcs = ["uv_toolchain.bzl"], - # EXPERIMENTAL: Visibility is restricted to allow for changes. - visibility = ["//:__subpackages__"], - deps = ["//python/uv/private:uv_toolchain_bzl"], + deps = ["//python/uv/private:uv_toolchain"], ) bzl_library( - name = "uv_toolchain_info_bzl", + name = "uv_toolchain_info", srcs = ["uv_toolchain_info.bzl"], - # EXPERIMENTAL: Visibility is restricted to allow for changes. - visibility = ["//:__subpackages__"], - deps = ["//python/uv/private:uv_toolchain_info_bzl"], + deps = ["//python/uv/private:uv_toolchain_info"], ) diff --git a/python/uv/private/BUILD.bazel b/python/uv/private/BUILD.bazel index 4ac75f4d45..ce0ce0ec81 100644 --- a/python/uv/private/BUILD.bazel +++ b/python/uv/private/BUILD.bazel @@ -24,106 +24,117 @@ filegroup( visibility = ["//python/uv:__pkg__"], ) +filegroup( + name = "lock_copier_template", + srcs = ["template/lock_copier.py"], + target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], + visibility = _NOT_REALLY_PUBLIC, +) + +filegroup( + name = "uv_lock_template", + srcs = select({ + "@platforms//os:windows": ["template/uv_lock.bat"], + "//conditions:default": ["template/uv_lock.sh"], + }), + target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], + visibility = _NOT_REALLY_PUBLIC, +) + +filegroup( + name = "uv_pip_compile_template", + srcs = select({ + "@platforms//os:windows": ["template/uv_pip_compile.bat"], + "//conditions:default": ["template/uv_pip_compile.sh"], + }), + target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], + visibility = _NOT_REALLY_PUBLIC, +) + bzl_library( - name = "current_toolchain_bzl", + name = "current_toolchain", srcs = ["current_toolchain.bzl"], visibility = ["//python/uv:__subpackages__"], + deps = [":toolchain_types"], ) +# keep bzl_library( - name = "lock_bzl", + name = "lock", srcs = ["lock.bzl"], - visibility = ["//python/uv:__subpackages__"], + visibility = [ + "//python/uv:__subpackages__", + "//tools/private:__subpackages__", + ], deps = [ - ":toolchain_types_bzl", - "//python:py_binary_bzl", - "//python/private:bzlmod_enabled_bzl", - "//python/private:common_labels_bzl", - "//python/private:toolchain_types_bzl", + ":toolchain_types", + "//python:py_binary", + "//python/private:bzlmod_enabled", + "//python/private:common_labels", + "//python/private:toolchain_types", "@bazel_skylib//lib:shell", ], ) bzl_library( - name = "toolchain_types_bzl", - srcs = ["toolchain_types.bzl"], + name = "toolchains_hub", + srcs = ["toolchains_hub.bzl"], visibility = ["//python/uv:__subpackages__"], + deps = [":toolchain_types"], ) bzl_library( - name = "uv_bzl", + name = "uv", srcs = ["uv.bzl"], visibility = ["//python/uv:__subpackages__"], deps = [ - ":toolchain_types_bzl", - ":uv_repository_bzl", - ":uv_toolchains_repo_bzl", - "//python/private:auth_bzl", - "//python/private:common_labels_bzl", - ], -) - -bzl_library( - name = "uv_lock_to_requirements_bzl", - srcs = ["uv_lock_to_requirements.bzl"], - visibility = [ - "//python/private:__subpackages__", - "//python/uv:__subpackages__", + ":toolchain_types", + ":uv_repository", + ":uv_toolchains_repo", + "//python/private:auth", + "//python/private:common_labels", ], ) bzl_library( - name = "uv_repository_bzl", + name = "uv_repository", srcs = ["uv_repository.bzl"], visibility = ["//python/uv:__subpackages__"], - deps = ["//python/private:auth_bzl"], + deps = ["//python/private:auth"], ) bzl_library( - name = "uv_toolchain_bzl", + name = "uv_toolchain", srcs = ["uv_toolchain.bzl"], visibility = ["//python/uv:__subpackages__"], - deps = [":uv_toolchain_info_bzl"], + deps = [":uv_toolchain_info"], ) bzl_library( - name = "uv_toolchain_info_bzl", - srcs = ["uv_toolchain_info.bzl"], + name = "uv_toolchains_repo", + srcs = ["uv_toolchains_repo.bzl"], visibility = ["//python/uv:__subpackages__"], + deps = ["//python/private:text_util"], ) +# keep bzl_library( - name = "uv_toolchains_repo_bzl", - srcs = ["uv_toolchains_repo.bzl"], - visibility = ["//python/uv:__subpackages__"], - deps = [ - "//python/private:text_util_bzl", + name = "uv_lock_to_requirements", + srcs = ["uv_lock_to_requirements.bzl"], + visibility = [ + "//python/private:__subpackages__", + "//python/uv:__subpackages__", ], ) -filegroup( - name = "lock_copier_template", - srcs = ["template/lock_copier.py"], - target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], - visibility = _NOT_REALLY_PUBLIC, -) - -filegroup( - name = "uv_lock_template", - srcs = select({ - "@platforms//os:windows": ["template/uv_lock.bat"], - "//conditions:default": ["template/uv_lock.sh"], - }), - target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], - visibility = _NOT_REALLY_PUBLIC, +bzl_library( + name = "toolchain_types", + srcs = ["toolchain_types.bzl"], + visibility = ["//python/uv:__subpackages__"], ) -filegroup( - name = "uv_pip_compile_template", - srcs = select({ - "@platforms//os:windows": ["template/uv_pip_compile.bat"], - "//conditions:default": ["template/uv_pip_compile.sh"], - }), - target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"], - visibility = _NOT_REALLY_PUBLIC, +bzl_library( + name = "uv_toolchain_info", + srcs = ["uv_toolchain_info.bzl"], + visibility = ["//python/uv:__subpackages__"], ) diff --git a/python/zipapp/BUILD.bazel b/python/zipapp/BUILD.bazel index 71249ae45c..77a8752701 100644 --- a/python/zipapp/BUILD.bazel +++ b/python/zipapp/BUILD.bazel @@ -11,19 +11,20 @@ filegroup( ) bzl_library( - name = "py_zipapp_binary_bzl", + name = "py_zipapp_binary", srcs = ["py_zipapp_binary.bzl"], deps = [ - "//python/private:util_bzl", - "//python/private/zipapp:py_zipapp_rule_bzl", + "//python/private:util", + "//python/private/zipapp:py_zipapp_rule", ], ) +# keep bzl_library( - name = "py_zipapp_test_bzl", + name = "py_zipapp_test", srcs = ["py_zipapp_test.bzl"], deps = [ - "//python/private:util_bzl", - "//python/private/zipapp:py_zipapp_rule_bzl", + "//python/private:util", + "//python/private/zipapp:py_zipapp_rule", ], ) diff --git a/sphinxdocs/docs/BUILD.bazel b/sphinxdocs/docs/BUILD.bazel index 87771f14f1..58c9e7fc18 100644 --- a/sphinxdocs/docs/BUILD.bazel +++ b/sphinxdocs/docs/BUILD.bazel @@ -48,11 +48,11 @@ sphinx_docs_library( sphinx_stardocs( name = "bzl_docs", srcs = [ - "//sphinxdocs:readthedocs_bzl", - "//sphinxdocs:sphinx_bzl", - "//sphinxdocs:sphinx_docs_library_bzl", - "//sphinxdocs:sphinx_stardoc_bzl", - "//sphinxdocs/private:sphinx_docs_library_bzl", + "//sphinxdocs:readthedocs", + "//sphinxdocs:sphinx", + "//sphinxdocs:sphinx_docs_library", + "//sphinxdocs:sphinx_stardoc", + "//sphinxdocs/private:sphinx_docs_library", ], prefix = "api/sphinxdocs/", target_compatible_with = _TARGET_COMPATIBLE_WITH, diff --git a/sphinxdocs/sphinxdocs/BUILD.bazel b/sphinxdocs/sphinxdocs/BUILD.bazel index 5a498b197e..fbdd633a32 100644 --- a/sphinxdocs/sphinxdocs/BUILD.bazel +++ b/sphinxdocs/sphinxdocs/BUILD.bazel @@ -42,29 +42,55 @@ bool_flag( ) bzl_library( - name = "sphinx_bzl", + name = "sphinx", srcs = ["sphinx.bzl"], visibility = ["//visibility:public"], - deps = ["//sphinxdocs/private:sphinx_bzl"], + deps = ["//sphinxdocs/private:sphinx"], ) bzl_library( - name = "sphinx_docs_library_bzl", + name = "sphinx_docs_library", srcs = ["sphinx_docs_library.bzl"], visibility = ["//visibility:public"], - deps = ["//sphinxdocs/private:sphinx_docs_library_macro_bzl"], + deps = ["//sphinxdocs/private:sphinx_docs_library_macro"], ) bzl_library( - name = "sphinx_stardoc_bzl", + name = "sphinx_stardoc", srcs = ["sphinx_stardoc.bzl"], visibility = ["//visibility:public"], - deps = ["//sphinxdocs/private:sphinx_stardoc_bzl"], + deps = ["//sphinxdocs/private:sphinx_stardoc"], ) bzl_library( - name = "readthedocs_bzl", + name = "readthedocs", srcs = ["readthedocs.bzl"], visibility = ["//visibility:public"], - deps = ["//sphinxdocs/private:readthedocs_bzl"], + deps = ["//sphinxdocs/private:readthedocs"], +) + +# ========= Deprecated aliases for backwards compatibility ========= + +alias( + name = "sphinx_bzl", + actual = ":sphinx", + deprecation = "Use //sphinxdocs:sphinx instead", +) + +alias( + name = "sphinx_docs_library_bzl", + actual = ":sphinx_docs_library", + deprecation = "Use //sphinxdocs:sphinx_docs_library instead", +) + +alias( + name = "sphinx_stardoc_bzl", + actual = ":sphinx_stardoc", + deprecation = "Use //sphinxdocs:sphinx_stardoc instead", +) + +alias( + name = "readthedocs_bzl", + actual = ":readthedocs", + deprecation = "Use //sphinxdocs:readthedocs instead", ) diff --git a/sphinxdocs/sphinxdocs/private/BUILD.bazel b/sphinxdocs/sphinxdocs/private/BUILD.bazel index 785d2e074d..b054ed2611 100644 --- a/sphinxdocs/sphinxdocs/private/BUILD.bazel +++ b/sphinxdocs/sphinxdocs/private/BUILD.bazel @@ -35,7 +35,7 @@ exports_files( ) bzl_library( - name = "util_bzl", + name = "util", srcs = ["util.bzl"], deps = [ "@bazel_skylib//lib:types", @@ -43,31 +43,31 @@ bzl_library( ) bzl_library( - name = "sphinx_docs_library_macro_bzl", + name = "sphinx_docs_library_macro", srcs = ["sphinx_docs_library_macro.bzl"], deps = [ - ":sphinx_docs_library_bzl", - "//sphinxdocs/private:util_bzl", + ":sphinx_docs_library", + ":util", ], ) bzl_library( - name = "sphinx_docs_library_bzl", + name = "sphinx_docs_library", srcs = ["sphinx_docs_library.bzl"], - deps = [":sphinx_docs_library_info_bzl"], + deps = [":sphinx_docs_library_info"], ) bzl_library( - name = "sphinx_docs_library_info_bzl", + name = "sphinx_docs_library_info", srcs = ["sphinx_docs_library_info.bzl"], ) bzl_library( - name = "sphinx_bzl", + name = "sphinx", srcs = ["sphinx.bzl"], deps = [ - ":sphinx_docs_library_info_bzl", - ":util_bzl", + ":sphinx_docs_library_info", + ":util", "@bazel_skylib//:bzl_library", "@bazel_skylib//lib:paths", "@bazel_skylib//lib:types", @@ -79,12 +79,12 @@ bzl_library( ) bzl_library( - name = "sphinx_stardoc_bzl", + name = "sphinx_stardoc", srcs = ["sphinx_stardoc.bzl"], deps = [ - ":sphinx_docs_library_macro_bzl", - "//sphinxdocs:sphinx_bzl", - "//sphinxdocs/private:util_bzl", + ":sphinx_docs_library_macro", + ":util", + "//sphinxdocs:sphinx", "@bazel_skylib//:bzl_library", "@bazel_skylib//lib:paths", "@bazel_skylib//lib:types", @@ -94,10 +94,10 @@ bzl_library( ) bzl_library( - name = "readthedocs_bzl", + name = "readthedocs", srcs = ["readthedocs.bzl"], deps = [ - ":util_bzl", + ":util", "@rules_python//python:py_binary_bzl", ], ) diff --git a/sphinxdocs/sphinxdocs/private/sphinx_stardoc.bzl b/sphinxdocs/sphinxdocs/private/sphinx_stardoc.bzl index ac76219299..75a4daf4d0 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx_stardoc.bzl +++ b/sphinxdocs/sphinxdocs/private/sphinx_stardoc.bzl @@ -171,7 +171,7 @@ def sphinx_stardoc( **common_kwargs ) - stardoc_name = internal_name + "_stardoc" + stardoc_name = internal_name + "__stardoc" # NOTE: The .binaryproto suffix is an optimization. It makes the stardoc() # call avoid performing a copy of the output to the desired name. @@ -186,7 +186,7 @@ def sphinx_stardoc( **common_kwargs ) - pb2md_name = internal_name + "_pb2md" + pb2md_name = internal_name + "__pb2md" _stardoc_proto_to_markdown( name = pb2md_name, src = stardoc_pb, diff --git a/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel index a5d402b809..2cbc773f77 100644 --- a/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel +++ b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel @@ -69,7 +69,6 @@ sphinx_stardoc( deps = [":func_and_providers_bzl"], ) -# A bzl_library with multiple sources bzl_library( name = "func_and_providers_bzl", srcs = [ @@ -89,6 +88,7 @@ bzl_library( srcs = ["bzl_typedef.bzl"], ) +# A bzl_library with multiple sources sphinx_build_binary( name = "sphinx-build", tags = ["manual"], # Only needed as part of sphinx doc building diff --git a/tests/config_settings/transition/BUILD.bazel b/tests/config_settings/transition/BUILD.bazel index 19d4958669..093de50959 100644 --- a/tests/config_settings/transition/BUILD.bazel +++ b/tests/config_settings/transition/BUILD.bazel @@ -1,6 +1,3 @@ load(":multi_version_tests.bzl", "multi_version_test_suite") -load(":py_args_tests.bzl", "py_args_test_suite") - -py_args_test_suite(name = "py_args_tests") multi_version_test_suite(name = "multi_version_tests") diff --git a/tests/config_settings/transition/py_args_tests.bzl b/tests/config_settings/transition/py_args_tests.bzl deleted file mode 100644 index 4538c88a5c..0000000000 --- a/tests/config_settings/transition/py_args_tests.bzl +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2023 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. - -"" - -load("@rules_testing//lib:test_suite.bzl", "test_suite") -load("//python/config_settings/private:py_args.bzl", "py_args") # buildifier: disable=bzl-visibility - -_tests = [] - -def _test_py_args_default(env): - actual = py_args("foo", {}) - - want = { - "args": None, - "data": None, - "deps": None, - "env": None, - "main": "foo.py", - "srcs": None, - } - env.expect.that_dict(actual).contains_exactly(want) - -_tests.append(_test_py_args_default) - -def _test_kwargs_get_consumed(env): - kwargs = { - "args": ["some", "args"], - "data": ["data"], - "deps": ["deps"], - "env": {"key": "value"}, - "main": "__main__.py", - "srcs": ["__main__.py"], - "visibility": ["//visibility:public"], - } - actual = py_args("bar_bin", kwargs) - - want = { - "args": ["some", "args"], - "data": ["data"], - "deps": ["deps"], - "env": {"key": "value"}, - "main": "__main__.py", - "srcs": ["__main__.py"], - } - env.expect.that_dict(actual).contains_exactly(want) - env.expect.that_dict(kwargs).keys().contains_exactly(["visibility"]) - -_tests.append(_test_kwargs_get_consumed) - -def py_args_test_suite(name): - """Create the test suite. - - Args: - name: the name of the test suite - """ - test_suite(name = name, basic_tests = _tests) diff --git a/tools/build_defs/python/private/BUILD.bazel b/tools/build_defs/python/private/BUILD.bazel index 746545640d..e189b18880 100644 --- a/tools/build_defs/python/private/BUILD.bazel +++ b/tools/build_defs/python/private/BUILD.bazel @@ -20,8 +20,9 @@ filegroup( visibility = ["//python:__subpackages__"], ) +# keep bzl_library( - name = "py_internal_renamed_bzl", + name = "py_internal_renamed", srcs = ["py_internal_renamed.bzl"], - visibility = ["//python/private:__pkg__"], + visibility = ["//:__subpackages__"], ) diff --git a/tools/private/BUILD.bazel b/tools/private/BUILD.bazel index adc8de3b0f..ae6951c245 100644 --- a/tools/private/BUILD.bazel +++ b/tools/private/BUILD.bazel @@ -1,3 +1,5 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") + package( default_visibility = ["//:__subpackages__"], ) @@ -8,3 +10,9 @@ filegroup( "//tools/private/zipapp:distribution", ], ) + +bzl_library( + name = "publish_deps", + srcs = ["publish_deps.bzl"], + deps = ["//python/uv/private:lock"], +) diff --git a/tools/private/gazelle/BUILD.bazel b/tools/private/gazelle/BUILD.bazel new file mode 100644 index 0000000000..2a38e8f3a1 --- /dev/null +++ b/tools/private/gazelle/BUILD.bazel @@ -0,0 +1,21 @@ +load("@bazel_gazelle//:def.bzl", "gazelle", "gazelle_binary") + +package( + default_visibility = ["//:__subpackages__"], +) + +gazelle_binary( + name = "gazelle_bin", + languages = [ + "@bazel_skylib_gazelle_plugin//bzl", + ], + # Marked manual to avoid building in CI, which fails due to copt settings. + tags = ["manual"], +) + +gazelle( + name = "gazelle", + gazelle = ":gazelle_bin", + # Marked manual to avoid building in CI, which fails due to copt settings. + tags = ["manual"], +) diff --git a/tools/private/update_deps/BUILD.bazel b/tools/private/update_deps/BUILD.bazel index beecf82189..c3f94c9814 100644 --- a/tools/private/update_deps/BUILD.bazel +++ b/tools/private/update_deps/BUILD.bazel @@ -34,10 +34,10 @@ py_binary( name = "update_coverage_deps", srcs = ["update_coverage_deps.py"], data = [ - "//python/private:coverage_deps", + "//python/private:coverage_deps_filegroup", ], env = { - "UPDATE_FILE": "$(rlocationpath //python/private:coverage_deps)", + "UPDATE_FILE": "$(rlocationpath //python/private:coverage_deps_filegroup)", }, imports = ["../../.."], deps = [ diff --git a/workspace_bazel9.bzl b/workspace_bazel9.bzl new file mode 100644 index 0000000000..dad59378a9 --- /dev/null +++ b/workspace_bazel9.bzl @@ -0,0 +1,10 @@ +"""Workaround for Bazel 9 duplicate name issue in Gazelle.""" + +def bazel_9_workaround(name = "bazel_9_workaround"): + # Necessary so that Bazel 9 recognizes this as rules_python and doesn't try + # to load the version Bazel itself uses by default. + # We hide this from Gazelle's WORKSPACE parser by putting it in a macro. + native.local_repository( + name = "rules_python", + path = ".", + ) From 959a103070bea87c5b19d24da06371955c9db9fb Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 28 Jun 2026 01:36:29 -0700 Subject: [PATCH 033/179] fix(ci): use --repo flag in gh pr diff to avoid checkout (#3861) The pr-metadata-checks workflow was failing because gh pr diff requires a git repository context when run without target repository information. We now explicitly pass the repository using the --repo flag, allowing the job to run successfully without needing a full repository checkout. --- .github/workflows/pr-metadata-checks.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-metadata-checks.yaml b/.github/workflows/pr-metadata-checks.yaml index 003b4d99a5..e49ed14810 100644 --- a/.github/workflows/pr-metadata-checks.yaml +++ b/.github/workflows/pr-metadata-checks.yaml @@ -44,7 +44,9 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | echo "Checking for blocked files..." - changed_files=$(gh pr diff "$PR_NUMBER" --name-only) + # We use --repo to avoid needing actions/checkout. + # This keeps the job lightweight and avoids "not a git repository" errors. + changed_files=$(gh pr diff "$PR_NUMBER" --name-only --repo "${{ github.repository }}") blocked_files=$(echo "$changed_files" | grep -E '^.agents/(plans|scratch)(/|$)' || true) if [ -n "$blocked_files" ]; then echo "::error::Files in .agents/plans and" \ From d37c64951d8ca7418c8680e2fd9157a862f9a925 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:41:50 +0900 Subject: [PATCH 034/179] feat: stop using chmod on Bazel 8.6+ (#3855) ## Summary Add an `extract_needs_chmod` config flag that controls whether `chmod` is run after extracting wheel files. The flag is `False` for Bazel >= 8.6 (where the fix for file permissions is built into Bazel) and `True` for older versions. Move `_maybe_fix_permissions` from `whl_extract.bzl` into `repo_utils.bzl` and have `_extract` call it conditionally based on the config flag. This ensures both `whl_extract` and `patch_whl` benefit from the chmod fix when needed. Whilst at it migrate tests to common mocks. Fixes #3585 Closes #3860 --- .agents/plans/pypi_hub_proxy_feature.md | 296 ------------------------ python/private/internal_config_repo.bzl | 3 + python/private/pypi/patch_whl.bzl | 1 + python/private/pypi/whl_extract.bzl | 24 +- python/private/repo_utils.bzl | 25 +- tests/repo_utils/repo_utils_test.bzl | 75 +++++- tests/support/mocks.bzl | 31 --- tests/support/mocks/mocks.bzl | 11 +- 8 files changed, 111 insertions(+), 355 deletions(-) delete mode 100644 .agents/plans/pypi_hub_proxy_feature.md delete mode 100644 tests/support/mocks.bzl diff --git a/.agents/plans/pypi_hub_proxy_feature.md b/.agents/plans/pypi_hub_proxy_feature.md deleted file mode 100644 index b3360d9dbd..0000000000 --- a/.agents/plans/pypi_hub_proxy_feature.md +++ /dev/null @@ -1,296 +0,0 @@ -# Implementation Plan: Canonical Automatic PyPI Proxy Hub - -This document defines the locked, production-ready architectural, Starlark API, -and testing specifications for implementing dynamic PyPI dependency resolution in -`rules_python` using the `venv` flag. - ---- - -## 1. Architectural Strategy: The Canonical `@pypi` Proxy - -The `pip` bzlmod extension will automatically synthesize a canonical `@pypi` -proxy repository rule that orchestrates routing to underlying concrete hubs. - -### Bzlmod-Exclusive Scope - -The Unified PyPI Hub Proxy is an **exclusive feature of `bzlmod`**. Legacy -`WORKSPACE` evaluations using independent `pip_parse` repository macros are not -supported, as bzlmod's module extension architecture provides the required -centralized coordination to inspect and interlink cross-module hubs. - -### Automatic Proxy Construction & Collision Logic - -During the evaluation of the `pip` extension across the dependency graph: -1. **Unconditional Creation**: The extension will **always** synthesize a - proxy repository rule with the apparent name `pypi`, even if zero - `pip.parse` concrete hubs are defined in the dependency graph (in which - case the proxy is completely valid but empty). -2. **Collision Prevention**: If a user explicitly defines a concrete hub - named `pypi` (`pip.parse(hub_name = "pypi")`), the automatic proxy - synthesis is skipped so the user maintains absolute control over that - repository name. - -In `MODULE.bazel`: -```starlark -pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") - -# Concrete hubs defined for different execution contexts -pip.parse(hub_name = "pypi_a", ...) -pip.parse(hub_name = "pypi_b", ...) - -# Designate 'pypi_b' as the default hub for the unified '@pypi' repository -pip.default(default_hub = "pypi_b") - -# The canonical proxy is automatically created unconditionally: -use_repo(pip, "pypi") -``` - -### Unified PyPI Hub - -The canonical `@pypi` proxy repository matches exactly how concrete hubs create -their directory structure: a root package for shared configuration settings, and -a dedicated subdirectory (subpackage) for each PyPI package. - -Here is a complete, representative code example of what the generated files in -`@pypi` will look like when resolving packages between `pypi_a` and `pypi_b`: - -#### 1. `@pypi//BUILD.bazel` (Root Package) -The root package contains the shared `config_setting` targets following the -`_is_venv_` private naming convention. Leading underscores are strictly -applied because these configuration settings are an internal implementation -detail of the proxy repository and are not intended to be a public API. - -```starlark -package(default_visibility = ["//visibility:public"]) - -config_setting( - name = "_is_venv_pypi_a", - flag_values = { - "@rules_python//python/config_settings:venv": "pypi_a", - }, -) - -config_setting( - name = "_is_venv_pypi_b", - flag_values = { - "@rules_python//python/config_settings:venv": "pypi_b", - }, -) -``` - -#### 2. `@pypi//foo/BUILD.bazel` (PyPI Package Subpackage) -Each PyPI package subpackage defines the standard aliases (`pkg`, `whl`, `data`, -`dist_info`, `extracted_wheel_files`), plus a complete **union of all custom -`extra_hub_aliases`** defined across all concrete hubs. - -Each alias resolves dynamically to the active concrete hub based on the root -private configuration settings: - -```starlark -package(default_visibility = ["//visibility:public"]) - -alias( - name = "foo", - actual = ":pkg", -) - -alias( - name = "pkg", - actual = select({ - "//:_is_venv_pypi_a": "@pypi_a//foo:pkg", - "//:_is_venv_pypi_b": "@pypi_b//foo:pkg", - # When venv is "auto" (unset), it defaults to the designated fallback - # (or first defined concrete hub). - "//conditions:default": "@pypi_b//foo:pkg", - }), -) - -alias( - name = "whl", - actual = select({ - "//:_is_venv_pypi_a": "@pypi_a//foo:whl", - "//:_is_venv_pypi_b": "@pypi_b//foo:whl", - "//conditions:default": "@pypi_b//foo:whl", - }), -) - -# ... standard aliases for data, dist_info, extracted_wheel_files ... - -# 3. Unionized custom extra alias (defined in pypi_a but missing in pypi_b): -alias( - name = "my_custom_tool", - actual = select({ - "//:_is_venv_pypi_a": "@pypi_a//foo:my_custom_tool", - # Unrepresented branch routes to execution failure target: - "//:_is_venv_pypi_b": "//:_missing_package_error_pypi_b_foo", - "//conditions:default": "@pypi_a//foo:my_custom_tool", - }), -) -``` - -### Disjoint Hub Packages & Execution-Phase Failure - -If a package exists in one concrete hub but is missing in another (e.g., `scipy` -is in `pypi_b` but not `pypi_a`), our proxy synthesizes a package subpackage for -the union of all packages. - -To ensure that `bazel cquery` and `bazel query` successfully analyze over the -entire transitive build graph without failing, unrepresented select branches -must route to a dedicated **execution-phase error rule**. - -```starlark -# In @pypi//scipy/BUILD.bazel -alias( - name = "pkg", - actual = select({ - # Routes to execution-phase action failure target: - "//:_is_venv_pypi_a": "//:_missing_package_error_pypi_a_scipy", - "//:_is_venv_pypi_b": "@pypi_b//scipy:pkg", - "//conditions:default": "@pypi_b//scipy:pkg", - }), -) -``` - -The synthesized `//:_missing_package_error_XX` rule in `@pypi//BUILD.bazel` -returns standard Starlark Python providers so analysis/cquery passes, but -registers a build action that fails when executed: - -``` -Dependency Error: Third-party package 'scipy' is not available when building under PyPI hub 'pypi_a'. -``` - -### Fallback Hub Precedence (`"auto"`) - -When a target depends on `@pypi//foo` and the active build setting is `"auto"`, -the proxy resolves to a concrete hub using the following precedence: -1. **Designated Fallback**: If the user has explicitly designated a fallback - concrete hub via `pip.default(default_hub = "...")` in their root - `MODULE.bazel`, the proxy routes to it. -2. **First Defined Hub**: If no fallback is explicitly designated via - `pip.default()`, the proxy **automatically routes to the first defined - concrete hub** parsed during extension evaluation (e.g., `pypi_a`). - -```starlark -# Explicitly override the "auto" fallback hub -pip.default( - default_hub = "pypi_b", -) -``` - ---- - -## 2. Core Rule Integration: `config_settings` Transitions - -Users will switch active hubs using the standard, highly generic -`config_settings` transition attribute on executable targets. - -### Build Setting Definition - -In `python/config_settings/BUILD.bazel`: - -```starlark -string_flag( - name = "venv", - build_setting_default = "auto", # Default value is "auto" - visibility = ["//visibility:public"], -) -``` - -In `python/private/common_labels.bzl`: -```starlark - VENV = str(Label("//python/config_settings:venv")), -``` - -In `python/private/transition_labels.bzl`: -```starlark -_BASE_TRANSITION_LABELS = [ - # ... existing transition labels ... - labels.VENV, -] -``` - -Because `py_binary` and `py_test` implement an incoming transition -(`_transition_executable_impl`) that automatically processes any -`config_settings` keys matching `TRANSITION_LABELS`, **this provides complete -transition capabilities with zero changes to our core rule definitions**. - -### Usage in BUILD.bazel - -Libraries consume packages through the canonical proxy: - -```starlark -py_library( - name = "common", - deps = ["@pypi//foo"], # Apparent proxy repository -) -``` - -Binaries change the active hub by transitioning the build setting: - -```starlark -# Resolves @pypi -> pypi_b (default hub / designated fallback) -py_binary( - name = "bin_default", - deps = [":common"], -) - -# Resolves @pypi -> pypi_a via transition -py_binary( - name = "bin_a", - deps = [":common"], - config_settings = { - "//python/config_settings:venv": "pypi_a", - }, -) -``` - -### Analysis Cache & Memory Best Practices - -Because transitions fork the Bazel configuration, building targets with highly -diversified `config_settings` across large build graphs will result in -re-analysis and re-compilation of shared dependencies. - -We will include explicit documentation guidelines advising users to keep their -`venv` transition configurations localized and minimized to preserve Bazel -caching and memory efficiency. - ---- - -## 3. Integration Testing Specification - -We will construct a comprehensive Bazel-in-Bazel integration test suite in -`tests/integration/unified_pypi/` to guarantee correctness and verify -transitions. - -The integration test suite will assert: -1. **`"auto"` Precedence**: Author a test asserting `bazel run //:bin_default` - correctly inherits `"auto"` and resolves dependencies from the designated fallback. -2. **Transitional Resolution**: Author a test asserting two binary targets in - the same package with different `config_settings` successfully resolve - dependencies and execute against their respective concrete hubs (`pypi_a` - vs `pypi_b`). -3. **Command Line Override**: Author a test asserting - `bazel run --//python/config_settings:venv=pypi_a //:bin_default` - successfully forces the executable to run using imports resolved from - `pypi_a`. -4. **Disjoint Execution Failure**: Author a test asserting `bazel cquery` over - a target depending on an unrepresented missing package succeeds, while - `bazel run` on that target gracefully fails during execution with the exact - synthesized error message. -5. **Unionized Extra Hub Aliases**: Author a test asserting that a binary - successfully runs using a custom `extra_hub_aliases` target resolved - through the `@pypi proxy`. - ---- - -## 4. Execution Steps - -1. **Phase 1**: Define `venv` `string_flag` and register it in - `common_labels.bzl` and `transition_labels.bzl`. -2. **Phase 2**: Update `python/private/pypi/extension.bzl` to synthesize the - canonical `pypi` proxy repository rule. -3. **Phase 3**: Implement `missing_package_error` execution failure rule and - the `proxy_hub_repository` generation logic. -4. **Phase 4**: Author the Bazel-in-Bazel integration test suite in - `tests/integration/unified_pypi/`. -5. **Phase 5**: Run all tests and verify full pass before PR submission. diff --git a/python/private/internal_config_repo.bzl b/python/private/internal_config_repo.bzl index 9bae0a5821..8ee6a2d017 100644 --- a/python/private/internal_config_repo.bzl +++ b/python/private/internal_config_repo.bzl @@ -30,6 +30,7 @@ config = struct( supports_whl_extraction = {supports_whl_extraction}, enable_pystar = True, enable_deprecation_warnings = {enable_deprecation_warnings}, + extract_needs_chmod = {extract_needs_chmod}, bazel_8_or_later = {bazel_8_or_later}, bazel_9_or_later = {bazel_9_or_later}, bazel_10_or_later = {bazel_10_or_later}, @@ -86,6 +87,7 @@ def _internal_config_repo_impl(rctx): bazel_minor_version = 99999 supports_whl_extraction = False + extract_needs_chmod = bazel_major_version < 8 or (bazel_major_version == 8 and bazel_minor_version < 6) if bazel_major_version >= 8: # Extracting .whl files requires Bazel 8.3.0 or later. if bazel_major_version > 8 or bazel_minor_version >= 3: @@ -105,6 +107,7 @@ def _internal_config_repo_impl(rctx): builtin_py_info_symbol = builtin_py_info_symbol, builtin_py_runtime_info_symbol = builtin_py_runtime_info_symbol, supports_whl_extraction = str(supports_whl_extraction), + extract_needs_chmod = str(extract_needs_chmod), builtin_py_cc_link_params_provider = builtin_py_cc_link_params_provider, bazel_8_or_later = str(bazel_major_version >= 8), bazel_9_or_later = str(bazel_major_version >= 9), diff --git a/python/private/pypi/patch_whl.bzl b/python/private/pypi/patch_whl.bzl index 98a5ad49c8..e8d76eeba5 100644 --- a/python/private/pypi/patch_whl.bzl +++ b/python/private/pypi/patch_whl.bzl @@ -80,6 +80,7 @@ def patch_whl(rctx, *, whl_path, patches): rctx, archive = whl_input, supports_whl_extraction = rp_config.supports_whl_extraction, + extract_needs_chmod = rp_config.extract_needs_chmod, ) if not patches: diff --git a/python/private/pypi/whl_extract.bzl b/python/private/pypi/whl_extract.bzl index 2ebb61a83a..0d61b9a07b 100644 --- a/python/private/pypi/whl_extract.bzl +++ b/python/private/pypi/whl_extract.bzl @@ -18,10 +18,9 @@ def whl_extract(rctx, *, whl_path, logger): archive = whl_path, output = install_dir_path, supports_whl_extraction = rp_config.supports_whl_extraction, + extract_needs_chmod = rp_config.extract_needs_chmod, ) - _maybe_fix_permissions(rctx, whl_path = whl_path, logger = logger) - metadata_file = find_whl_metadata( install_dir = install_dir_path, logger = logger, @@ -65,27 +64,6 @@ def whl_extract(rctx, *, whl_path, logger): # Ensure that there is no data dir left rctx.delete(data_dir) -# TODO: This can be removed when Bazel 8.6+ is the minimum supported version. -def _maybe_fix_permissions(rctx, *, whl_path, logger): - # Fix permissions on extracted files. Some wheels have files without read permissions set, - # which causes errors when trying to read them later. - # We apply this to the root directory to ensure that everything in bin/, site-packages/, - # etc. is readable and executable where appropriate. - os_name = repo_utils.get_platforms_os_name(rctx) - if os_name != "windows": - # On Unix-like systems, recursively add read permissions to all files - # and ensure directories are traversable (need execute permission) - result = repo_utils.execute_unchecked( - rctx, - op = "Fixing wheel permissions {}".format(whl_path), - arguments = ["chmod", "-R", "a+rX", "."], - logger = logger, - ) - if result.return_code != 0: - # It's possible chmod is not available or the filesystem doesn't support it. - # This is fine, we just want to try to fix permissions if possible. - logger.warn(lambda: "Failed to fix file permissions: {}".format(result.stderr)) - def merge_trees(src, dest): """Merge src into the destination path. diff --git a/python/private/repo_utils.bzl b/python/private/repo_utils.bzl index 6fa851b7b3..0e83fda28c 100644 --- a/python/private/repo_utils.bzl +++ b/python/private/repo_utils.bzl @@ -202,7 +202,7 @@ def _execute_internal( output = _outputs_to_str(result, log_stdout = log_stdout, log_stderr = log_stderr), )) - result_kwargs = {k: getattr(result, k) for k in dir(result)} + result_kwargs = {k: getattr(result, k) for k in dir(result) if k not in ["to_json", "to_proto"]} return struct( describe_failure = lambda: _execute_describe_failure( op = op, @@ -511,7 +511,7 @@ def _get_platforms_cpu_name(mrctx): return "riscv64" return arch -def _extract(mrctx, *, archive, supports_whl_extraction = False, **kwargs): +def _extract(mrctx, *, archive, supports_whl_extraction = False, extract_needs_chmod = False, **kwargs): """Extract an archive TODO: remove when the earliest supported bazel version is at least 8.3. @@ -533,6 +533,26 @@ def _extract(mrctx, *, archive, supports_whl_extraction = False, **kwargs): if not mrctx.delete(archive): fail("Failed to remove the symlink after extracting") + if extract_needs_chmod: + _maybe_fix_permissions(mrctx, whl_path = archive) + +def _maybe_fix_permissions(mrctx, *, whl_path, logger = None): + if not logger and hasattr(mrctx, "attr"): + logger = _logger(mrctx) + elif not logger: + fail("logger must be specified when using 'module_ctx'") + + os_name = _get_platforms_os_name(mrctx) + if os_name != "windows": + result = _execute_unchecked( + mrctx, + op = "Fixing wheel permissions {}".format(whl_path), + arguments = ["chmod", "-R", "a+rX", "."], + logger = logger, + ) + if result.return_code != 0: + logger.warn(lambda: "Failed to fix file permissions: {}".format(result.stderr)) + def _rename(mrctx, src, dest): """Rename a file or directory. @@ -575,6 +595,7 @@ repo_utils = struct( get_platforms_os_name = _get_platforms_os_name, is_repo_debug_enabled = _is_repo_debug_enabled, logger = _logger, + maybe_fix_permissions = _maybe_fix_permissions, mkdir = _mkdir, norm_path = _norm_path, relative_to = _relative_to, diff --git a/tests/repo_utils/repo_utils_test.bzl b/tests/repo_utils/repo_utils_test.bzl index ce9e48b5a6..56d125724d 100644 --- a/tests/repo_utils/repo_utils_test.bzl +++ b/tests/repo_utils/repo_utils_test.bzl @@ -2,10 +2,21 @@ load("@rules_testing//lib:test_suite.bzl", "test_suite") load("//python/private:repo_utils.bzl", "repo_utils") # buildifier: disable=bzl-visibility -load("//tests/support:mocks.bzl", "mocks") +load("//tests/support/mocks:mocks.bzl", "mocks") _tests = [] +def _make_rctx(*, os_name, mock_extracts = None, mock_files = None): + return mocks.rctx( + attr = { + "name": "unit", + "_rule_name": "test_rule", + }, + mock_files = mock_files, + mock_extracts = mock_extracts, + os_name = os_name, + ) + def _test_get_platforms_os_name(env): mock_mrctx = mocks.rctx(os_name = "Mac OS X") got = repo_utils.get_platforms_os_name(mock_mrctx) @@ -50,6 +61,68 @@ def _test_is_relative_to(env): _tests.append(_test_is_relative_to) +def _test_extract_calls_chmod_when_enabled(env): + mock_rctx = _make_rctx( + os_name = "linux", + mock_extracts = {"test.whl": {"f1": "c1"}}, + ) + + repo_utils.extract( + mock_rctx, + archive = mock_rctx.path("test.whl"), + output = "out", + supports_whl_extraction = True, + extract_needs_chmod = True, + ) + + env.expect.that_bool(len(mock_rctx.execute_calls) > 0).equals(True) + env.expect.that_str(mock_rctx.execute_calls[0][0]).equals("chmod") + +_tests.append(_test_extract_calls_chmod_when_enabled) + +def _test_extract_skips_chmod_when_disabled(env): + mock_rctx = _make_rctx( + os_name = "linux", + mock_extracts = {"test.whl": {"f1": "c1"}}, + ) + + repo_utils.extract( + mock_rctx, + archive = mock_rctx.path("test.whl"), + output = "out", + supports_whl_extraction = True, + extract_needs_chmod = False, + ) + + env.expect.that_collection(mock_rctx.execute_calls).contains_exactly([]) + +_tests.append(_test_extract_skips_chmod_when_disabled) + +def _test_maybe_fix_permissions_calls_chmod_on_linux(env): + mock_rctx = _make_rctx(os_name = "linux") + + repo_utils.maybe_fix_permissions( + mock_rctx, + whl_path = mock_rctx.path("test.whl"), + ) + + env.expect.that_bool(len(mock_rctx.execute_calls) > 0).equals(True) + env.expect.that_str(mock_rctx.execute_calls[0][0]).equals("chmod") + +_tests.append(_test_maybe_fix_permissions_calls_chmod_on_linux) + +def _test_maybe_fix_permissions_skips_on_windows(env): + mock_rctx = _make_rctx(os_name = "windows") + + repo_utils.maybe_fix_permissions( + mock_rctx, + whl_path = mock_rctx.path("test.whl"), + ) + + env.expect.that_collection(mock_rctx.execute_calls).contains_exactly([]) + +_tests.append(_test_maybe_fix_permissions_skips_on_windows) + def repo_utils_test_suite(name): """Create the test suite. diff --git a/tests/support/mocks.bzl b/tests/support/mocks.bzl deleted file mode 100644 index 2a4ccd0fc4..0000000000 --- a/tests/support/mocks.bzl +++ /dev/null @@ -1,31 +0,0 @@ -"""Mocks for testing.""" - -def _rctx(os_name = "linux", os_arch = "x86_64", environ = None, **kwargs): - """Creates a mock of repository_ctx or module_ctx. - - Args: - os_name: The OS name to mock (e.g., "linux", "Mac OS X", "windows"). - os_arch: The OS architecture to mock (e.g., "x86_64", "aarch64"). - environ: A dictionary representing the environment variables. - **kwargs: Additional attributes to add to the mock struct. - - Returns: - A struct mocking repository_ctx. - """ - if environ == None: - environ = {} - - attrs = { - "getenv": environ.get, - "os": struct( - name = os_name, - arch = os_arch, - ), - } - attrs.update(kwargs) - - return struct(**attrs) - -mocks = struct( - rctx = _rctx, -) diff --git a/tests/support/mocks/mocks.bzl b/tests/support/mocks/mocks.bzl index 84d39fa6e2..6c79b1f4ff 100644 --- a/tests/support/mocks/mocks.bzl +++ b/tests/support/mocks/mocks.bzl @@ -185,7 +185,7 @@ def _mctx_download( return struct(success = True, wait = lambda: struct(success = True)) return struct(success = False, wait = lambda: struct(success = False)) -def _mctx_report_progress(self, message): +def _mrctx_report_progress(self, message): self.report_progress_calls.append(message) return None @@ -259,7 +259,7 @@ def _mctx_new( path = lambda *a, **k: _mctx_path(self, *a, **k), read = lambda *a, **k: _mctx_read(self, *a, **k), download = lambda *a, **k: _mctx_download(self, *a, **k), - report_progress = lambda *a, **k: _mctx_report_progress(self, *a, **k), + report_progress = lambda *a, **k: _mrctx_report_progress(self, *a, **k), add_module = lambda **k: _mctx_add_module(self, **k), ) return self @@ -439,6 +439,7 @@ def _rctx_execute( environment, custom_reporter, ) # @unused + self.execute_calls.append(arguments) return struct(return_code = 0, stdout = "", stderr = "") def _rctx_symlink(self, target, link_name): @@ -485,7 +486,10 @@ def _rctx_new( mock_which = mock_which, mock_downloads = mock_downloads, mock_extracts = mock_extracts, + execute_calls = [], + report_progress_calls = [], attr = struct(**attr), + name = attr.get("name", "mock"), os = struct( name = os_name, arch = arch_name, @@ -494,10 +498,12 @@ def _rctx_new( path = lambda *a, **k: _rctx_path(self, *a, **k), read = lambda *a, **k: _rctx_read(self, *a, **k), file = lambda *a, **k: _rctx_file(self, *a, **k), + getenv = environ.get, template = lambda *a, **k: _rctx_template(self, *a, **k), which = lambda *a, **k: _rctx_which(self, *a, **k), download = lambda *a, **k: _rctx_download(self, *a, **k), extract = lambda *a, **k: _rctx_extract(self, *a, **k), + delete = lambda x: True, download_and_extract = lambda *a, **k: _rctx_download_and_extract( self, *a, @@ -505,6 +511,7 @@ def _rctx_new( ), execute = lambda *a, **k: _rctx_execute(self, *a, **k), symlink = lambda *a, **k: _rctx_symlink(self, *a, **k), + report_progress = lambda *a, **k: _mrctx_report_progress(self, *a, **k), ) return self From 20db9560c52878a3e5dc427430c225d3efa1a730 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 28 Jun 2026 01:47:08 -0700 Subject: [PATCH 035/179] ci: make pr-metadata-check run on PR body edit (#3862) Make the check run on edit so that it can detect adding/removing of the special marker text. --- .github/workflows/pr-metadata-checks.yaml | 1 + BUILD.bazel | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/pr-metadata-checks.yaml b/.github/workflows/pr-metadata-checks.yaml index e49ed14810..8060ff921e 100644 --- a/.github/workflows/pr-metadata-checks.yaml +++ b/.github/workflows/pr-metadata-checks.yaml @@ -8,6 +8,7 @@ on: - reopened - labeled - unlabeled + - edited jobs: blocks-do-not-merge: diff --git a/BUILD.bazel b/BUILD.bazel index a52251393d..a25863708c 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -108,3 +108,5 @@ bzl_library( srcs = ["version.bzl"], visibility = ["//:__subpackages__"], ) + +# No-op change to verify PR metadata checks From 367190add274b22efe9992fd8b364d22396a2809 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:00:43 +0900 Subject: [PATCH 036/179] chore(pypi): cleanup unused code (#3864) This is no longer used starting when we enabled pipstar by default and did a code cleanup where Python is no longer used to extract the wheels. Split from #3856 --- .../pypi/generate_whl_library_build_bazel.bzl | 47 +--- python/private/pypi/pkg_aliases.bzl | 1 + python/private/pypi/whl_library_targets.bzl | 107 +------- ...generate_whl_library_build_bazel_tests.bzl | 65 ----- .../whl_library_targets_tests.bzl | 247 ------------------ 5 files changed, 15 insertions(+), 452 deletions(-) diff --git a/python/private/pypi/generate_whl_library_build_bazel.bzl b/python/private/pypi/generate_whl_library_build_bazel.bzl index a9a29081f7..71eab26c0e 100644 --- a/python/private/pypi/generate_whl_library_build_bazel.bzl +++ b/python/private/pypi/generate_whl_library_build_bazel.bzl @@ -21,8 +21,6 @@ _RENDER = { "copy_files": render.dict, "data": render.list, "data_exclude": render.list, - "dependencies": render.list, - "dependencies_by_platform": lambda x: render.dict(x, value_repr = render.list), "entry_points": render.dict_dict, "extras": render.list, "group_deps": render.list, @@ -30,7 +28,6 @@ _RENDER = { "requires_dist": render.list, "srcs_exclude": render.list, "tags": render.list, - "target_platforms": render.list, } # NOTE @aignas 2024-10-25: We have to keep this so that files in @@ -55,15 +52,17 @@ package_metadata( def generate_whl_library_build_bazel( *, annotation = None, - default_python_version = None, + config_load, purl = None, + requires_dist = [], **kwargs): """Generate a BUILD file for an unzipped Wheel Args: annotation: The annotation for the build file. - default_python_version: The python version to use to parse the METADATA. + config_load: {type}`str` The location from where to load the config. purl: The purl. + requires_dist: {type}`list[str]` The list of dependencies from the METADATA file. **kwargs: Extra args serialized to be passed to the {obj}`whl_library_targets`. @@ -75,36 +74,14 @@ def generate_whl_library_build_bazel( """load("@package_metadata//rules:package_metadata.bzl", "package_metadata")""", ] - if kwargs.get("tags"): - fn = "whl_library_targets" - - # legacy path - unsupported_args = [ - "requires", - "metadata_name", - "metadata_version", - "packages", - "include", - ] + fn = "whl_library_targets_from_requires" + if not requires_dist: + # no deps, we can leave the extra loads out + pass else: - fn = "whl_library_targets_from_requires" - unsupported_args = [ - "dependencies", - "dependencies_by_platform", - "target_platforms", - "default_python_version", - ] - packages_load = kwargs.pop("config_load") - if not kwargs.get("requires_dist"): - # no deps, we can leave the extra loads out - pass - else: - loads.append("""load("{}", "{}")""".format(packages_load, "packages")) - kwargs["include"] = "packages" - - for arg in unsupported_args: - if kwargs.get(arg): - fail("BUG, unsupported arg: '{}'".format(arg)) + loads.append("""load("{}", "{}")""".format(config_load, "packages")) + kwargs["include"] = "packages" + kwargs["requires_dist"] = requires_dist loads.extend([ """load("@rules_python//python/private/pypi:whl_library_targets.bzl", "{}")""".format(fn), @@ -119,8 +96,6 @@ def generate_whl_library_build_bazel( kwargs["srcs_exclude"] = annotation.srcs_exclude_glob if annotation.additive_build_content: additional_content.append(annotation.additive_build_content) - if default_python_version: - kwargs["default_python_version"] = default_python_version contents = "\n".join( [ diff --git a/python/private/pypi/pkg_aliases.bzl b/python/private/pypi/pkg_aliases.bzl index 111a49d3c6..b1c29c95ee 100644 --- a/python/private/pypi/pkg_aliases.bzl +++ b/python/private/pypi/pkg_aliases.bzl @@ -201,6 +201,7 @@ def multiplatform_whl_aliases( ret[alias] = repo continue + # This is if we are using `whl_config_setting` struct config_settings = get_config_settings( target_platforms = alias.target_platforms, python_version = alias.version, diff --git a/python/private/pypi/whl_library_targets.bzl b/python/private/pypi/whl_library_targets.bzl index 01a89aadcc..933b529053 100644 --- a/python/private/pypi/whl_library_targets.bzl +++ b/python/private/pypi/whl_library_targets.bzl @@ -120,10 +120,8 @@ def whl_library_targets( tags = [], dependencies = [], filegroups = None, - dependencies_by_platform = {}, dependencies_with_markers = {}, entry_points = {}, - group_deps = [], group_name = "", data = [], copy_files = {}, @@ -151,8 +149,6 @@ def whl_library_targets( the filename of the sdist. tags: {type}`list[str]` The tags set on the `py_library`. dependencies: {type}`list[str]` A list of dependencies. - dependencies_by_platform: {type}`dict[str, list[str]]` A list of - dependencies by platform key. dependencies_with_markers: {type}`dict[str, str]` A marker to evaluate in order for the dep to be included. entry_points: {type}`list[dict]` A list of parsed entry point definitions. @@ -162,10 +158,6 @@ def whl_library_targets( contains this library. If set, this library will behave as a shim to group implementation rules which will provide simultaneously installed dependencies which would otherwise form a cycle. - group_deps: {type}`list[str]` names of fellow members of the group (if - any). These will be excluded from generated deps lists so as to avoid - direct cycles. These dependencies will be provided at runtime by the - group rules which wrap this library and its fellows together. copy_executables: {type}`dict[str, str]` The mapping between src and dest locations for the targets. copy_files: {type}`dict[str, str]` The mapping between src and @@ -183,10 +175,6 @@ def whl_library_targets( rules: {type}`struct` A struct with references to rules for creating targets. """ dependencies = sorted([normalize_name(d) for d in dependencies]) - dependencies_by_platform = { - platform: sorted([normalize_name(d) for d in deps]) - for platform, deps in dependencies_by_platform.items() - } tags = sorted(tags) data = [] + data @@ -267,9 +255,7 @@ def whl_library_targets( data.append(dest) _config_settings( - dependencies_by_platform = dependencies_by_platform.keys(), dependencies_with_markers = dependencies_with_markers, - native = native, rules = rules, visibility = ["//visibility:private"], ) @@ -278,25 +264,6 @@ def whl_library_targets( for d in dependencies_with_markers } - # Ensure this list is normalized - # Note: mapping used as set - group_deps = { - normalize_name(d): True - for d in group_deps - } - - dependencies = [ - d - for d in dependencies - if d not in group_deps - ] - dependencies_by_platform = { - p: deps - for p, deps in dependencies_by_platform.items() - for deps in [[d for d in deps if d not in group_deps]] - if deps - } - # If this library is a member of a group, its public label aliases need to # point to the group implementation rule not the implementation rules. We # also need to mark the implementation rules as visible to the group @@ -351,7 +318,6 @@ def whl_library_targets( srcs = [name], data = _deps( deps = dependencies, - deps_by_platform = dependencies_by_platform, deps_conditional = deps_conditional, tmpl = dep_template.format(name = "{}", target = WHEEL_FILE_PUBLIC_LABEL), ), @@ -418,7 +384,6 @@ def whl_library_targets( imports = ["site-packages"], deps = _deps( deps = dependencies, - deps_by_platform = dependencies_by_platform, deps_conditional = deps_conditional, tmpl = dep_template.format(name = "{}", target = PY_LIBRARY_PUBLIC_LABEL), ), @@ -428,22 +393,13 @@ def whl_library_targets( namespace_package_files = namespace_package_files, ) -def _config_settings(dependencies_by_platform, dependencies_with_markers, rules, native = native, **kwargs): +def _config_settings(dependencies_with_markers, rules, **kwargs): """Generate config settings for the targets. Args: - dependencies_by_platform: {type}`list[str]` platform keys, can be - one of the following formats: - * `//conditions:default` - * `@platforms//os:{value}` - * `@platforms//cpu:{value}` - * `@//python/config_settings:is_python_3.{minor_version}` - * `{os}_{cpu}` - * `cp3{minor_version}_{os}_{cpu}` dependencies_with_markers: {type}`dict[str, str]` The markers to evaluate by each dep. rules: used for testing - native: {type}`native` The native struct for overriding in tests. **kwargs: Extra kwargs to pass to the rule. """ for dep, expression in dependencies_with_markers.items(): @@ -453,46 +409,7 @@ def _config_settings(dependencies_by_platform, dependencies_with_markers, rules, **kwargs ) - for p in dependencies_by_platform: - if p.startswith("@") or p.endswith("default"): - continue - - # TODO @aignas 2025-04-20: add tests here - abi, _, tail = p.partition("_") - if not abi.startswith("cp"): - tail = p - abi = "" - os, _, arch = tail.partition("_") - - _kwargs = dict(kwargs) - _kwargs["constraint_values"] = [ - "@platforms//cpu:{}".format(arch), - "@platforms//os:{}".format(os), - ] - - if abi: - _kwargs["flag_values"] = { - Label("//python/config_settings:python_version"): "3.{}".format(abi[len("cp3"):]), - } - - native.config_setting( - name = "is_{name}".format( - name = p.replace("cp3", "python_3."), - ), - **_kwargs - ) - -def _plat_label(plat): - if plat.endswith("default"): - return plat - elif plat.startswith("@//"): - return Label(plat.strip("@")) - elif plat.startswith("@"): - return plat - else: - return ":is_" + plat.replace("cp3", "python_3.") - -def _deps(deps, deps_by_platform, deps_conditional, tmpl): +def _deps(deps, deps_conditional, tmpl): deps = [tmpl.format(d) for d in sorted(deps)] for dep, setting in deps_conditional.items(): @@ -501,22 +418,4 @@ def _deps(deps, deps_by_platform, deps_conditional, tmpl): "//conditions:default": [], }) - if not deps_by_platform: - return deps - - deps_by_platform = { - _plat_label(p): [ - tmpl.format(d) - for d in sorted(deps) - ] - for p, deps in sorted(deps_by_platform.items()) - } - - # Add the default, which means that we will be just using the dependencies in - # `deps` for platforms that are not handled in a special way by the packages - deps_by_platform.setdefault("//conditions:default", []) - - if not deps: - return select(deps_by_platform) - else: - return deps + select(deps_by_platform) + return deps diff --git a/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl b/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl index 9586581cad..1fd99205b1 100644 --- a/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl +++ b/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl @@ -19,71 +19,6 @@ load("//python/private/pypi:generate_whl_library_build_bazel.bzl", "generate_whl _tests = [] -def _test_all_legacy(env): - want = """\ -load("@package_metadata//rules:package_metadata.bzl", "package_metadata") -load("@rules_python//python/private/pypi:whl_library_targets.bzl", "whl_library_targets") - -package(default_visibility = ["//visibility:public"]) - -package_metadata( - name = "package_metadata", - purl = None, - visibility = ["//:__subpackages__"], -) - -whl_library_targets( - copy_executables = { - "exec_src": "exec_dest", - }, - copy_files = { - "file_src": "file_dest", - }, - data = ["extra_target"], - data_exclude = [ - "exclude_via_attr", - "data_exclude_all", - ], - dep_template = "@pypi_{name}//:{target}", - dependencies = ["foo"], - dependencies_by_platform = { - "baz": ["bar"], - }, - group_deps = [ - "foo", - "fox", - "qux", - ], - group_name = "qux", - name = "foo.whl", - srcs_exclude = ["srcs_exclude_all"], - tags = ["tag1"], -) - -# SOMETHING SPECIAL AT THE END -""" - actual = generate_whl_library_build_bazel( - dep_template = "@pypi_{name}//:{target}", - name = "foo.whl", - dependencies = ["foo"], - dependencies_by_platform = {"baz": ["bar"]}, - data_exclude = ["exclude_via_attr"], - annotation = struct( - copy_files = {"file_src": "file_dest"}, - copy_executables = {"exec_src": "exec_dest"}, - data = ["extra_target"], - data_exclude_glob = ["data_exclude_all"], - srcs_exclude_glob = ["srcs_exclude_all"], - additive_build_content = """# SOMETHING SPECIAL AT THE END""", - ), - group_name = "qux", - group_deps = ["foo", "fox", "qux"], - tags = ["tag1"], - ) - env.expect.that_str(actual.replace("@@", "@")).equals(want) - -_tests.append(_test_all_legacy) - def _test_all_workspace(env): want = """\ load("@package_metadata//rules:package_metadata.bzl", "package_metadata") diff --git a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl index 91db15f296..5bd1d1f549 100644 --- a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl +++ b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl @@ -72,72 +72,12 @@ def _test_filegroups(env): _tests.append(_test_filegroups) -def _test_platforms(env): - calls = [] - - whl_library_targets( - name = "", - dep_template = None, - dependencies_by_platform = { - "@//python/config_settings:is_python_3.9": ["py39_dep"], - "@platforms//cpu:aarch64": ["arm_dep"], - "@platforms//os:windows": ["win_dep"], - "cp310.11_linux_ppc64le": ["full_version_dep"], - "cp310_linux_ppc64le": ["py310_linux_ppc64le_dep"], - "linux_x86_64": ["linux_intel_dep"], - }, - filegroups = {}, - native = struct( - config_setting = lambda **kwargs: calls.append(kwargs), - glob = lambda *args, **kwargs: [], - ), - rules = struct( - venv_rewrite_shebang = lambda **kwargs: None, - ), - ) - - env.expect.that_collection(calls).contains_exactly([ - { - "name": "is_python_3.10.11_linux_ppc64le", - "visibility": ["//visibility:private"], - "constraint_values": [ - "@platforms//cpu:ppc64le", - "@platforms//os:linux", - ], - "flag_values": { - Label("//python/config_settings:python_version"): "3.10.11", - }, - }, - { - "name": "is_python_3.10_linux_ppc64le", - "visibility": ["//visibility:private"], - "constraint_values": [ - "@platforms//cpu:ppc64le", - "@platforms//os:linux", - ], - "flag_values": { - Label("//python/config_settings:python_version"): "3.10", - }, - }, - { - "name": "is_linux_x86_64", - "visibility": ["//visibility:private"], - "constraint_values": [ - "@platforms//cpu:x86_64", - "@platforms//os:linux", - ], - }, - ]) # buildifier: @unsorted-dict-items - -_tests.append(_test_platforms) - def _test_copy(env): calls = [] whl_library_targets( name = "", dep_template = None, - dependencies_by_platform = {}, filegroups = {}, copy_files = {"file_src": "file_dest"}, copy_executables = {"exec_src": "exec_dest"}, @@ -289,193 +229,6 @@ def _test_whl_and_library_deps_from_requires(env): _tests.append(_test_whl_and_library_deps_from_requires) -def _test_whl_and_library_deps(env): - filegroup_calls = [] - py_library_calls = [] - m_glob = mocks.glob() - m_glob.results.append([]) # bin - m_glob.results.append([]) # rewrite-bin - m_glob.results.append(["site-packages/foo/SRCS.py"]) - m_glob.results.append(["site-packages/foo/DATA.txt"]) - m_glob.results.append(["site-packages/foo/PYI.pyi"]) - - whl_library_targets( - name = "foo.whl", - dep_template = "@pypi_{name}//:{target}", - dependencies = ["foo", "bar-baz"], - dependencies_by_platform = { - "@//python/config_settings:is_python_3.9": ["py39_dep"], - "@platforms//cpu:aarch64": ["arm_dep"], - "@platforms//os:windows": ["win_dep"], - "cp310_linux_ppc64le": ["py310_linux_ppc64le_dep"], - "cp39_anyos_aarch64": ["py39_arm_dep"], - "cp39_linux_anyarch": ["py39_linux_dep"], - "linux_x86_64": ["linux_intel_dep"], - }, - data_exclude = [], - tags = ["tag1", "tag2"], - # Overrides for testing - filegroups = {}, - native = struct( - filegroup = lambda **kwargs: filegroup_calls.append(kwargs), - config_setting = lambda **_: None, - glob = m_glob.glob, - ), - rules = struct( - py_library = lambda **kwargs: py_library_calls.append(kwargs), - create_inits = lambda **kwargs: ["_create_inits_target"], - venv_rewrite_shebang = lambda **kwargs: None, - ), - ) - - env.expect.that_collection(filegroup_calls).contains_exactly([ - { - "name": "whl", - "srcs": ["foo.whl"], - "data": [ - "@pypi_bar_baz//:whl", - "@pypi_foo//:whl", - ] + select( - { - Label("//python/config_settings:is_python_3.9"): ["@pypi_py39_dep//:whl"], - "@platforms//cpu:aarch64": ["@pypi_arm_dep//:whl"], - "@platforms//os:windows": ["@pypi_win_dep//:whl"], - ":is_python_3.10_linux_ppc64le": ["@pypi_py310_linux_ppc64le_dep//:whl"], - ":is_python_3.9_anyos_aarch64": ["@pypi_py39_arm_dep//:whl"], - ":is_python_3.9_linux_anyarch": ["@pypi_py39_linux_dep//:whl"], - ":is_linux_x86_64": ["@pypi_linux_intel_dep//:whl"], - "//conditions:default": [], - }, - ), - "visibility": ["//visibility:public"], - }, - ]) # buildifier: @unsorted-dict-items - - env.expect.that_collection(py_library_calls).has_size(1) - if len(py_library_calls) != 1: - return - env.expect.that_dict(py_library_calls[0]).contains_exactly({ - "name": "pkg", - "srcs": ["site-packages/foo/SRCS.py"] + select({ - Label("//python/config_settings:_is_venvs_site_packages_yes"): [], - "//conditions:default": ["_create_inits_target"], - }), - "pyi_srcs": ["site-packages/foo/PYI.pyi"], - "data": ["site-packages/foo/DATA.txt", "data"], - "imports": ["site-packages"], - "deps": [ - "@pypi_bar_baz//:pkg", - "@pypi_foo//:pkg", - ] + select( - { - Label("//python/config_settings:is_python_3.9"): ["@pypi_py39_dep//:pkg"], - "@platforms//cpu:aarch64": ["@pypi_arm_dep//:pkg"], - "@platforms//os:windows": ["@pypi_win_dep//:pkg"], - ":is_python_3.10_linux_ppc64le": ["@pypi_py310_linux_ppc64le_dep//:pkg"], - ":is_python_3.9_anyos_aarch64": ["@pypi_py39_arm_dep//:pkg"], - ":is_python_3.9_linux_anyarch": ["@pypi_py39_linux_dep//:pkg"], - ":is_linux_x86_64": ["@pypi_linux_intel_dep//:pkg"], - "//conditions:default": [], - }, - ), - "tags": ["tag1", "tag2"], - "visibility": ["//visibility:public"], - "experimental_venvs_site_packages": Label("//python/config_settings:venvs_site_packages"), - "namespace_package_files": [] + select({ - Label("//python/config_settings:_is_venvs_site_packages_yes"): [], - "//conditions:default": ["_create_inits_target"], - }), - }) # buildifier: @unsorted-dict-items - -_tests.append(_test_whl_and_library_deps) - -def _test_group(env): - alias_calls = [] - py_library_calls = [] - - m_glob = mocks.glob() - m_glob.results.append([]) # bin - m_glob.results.append([]) # rewrite-bin - m_glob.results.append(["site-packages/foo/srcs.py"]) - m_glob.results.append(["site-packages/foo/data.txt"]) - m_glob.results.append(["site-packages/foo/pyi.pyi"]) - - whl_library_targets( - name = "foo.whl", - dep_template = "@pypi_{name}//:{target}", - dependencies = ["foo", "bar-baz", "qux"], - dependencies_by_platform = { - "linux_x86_64": ["box", "box-amd64"], - "windows_x86_64": ["fox"], - "@platforms//os:linux": ["box"], # buildifier: disable=unsorted-dict-items to check that we sort inside the test - }, - tags = [], - data_exclude = [], - group_name = "qux", - group_deps = ["foo", "fox", "qux"], - # Overrides for testing - filegroups = {}, - native = struct( - config_setting = lambda **_: None, - glob = m_glob.glob, - alias = lambda **kwargs: alias_calls.append(kwargs), - ), - rules = struct( - py_library = lambda **kwargs: py_library_calls.append(kwargs), - create_inits = lambda **kwargs: ["_create_inits_target"], - venv_rewrite_shebang = lambda **kwargs: None, - ), - ) - - env.expect.that_collection(alias_calls).contains_exactly([ - {"name": "pkg", "actual": "@pypi__config//_groups:qux_pkg", "visibility": ["//visibility:public"]}, - {"name": "whl", "actual": "@pypi__config//_groups:qux_whl", "visibility": ["//visibility:public"]}, - ]) # buildifier: @unsorted-dict-items - - env.expect.that_collection(py_library_calls).has_size(1) - if len(py_library_calls) != 1: - return - - py_library_call = py_library_calls[0] - env.expect.where(case = "verify py library call").that_dict( - py_library_call, - ).contains_exactly({ - "name": "_pkg", - "srcs": ["site-packages/foo/srcs.py"] + select({ - Label("//python/config_settings:_is_venvs_site_packages_yes"): [], - "//conditions:default": ["_create_inits_target"], - }), - "pyi_srcs": ["site-packages/foo/pyi.pyi"], - "data": ["site-packages/foo/data.txt", "data"], - "imports": ["site-packages"], - "deps": ["@pypi_bar_baz//:pkg"] + select({ - "@platforms//os:linux": ["@pypi_box//:pkg"], - ":is_linux_x86_64": ["@pypi_box//:pkg", "@pypi_box_amd64//:pkg"], - "//conditions:default": [], - }), - "tags": [], - "visibility": ["@pypi__config//_groups:__pkg__"], - "experimental_venvs_site_packages": Label("//python/config_settings:venvs_site_packages"), - "namespace_package_files": [] + select({ - Label("//python/config_settings:_is_venvs_site_packages_yes"): [], - "//conditions:default": ["_create_inits_target"], - }), - }) # buildifier: @unsorted-dict-items - - env.expect.that_collection(m_glob.calls, expr = "glob calls").contains_exactly([ - mocks.glob_call(["bin/*"], allow_empty = True), - mocks.glob_call(["rewrite-bin/*"], allow_empty = True), - mocks.glob_call(["site-packages/**/*.py"], exclude = [], allow_empty = True), - mocks.glob_call(["site-packages/**/*"], exclude = [ - "**/*.py", - "**/*.pyc", - "**/*.pyc.*", - ], allow_empty = True), - mocks.glob_call(["site-packages/**/*.pyi"], allow_empty = True), - ]) - -_tests.append(_test_group) - def _test_sdist_excludes_record(env): py_library_calls = [] m_glob = mocks.glob() From 097f6a15c186f170a0471a0551e47fc910889693 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 28 Jun 2026 22:51:06 -0700 Subject: [PATCH 037/179] chore(release): correct version tagging and improve promotion flow (#3866) This change refactors the release promotion tool to ensure all pre-conditions are verified before making any modifications and automates post-promotion tracking updates. It also fixes a bug where the tool incorrectly assumed 'v' prefixes when identifying the latest release candidate and computing the final version tag. --- tests/tools/private/release/release_test.py | 406 +++++++++++++++++++- tools/private/release/gh.py | 71 ++-- tools/private/release/git.py | 6 +- tools/private/release/release.py | 125 ++++-- tools/private/release/utils.py | 5 +- 5 files changed, 541 insertions(+), 72 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 9f9cd2e801..4ec7de53ba 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -3,7 +3,7 @@ import shutil import tempfile import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from tools.private.release import changelog_news, release as releaser @@ -528,6 +528,33 @@ def test_get_latest_version_only_rc_tags(self, mock_get_tags): releaser.get_latest_version() +class GetLatestRcTagTest(unittest.TestCase): + @patch("tools.private.release.release.git.get_tags") + def test_get_latest_rc_tag_no_tags(self, mock_get_tags): + mock_get_tags.return_value = [] + self.assertIsNone(releaser.get_latest_rc_tag("2.0.0")) + + @patch("tools.private.release.release.git.get_tags") + def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): + mock_get_tags.return_value = ["1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0"] + self.assertIsNone(releaser.get_latest_rc_tag("2.0.0")) + + @patch("tools.private.release.release.git.get_tags") + def test_get_latest_rc_tag_success(self, mock_get_tags): + mock_get_tags.return_value = [ + "2.0.0-rc0", + "2.0.0-rc2", + "2.0.0-rc1", + "2.1.0-rc0", + ] + self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") + + @patch("tools.private.release.release.git.get_tags") + def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): + mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] + self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") + + class DetermineNextVersionTest(unittest.TestCase): def setUp(self): self.tmpdir = pathlib.Path(tempfile.mkdtemp()) @@ -648,5 +675,382 @@ def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): self.assertEqual(next_version, "1.2.4") +class CmdPrepareTest(unittest.TestCase): + def setUp(self): + self.mock_git = patch("tools.private.release.release.git").start() + self.mock_gh = patch("tools.private.release.release.gh").start() + self.addCleanup(patch.stopall) + + @patch("tools.private.release.release.pathlib.Path") + @patch("tools.private.release.release.changelog_news") + @patch("tools.private.release.release.replace_version_next") + def test_prepare_success_existing_issue( + self, mock_replace, mock_changelog, mock_path + ): + # Arrange + args = MagicMock(version="2.0.0", issue=None) + self.mock_git.status.side_effect = ["", "M foo"] + self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_gh.create_tracking_issue.assert_not_called() + self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) + + @patch("tools.private.release.release.pathlib.Path") + @patch("tools.private.release.release.changelog_news") + @patch("tools.private.release.release.replace_version_next") + def test_prepare_success_create_issue( + self, mock_replace, mock_changelog, mock_path + ): + # Arrange + args = MagicMock(version="2.0.0", issue=None) + self.mock_git.status.side_effect = ["", "M foo"] + self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found") + self.mock_gh.create_tracking_issue.return_value = 123 + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + + mock_template = MagicMock() + mock_template.exists.return_value = True + mock_template.read_text.return_value = "template content" + mock_path.return_value = mock_template + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_gh.create_tracking_issue.assert_called_once_with( + "2.0.0", "template content" + ) + self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) + + @patch("tools.private.release.release.pathlib.Path") + @patch("tools.private.release.release.changelog_news") + @patch("tools.private.release.release.replace_version_next") + def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog, mock_path): + # Arrange + args = MagicMock(version="2.0.0", issue=None) + self.mock_git.status.side_effect = ["", "M foo"] + self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = ValueError( + "Multiple open tracking issues" + ) + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 1) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_gh.create_tracking_issue.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + + +class CmdCreateRcTest(unittest.TestCase): + def setUp(self): + self.mock_git = patch("tools.private.release.release.git").start() + self.mock_gh = patch("tools.private.release.release.gh").start() + self.addCleanup(patch.stopall) + + def test_create_rc_success_first_rc(self): + # Arrange + args = MagicMock(issue=123) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_tags.return_value = [] + self.mock_git.get_tags_at_head.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = releaser.cmd_create_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD") + self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc0") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("tag=2.0.0-rc0", call_args[1]) + self.assertIn("commit=12345678", call_args[1]) + + self.mock_gh.post_issue_comment.assert_called_once() + + def test_create_rc_success_next_rc(self): + # Arrange + args = MagicMock(issue=123) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag RC1 | status=pending +""" + self.mock_git.get_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_tags_at_head.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = releaser.cmd_create_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD") + self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc1") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("tag=2.0.0-rc1", call_args[1]) + + self.mock_gh.post_issue_comment.assert_called_once() + + def test_create_rc_already_tagged(self): + # Arrange + args = MagicMock(issue=123) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_tags.return_value = [] + self.mock_git.get_tags_at_head.return_value = ["2.0.0-rc0"] + + # Act + result = releaser.cmd_create_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + +class CmdPromoteRcTest(unittest.TestCase): + def setUp(self): + self.mock_git = patch("tools.private.release.release.git").start() + self.mock_gh = patch("tools.private.release.release.gh").start() + self.addCleanup(patch.stopall) + + def test_promote_rc_success(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123, dry_run=False) + self.mock_git.get_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_commit_sha.return_value = "abcdef123456" + self.mock_git.tag_exists.return_value = False + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("upstream", tags=True, force=True) + self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag_exists.assert_called_once_with("2.0.0") + self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") + self.mock_git.push.assert_called_once_with("upstream", "2.0.0") + + # Verify issue update + self.mock_gh.get_issue_body.assert_called_once_with(123) + expected_updated_body = ( + "- [x] Tag Final | status=done tag=2.0.0 commit=abcdef12" + ) + self.mock_gh.update_issue_body.assert_called_once_with( + 123, expected_updated_body + ) + expected_comment = ( + "Version 2.0.0 has been tagged.\n\n" + "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" + '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' + ) + self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) + + def test_promote_rc_resolve_issue_success(self): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = False + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_git.get_commit_sha.return_value = "abcdef123456" + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") + self.mock_git.push.assert_called_once_with("upstream", "2.0.0") + self.mock_gh.get_issue_body.assert_called_once_with(123) + expected_updated_body = ( + "- [x] Tag Final | status=done tag=2.0.0 commit=abcdef12" + ) + self.mock_gh.update_issue_body.assert_called_once_with( + 123, expected_updated_body + ) + expected_comment = ( + "Version 2.0.0 has been tagged.\n\n" + "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" + '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' + ) + self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) + + def test_promote_rc_defaults_to_determine_next_version(self): + # Arrange + args = MagicMock(version=None, issue=123, dry_run=False) + self.mock_git.get_current_branch.return_value = "release/2.0" + self.mock_git.get_tags.return_value = ["2.0.0", "2.0.1-rc0"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.tag_exists.return_value = False + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.get_current_branch.assert_called_once() + self.assertTrue(self.mock_git.get_tags.call_count >= 2) + + self.mock_git.checkout.assert_not_called() + self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0") + self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") + self.mock_git.push.assert_called_once_with("upstream", "2.0.1") + + expected_updated_body = ( + "- [x] Tag Final | status=done tag=2.0.1 commit=12345678" + ) + self.mock_gh.update_issue_body.assert_called_once_with( + 123, expected_updated_body + ) + expected_comment = ( + "Version 2.0.1 has been tagged.\n\n" + "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1\n" + '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.1" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)' + ) + self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) + + def test_promote_rc_dry_run_success(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123, dry_run=True) + self.mock_git.get_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_commit_sha.return_value = "abcdef123456" + self.mock_git.tag_exists.return_value = False + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("upstream", tags=True, force=True) + self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.tag_exists.assert_called_once_with("2.0.0") + + # Core dry-run assertions: NO modifications + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + self.mock_gh.post_issue_comment.assert_not_called() + + def test_promote_rc_tag_already_exists(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123) + self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = True + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 1) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.get_issue_body.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_promote_rc_issue_not_found(self): + # Arrange + args = MagicMock(version="2.0.0", issue=None) + self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found") + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 1) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.get_issue_body.assert_not_called() + + def test_promote_rc_issue_malformed(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123) + self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = False + self.mock_git.get_commit_sha.return_value = "abcdef123456" + initial_body = "malformed body" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 1) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_promote_rc_no_rc_found(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123) + self.mock_git.get_tags.return_value = [] + + # Act + result = releaser.cmd_promote_rc(args) + + # Assert + self.assertEqual(result, 1) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_gh.get_issue_body.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 9fa94eee20..09a0d3ae47 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -6,41 +6,62 @@ from tools.private.release.utils import run_cmd +_REPO = "bazel-contrib/rules_python" +_LABEL = "type: release" + + +def list_issues(*, fields, label=None, state=None, search=None): + """Helper to list issues using gh CLI.""" + cmd = ["gh", "issue", "list", f"--repo={_REPO}"] + if label: + cmd.append(f"--label={label}") + if state: + cmd.append(f"--state={state}") + if search: + cmd.append(f"--search={search}") + cmd.append(f"--json={fields}") + + output = run_cmd(*cmd) + return json.loads(output) if output else [] -def get_open_tracking_issues(): - """Returns a list of open tracking issues with the 'type:release' label.""" - output = run_cmd( - "gh", - "issue", - "list", - "--label=type:release", - "--state=open", - "--json=number,title,url", + +def get_open_tracking_issues(version=None): + """Returns a list of open tracking issues with the 'type: release' label.""" + search = f'"Release {version}" in:title' if version else None + return list_issues( + label=_LABEL, + state="open", + search=search, + fields="number,title,url", ) - return json.loads(output) if output else [] -def resolve_issue_number(version): +def get_release_tracking_issue(version): """Resolves the tracking issue number for a given version. - Searches for an open issue with label 'type:release' and 'Release ' in the title. + Searches for an open issue with label 'type: release' and 'Release ' in the title. Raises ValueError if 0 or multiple issues are found. """ - matching_issues = [] - for issue in get_open_tracking_issues(): - if f"Release {version}" in issue["title"]: - matching_issues.append(issue) - - if not matching_issues: - raise ValueError(f"No open tracking issue found matching 'Release {version}'") - if len(matching_issues) > 1: - urls = [issue["url"] for issue in matching_issues] + matching_issues = get_open_tracking_issues(version) + + exact_matches = [] + for issue in matching_issues: + if issue["title"] == f"Release {version}": + exact_matches.append(issue) + + if not exact_matches: + raise ValueError( + f"No open tracking issue found matching 'Release {version}' " + f"in repo {_REPO} with label '{_LABEL}'" + ) + if len(exact_matches) > 1: + urls = [issue["url"] for issue in exact_matches] raise ValueError( - f"Multiple open tracking issues found for version {version}:\n" - + "\n".join(urls) + f"Multiple open tracking issues found for version {version} " + f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls) ) - return matching_issues[0]["number"] + return exact_matches[0]["number"] def create_tracking_issue(version, template_content): @@ -63,7 +84,7 @@ def create_tracking_issue(version, template_content): "issue", "create", f"--title=Release {version}", - "--label=type:release", + f"--label={_LABEL}", f"--body-file={temp_path}", ) issue_url = output.strip() diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 9bfd905109..4e623e2f7a 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -59,9 +59,9 @@ def merge(commit_ref, ff_only=True): run_cmd(*cmd, capture_output=False) -def tag(tag_name): - """Creates a local tag pointing to HEAD.""" - run_cmd("git", "tag", tag_name, capture_output=False) +def tag(tag_name, commit_ref): + """Creates a local tag pointing to a specific commit.""" + run_cmd("git", "tag", tag_name, commit_ref, capture_output=False) def cherry_pick(sha): diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 0e509f1bf3..3c2eed82ff 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -76,7 +76,7 @@ def get_latest_version(): def get_latest_rc_tag(version): """Queries git tags and returns the highest RC tag for the version.""" tags = git.get_tags() - pattern = rf"^v{re.escape(version)}-rc\d+$" + pattern = rf"^{re.escape(version)}-rc\d+$" rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] if not rc_tags: return None @@ -227,7 +227,10 @@ def update_task_in_body(body, task_name, checked, metadata): updated_lines.append(line) if not found: - raise ValueError(f"Task '{task_name}' not found in issue body.") + raise ValueError( + f"Task '{task_name}' not found in issue body. " + f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')" + ) return "\n".join(updated_lines) @@ -413,13 +416,13 @@ def cmd_prepare(args): issue_num = args.issue if not issue_num: - open_issues = gh.get_open_tracking_issues() - for issue in open_issues: - if f"Release {version}" in issue["title"]: - issue_num = issue["number"] - break - - if not issue_num: + try: + issue_num = gh.get_release_tracking_issue(version) + print(f"Found active tracking issue #{issue_num} for v{version}") + except ValueError as e: + if "Multiple open tracking issues" in str(e): + print(f"Error: {e}") + return 1 print( f"No active tracking issue found for v{version}. Creating a new one..." ) @@ -710,18 +713,18 @@ def cmd_create_rc(args): if not latest_rc: next_rc_num = 0 - next_rc = f"v{version}-rc0" + next_rc = f"{version}-rc0" else: rc_num = int(latest_rc.split("-rc")[-1]) next_rc_num = rc_num + 1 - next_rc = f"v{version}-rc{next_rc_num}" + next_rc = f"{version}-rc{next_rc_num}" # Precheck: next RC number must exist and be unchecked in the checklist rc_tags = state.get("rc_tags", {}) if next_rc_num not in rc_tags: print( f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" - f" to cut v{version}-rc{next_rc_num}." + f" to cut {version}-rc{next_rc_num}." ) return 1 @@ -735,12 +738,12 @@ def cmd_create_rc(args): # Verify HEAD is not already tagged git.checkout(branch_name) head_tags = git.get_tags_at_head() - if any(tag.startswith(f"v{version}-rc") for tag in head_tags): + if any(tag.startswith(f"{version}-rc") for tag in head_tags): print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") return 0 print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc) + git.tag(next_rc, "HEAD") git.push("origin", next_rc) commit_sha = git.get_commit_sha("HEAD") @@ -770,49 +773,83 @@ def cmd_promote_rc(args): version = args.version if version is None: version = determine_next_version() - version = version.replace("v", "") - final_tag = f"v{version}" - git.fetch("--tags", "--force") + # Fetch from upstream to ensure we have the latest tags + git.fetch("upstream", tags=True, force=True) latest_rc = get_latest_rc_tag(version) if not latest_rc: - print(f"Error: No release candidate tags found matching v{version}-rc*") + print(f"Error: No release candidate tags found matching {version}-rc*") return 1 - print(f"Promoting {latest_rc} to final release {final_tag}...") - git.checkout(latest_rc) - - commit_sha = git.get_commit_sha("HEAD") - - if not git.tag_exists(final_tag): - git.tag(final_tag) - git.push("origin", final_tag) - else: - print(f"Final tag {final_tag} already exists.") + # Verify final tag doesn't already exist + if git.tag_exists(version): + print(f"Error: Final tag {version} already exists.") + return 1 - # Resolve issue number + # Verify issue can be found issue_num = args.issue if not issue_num: try: - issue_num = gh.resolve_issue_number(version) + issue_num = gh.get_release_tracking_issue(version) + except ValueError as e: + print(f"Error: {e}") + return 1 except Exception as e: - print(f"Warning: Could not query GitHub to find tracking issue: {e}") + print(f"Error: Unexpected error finding tracking issue: {e}") + return 1 + + # Get commit SHA of the RC tag (which will be the same for the final tag) + commit_sha = git.get_commit_sha(latest_rc) - if issue_num: - print(f"Updating tracking issue #{issue_num} checklist...") - body = gh.get_issue_body(issue_num) - metadata = {"status": "done", "tag": final_tag, "commit": commit_sha[:8]} + # Verify issue is in the right format by trying to prepare the update + print(f"Verifying tracking issue #{issue_num} format...") + body = gh.get_issue_body(issue_num) + metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} + try: updated_body = update_task_in_body( body, "Tag Final", checked=True, metadata=metadata ) - gh.update_issue_body(issue_num, updated_body) - print("Checklist updated successfully.") - return 0 - else: + except ValueError as e: + print(f"Error: Tracking issue #{issue_num} is malformed: {e}") + return 1 + + # All pre-conditions met, perform modifications + if args.dry_run: print( - "Error: No active tracking issue found or specified. Checklist was not updated." + f"[DRY RUN] Pre-conditions passed successfully for promoting {latest_rc} to {version}." ) - return 1 + print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") + print(f"[DRY RUN] Would push tag {version} to upstream") + print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") + print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") + return 0 + + print( + f"Promoting {latest_rc} to final release {version} (commit" + f" {commit_sha[:8]}) using tracking issue #{issue_num}..." + ) + + # Tag the specific commit without checkout, and push to upstream + git.tag(version, commit_sha) + git.push("upstream", version) + + print(f"Updating tracking issue #{issue_num} checklist...") + gh.update_issue_body(issue_num, updated_body) + + print(f"Posting comment to tracking issue #{issue_num}...") + import urllib.parse + + release_url = f"{_REPO_URL}/releases/tag/{version}" + bcr_query = f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" + comment_body = ( + f"Version {version} has been tagged.\n\n" + f"- **Release Page**: {release_url}\n" + f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" + ) + gh.post_issue_comment(issue_num, comment_body) + + return 0 def create_parser(): @@ -924,6 +961,12 @@ def create_parser(): type=int, help="The tracking issue number (optional).", ) + promote_parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) return parser diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 5a8411b657..5eb7c43a71 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -1,5 +1,6 @@ """Utility functions for the release tool.""" +import shlex import subprocess @@ -10,7 +11,7 @@ def run_cmd(*args, check=True, capture_output=True): a detailed note explaining the failure to preserve the stack trace. """ cmd = [str(arg) for arg in args] - print(f"Running: {' '.join(cmd)}") + print(f"Running: {shlex.join(cmd)}") try: result = subprocess.run( cmd, @@ -21,7 +22,7 @@ def run_cmd(*args, check=True, capture_output=True): ) return result.stdout.strip() if capture_output else None except subprocess.CalledProcessError as e: - note = f"Error running command: {' '.join(cmd)}" + note = f"Error running command: {shlex.join(cmd)}" if capture_output: note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" e.add_note(note) From 9734b11bbd1ec2d04431bd0865b1a59778473397 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 18:05:41 -0700 Subject: [PATCH 038/179] chore(release): add dry-run to release tool prepare command (#3869) To enable safe testing of the release pipeline, this change introduces a --dry-run flag to the prepare command that simulates all repository and GitHub actions. This also fixes a few issues with the prepare command not fully working end-to-end. Along the way, factor out the code a bit. release.py was over 1000 lines and becoming unwieldly. --- .github/workflows/prepare_release.yml | 2 +- tests/tools/private/release/release_test.py | 253 ++++++++++------ tools/private/release/BUILD.bazel | 3 + tools/private/release/gh.py | 22 +- tools/private/release/git.py | 15 +- tools/private/release/prepare.py | 144 +++++++++ tools/private/release/release.py | 314 ++------------------ tools/private/release/release_issue.py | 62 ++++ tools/private/release/shell.py | 29 ++ tools/private/release/utils.py | 182 ++++++++++-- 10 files changed, 601 insertions(+), 425 deletions(-) create mode 100644 tools/private/release/prepare.py create mode 100644 tools/private/release/release_issue.py create mode 100644 tools/private/release/shell.py diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 7d5e93aa6b..b080f121eb 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -31,6 +31,6 @@ jobs: - name: Run Release Preparation Pipeline run: | # Manual trigger: run full preparation - bazel run //tools/private/release -- prepare + bazel run //tools/private/release -- prepare --no-dry-run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 4ec7de53ba..534aa2a5fd 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -5,19 +5,48 @@ import unittest from unittest.mock import MagicMock, patch -from tools.private.release import changelog_news, release as releaser +from tools.private.release import changelog_news, release as releaser, utils +from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError -class ReleaserTest(unittest.TestCase): +def _mock_git_and_gh(test_case): + mock_git = MagicMock() + mock_gh = MagicMock() + test_case.mock_git = mock_git + test_case.mock_gh = mock_gh + + # Patch bindings in modules that import them at module level + patch("tools.private.release.release.git", new=mock_git).start() + patch("tools.private.release.prepare.git", new=mock_git).start() + patch("tools.private.release.utils.git", new=mock_git).start() + + patch("tools.private.release.release.gh", new=mock_gh).start() + patch("tools.private.release.prepare.gh", new=mock_gh).start() + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError + mock_gh.NoTrackingIssueError = NoTrackingIssueError + + test_case.addCleanup(patch.stopall) + + # Apply safe defaults + mock_git.get_current_branch.return_value = None + mock_git.get_tags.return_value = [] + mock_git.get_tags_at_head.return_value = [] + mock_git.status.return_value = "" + mock_git.branch_exists.return_value = False + mock_git.tag_exists.return_value = False + mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") + + +class TempDirTestCase(unittest.TestCase): def setUp(self): self.tmpdir = pathlib.Path(tempfile.mkdtemp()) self.original_cwd = os.getcwd() self.addCleanup(shutil.rmtree, self.tmpdir) - os.chdir(self.tmpdir) - # NOTE: On windows, this must be done before files are deleted. self.addCleanup(os.chdir, self.original_cwd) + +class ReleaserTest(TempDirTestCase): def test_update_changelog_with_news(self): # Arrange changelog = """# Changelog @@ -431,7 +460,7 @@ def test_replace_version_next(self): """ (self.tmpdir / "mock_file.bzl").write_text(mock_file_content) - releaser.replace_version_next("0.28.0") + utils.replace_version_next("0.28.0") new_content = (self.tmpdir / "mock_file.bzl").read_text() @@ -462,7 +491,7 @@ def test_replace_version_next_excludes_bazel_dirs(self): version = "0.28.0" # Act - releaser.replace_version_next(version) + utils.replace_version_next(version) # Assert new_content = (bazel_dir / "mock_file.bzl").read_text() @@ -490,56 +519,56 @@ def test_invalid_version(self): class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_success(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] - self.assertEqual(releaser.get_latest_version(), "1.0.0") + self.assertEqual(utils.get_latest_version(), "1.0.0") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_rc_is_latest(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] with self.assertRaisesRegex( ValueError, "The latest version is a pre-release version: 1.1.0rc0" ): - releaser.get_latest_version() + utils.get_latest_version() - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] with self.assertRaisesRegex( RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." ): - releaser.get_latest_version() + utils.get_latest_version() - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["v1.0", "latest"] with self.assertRaisesRegex( RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." ): - releaser.get_latest_version() + utils.get_latest_version() - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_only_rc_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] with self.assertRaisesRegex( ValueError, "The latest version is a pre-release version: 1.1.0rc0" ): - releaser.get_latest_version() + utils.get_latest_version() class GetLatestRcTagTest(unittest.TestCase): - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] - self.assertIsNone(releaser.get_latest_rc_tag("2.0.0")) + self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0"] - self.assertIsNone(releaser.get_latest_rc_tag("2.0.0")) + self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_success(self, mock_get_tags): mock_get_tags.return_value = [ "2.0.0-rc0", @@ -547,26 +576,19 @@ def test_get_latest_rc_tag_success(self, mock_get_tags): "2.0.0-rc1", "2.1.0-rc0", ] - self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") + self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] - self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") + self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") -class DetermineNextVersionTest(unittest.TestCase): +class DetermineNextVersionTest(TempDirTestCase): def setUp(self): - self.tmpdir = pathlib.Path(tempfile.mkdtemp()) - self.original_cwd = os.getcwd() - self.addCleanup(shutil.rmtree, self.tmpdir) - - os.chdir(self.tmpdir) - # NOTE: On windows, this must be done before files are deleted. - self.addCleanup(os.chdir, self.original_cwd) - + super().setUp() self.mock_get_latest_version = patch( - "tools.private.release.release.get_latest_version" + "tools.private.release.utils.get_latest_version" ).start() self.addCleanup(patch.stopall) @@ -574,7 +596,7 @@ def test_no_markers(self): (self.tmpdir / "mock_file.bzl").write_text("no markers here") self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.2.4") @@ -584,7 +606,7 @@ def test_only_patch(self): ) self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.2.4") @@ -594,7 +616,7 @@ def test_only_feature(self): ) self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.3.0") @@ -607,36 +629,36 @@ def test_both_markers(self): ) self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.3.0") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_with_existing_tags( self, mock_get_tags, mock_get_branch ): mock_get_branch.return_value = "release/0.37" mock_get_tags.return_value = ["0.37.0", "0.37.1", "0.36.0"] - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_no_tags( self, mock_get_tags, mock_get_branch ): mock_get_branch.return_value = "release/0.38" mock_get_tags.return_value = ["0.37.0"] # No 0.38.x tags - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "0.38.0") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_with_active_rc( self, mock_get_tags, mock_get_branch ): @@ -644,13 +666,13 @@ def test_determine_next_version_on_release_branch_with_active_rc( # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet mock_get_tags.return_value = ["0.37.0-rc0", "0.37.0-rc1", "0.36.0"] - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() # Should target 0.37.0, not 0.37.1 self.assertEqual(next_version, "0.37.0") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( self, mock_get_tags, mock_get_branch ): @@ -658,39 +680,36 @@ def test_determine_next_version_on_release_branch_with_stable_and_active_patch_r # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet) mock_get_tags.return_value = ["0.37.0", "0.37.1-rc0", "0.36.0"] - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() # Should target 0.37.1, not 0.37.2 self.assertEqual(next_version, "0.37.1") - @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.git.get_current_branch") def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): mock_get_branch.return_value = "main" # Should fallback to default behavior (which uses mock_get_latest_version from setUp) self.mock_get_latest_version.return_value = "1.2.3" (self.tmpdir / "mock_file.bzl").write_text("no markers here") - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.2.4") -class CmdPrepareTest(unittest.TestCase): +class CmdPrepareTest(TempDirTestCase): def setUp(self): - self.mock_git = patch("tools.private.release.release.git").start() - self.mock_gh = patch("tools.private.release.release.gh").start() - self.addCleanup(patch.stopall) + super().setUp() + _mock_git_and_gh(self) - @patch("tools.private.release.release.pathlib.Path") - @patch("tools.private.release.release.changelog_news") - @patch("tools.private.release.release.replace_version_next") - def test_prepare_success_existing_issue( - self, mock_replace, mock_changelog, mock_path - ): + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): # Arrange - args = MagicMock(version="2.0.0", issue=None) + args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.status.side_effect = ["", "M foo"] self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = None self.mock_gh.get_release_tracking_issue.return_value = 123 self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" @@ -702,28 +721,28 @@ def test_prepare_success_existing_issue( self.assertEqual(result, 0) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_git.add_modified_and_deleted.assert_called_once() - @patch("tools.private.release.release.pathlib.Path") - @patch("tools.private.release.release.changelog_news") - @patch("tools.private.release.release.replace_version_next") - def test_prepare_success_create_issue( - self, mock_replace, mock_changelog, mock_path - ): + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_success_create_issue(self, mock_replace, mock_changelog): # Arrange - args = MagicMock(version="2.0.0", issue=None) + template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + template_file = template_dir / "release_tracking_template.md" + template_file.write_text("dummy template content") + + args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.status.side_effect = ["", "M foo"] self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found") + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) self.mock_gh.create_tracking_issue.return_value = 123 self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - mock_template = MagicMock() - mock_template.exists.return_value = True - mock_template.read_text.return_value = "template content" - mock_path.return_value = mock_template - # Act result = releaser.cmd_prepare(args) @@ -731,20 +750,20 @@ def test_prepare_success_create_issue( self.assertEqual(result, 0) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_called_once_with( - "2.0.0", "template content" + "2.0.0", "dummy template content" ) - self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_git.add_modified_and_deleted.assert_called_once() - @patch("tools.private.release.release.pathlib.Path") - @patch("tools.private.release.release.changelog_news") - @patch("tools.private.release.release.replace_version_next") - def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog, mock_path): + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): # Arrange - args = MagicMock(version="2.0.0", issue=None) + args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.status.side_effect = ["", "M foo"] self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ValueError( - "Multiple open tracking issues" + self.mock_gh.get_release_tracking_issue.side_effect = ( + MultipleTrackingIssuesError("Multiple open tracking issues") ) # Act @@ -755,13 +774,60 @@ def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog, mock_path): self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_not_called() self.mock_gh.create_pr.assert_not_called() + self.mock_git.add_modified_and_deleted.assert_not_called() + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_dry_run(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=True) + self.mock_git.status.side_effect = [""] + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_not_called() + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + self.mock_git.fetch.assert_called_once() + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_git.add_modified_and_deleted.assert_not_called() + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): + # Arrange + template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + template_file = template_dir / "release_tracking_template.md" + template_file.write_text("dummy template content") + + args = MagicMock(version="2.0.0", issue=None, dry_run=True) + self.mock_git.status.side_effect = [""] + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_not_called() + self.mock_gh.create_tracking_issue.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + self.mock_git.add_modified_and_deleted.assert_not_called() class CmdCreateRcTest(unittest.TestCase): def setUp(self): - self.mock_git = patch("tools.private.release.release.git").start() - self.mock_gh = patch("tools.private.release.release.gh").start() - self.addCleanup(patch.stopall) + _mock_git_and_gh(self) def test_create_rc_success_first_rc(self): # Arrange @@ -848,9 +914,7 @@ def test_create_rc_already_tagged(self): class CmdPromoteRcTest(unittest.TestCase): def setUp(self): - self.mock_git = patch("tools.private.release.release.git").start() - self.mock_gh = patch("tools.private.release.release.gh").start() - self.addCleanup(patch.stopall) + _mock_git_and_gh(self) def test_promote_rc_success(self): # Arrange @@ -893,6 +957,7 @@ def test_promote_rc_resolve_issue_success(self): args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.get_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = None self.mock_gh.get_release_tracking_issue.return_value = 123 self.mock_git.get_commit_sha.return_value = "abcdef123456" initial_body = "- [ ] Tag Final" @@ -1004,7 +1069,9 @@ def test_promote_rc_issue_not_found(self): args = MagicMock(version="2.0.0", issue=None) self.mock_git.get_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found") + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) # Act result = releaser.cmd_promote_rc(args) diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 747cb74e10..96f864141d 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -12,7 +12,10 @@ py_binary( srcs = [ "gh.py", "git.py", + "prepare.py", "release.py", + "release_issue.py", + "shell.py", "utils.py", ], main = "release.py", diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 09a0d3ae47..f37d87f396 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -4,12 +4,24 @@ import os import tempfile -from tools.private.release.utils import run_cmd +from tools.private.release.shell import run_cmd _REPO = "bazel-contrib/rules_python" _LABEL = "type: release" +class MultipleTrackingIssuesError(ValueError): + """Raised when multiple open tracking issues are found for a version.""" + + pass + + +class NoTrackingIssueError(ValueError): + """Raised when no open tracking issue is found for a version.""" + + pass + + def list_issues(*, fields, label=None, state=None, search=None): """Helper to list issues using gh CLI.""" cmd = ["gh", "issue", "list", f"--repo={_REPO}"] @@ -50,13 +62,13 @@ def get_release_tracking_issue(version): exact_matches.append(issue) if not exact_matches: - raise ValueError( + raise NoTrackingIssueError( f"No open tracking issue found matching 'Release {version}' " f"in repo {_REPO} with label '{_LABEL}'" ) if len(exact_matches) > 1: urls = [issue["url"] for issue in exact_matches] - raise ValueError( + raise MultipleTrackingIssuesError( f"Multiple open tracking issues found for version {version} " f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls) ) @@ -138,7 +150,7 @@ def update_issue_body(issue_num, body): os.unlink(temp_path) -def create_pr(version, branch, issue_num): +def create_pr(version, issue_num): """Creates a pull request for release preparation.""" return run_cmd( "gh", @@ -146,9 +158,7 @@ def create_pr(version, branch, issue_num): "create", f"--title=Prepare release v{version}", f"--body=Work towards #{issue_num}", - f"--head={branch}", "--base=main", - "--label=release-prepared", ) diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 4e623e2f7a..9c2662d4e6 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -2,7 +2,7 @@ import subprocess -from tools.private.release.utils import run_cmd +from tools.private.release.shell import run_cmd def get_tags(): @@ -24,6 +24,11 @@ def add(*files): run_cmd("git", "add", *files, capture_output=False) +def add_modified_and_deleted(): + """Stages all modified and deleted tracked files.""" + run_cmd("git", "add", "--update", capture_output=False) + + def commit(message, amend=False, no_edit=False): """Commits staged changes, optionally amending the previous commit.""" cmd = ["git", "commit"] @@ -36,9 +41,13 @@ def commit(message, amend=False, no_edit=False): run_cmd(*cmd, capture_output=False) -def push(remote, ref): +def push(remote, ref, set_upstream=False): """Pushes a reference to a remote repository.""" - run_cmd("git", "push", remote, ref, capture_output=False) + cmd = ["git", "push"] + if set_upstream: + cmd.append("-u") + cmd.extend([remote, ref]) + run_cmd(*cmd, capture_output=False) def fetch(remote="origin", tags=False, force=False): diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py new file mode 100644 index 0000000000..1d946bda6c --- /dev/null +++ b/tools/private/release/prepare.py @@ -0,0 +1,144 @@ +import datetime +import pathlib + +from tools.private.release import changelog_news, gh, git +from tools.private.release.release_issue import update_task_in_body +from tools.private.release.utils import ( + determine_next_version, + replace_version_next, +) + + +def cmd_prepare(args): + """Executes the prepare subcommand.""" + print("Fetching upstream to verify fresh release history...") + git.fetch(tags=True, force=True) + + # Run pre-check: verify there are no local edits + status = git.status() + if status: + print( + "Error: Local edits detected. Workspace must be completely clean" + " before running release preparation." + ) + for line in status.splitlines(): + print(f" {line}") + return 1 + print("Pre-check passed: Workspace is clean.") + + version = args.version + if version is None: + version = determine_next_version() + + print(f"Running preparation pipeline for {version}...") + + # 1. Find or create tracking issue (EARLY) + # We do this before any write operations (branch creation, commit, push) + issue_num = args.issue + + if not issue_num: + try: + issue_num = gh.get_release_tracking_issue(version) + print(f"Tracking issue: #{issue_num}") + except gh.MultipleTrackingIssuesError as e: + print(f"Error: {e}") + return 1 + except gh.NoTrackingIssueError: + # Not found, we need the template + template_path = pathlib.Path( + ".github/ISSUE_TEMPLATE/release_tracking_template.md" + ) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + template_content = template_path.read_text(encoding="utf-8") + + if args.dry_run: + print( + f"[DRY RUN] No active tracking issue found for {version}. Would create a new one." + ) + print(f"[DRY RUN] Title: Release {version}\n{template_content}") + issue_num = None # Keep it None for dry-run prints later + else: + print( + f"No active tracking issue found for {version}. Creating a new one..." + ) + issue_num = gh.create_tracking_issue(version, template_content) + print(f"Tracking issue: #{issue_num}") + else: + print(f"Tracking issue: #{issue_num}") + + branch_name = f"prepare-{version}" + + # 2. Interleaved git and write operations + + # --- Branch selection/creation --- + if git.branch_exists(branch_name): + if args.dry_run: + print( + f"[DRY RUN] Branch {branch_name} already exists. Would checkout existing branch." + ) + else: + print(f"Branch {branch_name} already exists. Checking it out...") + git.checkout(branch_name) + else: + if args.dry_run: + print(f"[DRY RUN] Would create and checkout branch {branch_name}") + else: + git.checkout(branch_name, create_branch=True) + + # --- Update files --- + if args.dry_run: + print( + f"[DRY RUN] Would update CHANGELOG.md and version placeholders for {version}" + ) + else: + print("Updating changelog and placeholders...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + replace_version_next(version) + + # --- Commit and Push --- + if args.dry_run: + print(f"[DRY RUN] Would push branch {branch_name} to origin") + else: + modified_files = git.status() + if not modified_files: + print("No files modified by the release tool. Nothing to commit.") + return 0 + + # Stage all modified and deleted tracked files + git.add_modified_and_deleted() + + git.commit(f"Prepare release {version}") + git.push("origin", branch_name, set_upstream=True) + + # --- Create PR --- + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}" + ) + else: + pr_url = gh.create_pr(version, issue_num) + pr_num = pr_url.split("/")[-1] + print(f"Created Pull Request: {pr_url} (PR #{pr_num})") + + # --- Update checklist --- + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would update tracking issue {target_issue} checklist 'Prepare Release' task status to PENDING" + ) + else: + print( + f"Updating tracking issue #{issue_num} checklist 'Prepare Release' task status to PENDING..." + ) + body = gh.get_issue_body(issue_num) + metadata = {"status": "pending", "pr": f"#{pr_num}"} + updated_body = update_task_in_body( + body, "Prepare Release", checked=False, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Preparation pipeline completed successfully!") + + return 0 diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 3c2eed82ff..d9a225d5cf 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -2,167 +2,26 @@ import argparse import datetime -import fnmatch import os import pathlib import re import sys -from packaging.version import parse as parse_version - from tools.private.release import changelog_news, gh, git - -_REPO_URL = "https://github.com/bazel-contrib/rules_python" - -_EXCLUDE_PATTERNS = [ - "./.git/*", - "./.github/*", - "./.bazelci/*", - "./.bcr/*", - "./bazel-*/*", - "./CONTRIBUTING.md", - "./RELEASING.md", - "./tools/private/release/*", - "./tests/tools/private/release/*", -] +from tools.private.release.prepare import cmd_prepare +from tools.private.release.release_issue import ( + parse_metadata_line, + update_task_in_body, +) +from tools.private.release.utils import ( + _REPO_URL, + determine_next_version, + get_latest_rc_tag, +) _RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) -def _iter_version_placeholder_files(): - for root, dirs, files in os.walk(".", topdown=True): - # Filter directories - dirs[:] = [ - d - for d in dirs - if not any( - fnmatch.fnmatch(os.path.join(root, d), pattern) - for pattern in _EXCLUDE_PATTERNS - ) - ] - - for filename in files: - filepath = os.path.join(root, filename) - if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS): - continue - - yield filepath - - -def get_latest_version(): - """Gets the latest version from git tags.""" - tags = git.get_tags() - versions = [ - (tag, parse_version(tag)) - for tag in tags - if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip()) - ] - if not versions: - raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.") - - versions.sort(key=lambda v: v[1]) - latest_tag, latest_version = versions[-1] - - if latest_version.is_prerelease: - raise ValueError(f"The latest version is a pre-release version: {latest_tag}") - - stable_versions = [tag for tag, version in versions if not version.is_prerelease] - if not stable_versions: - raise ValueError("No stable git tags found matching X.Y.Z format.") - - return stable_versions[-1] - - -def get_latest_rc_tag(version): - """Queries git tags and returns the highest RC tag for the version.""" - tags = git.get_tags() - pattern = rf"^{re.escape(version)}-rc\d+$" - rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] - if not rc_tags: - return None - rc_tags.sort(key=parse_version) - return rc_tags[-1] - - -def should_increment_minor(): - """Checks if the minor version should be incremented.""" - for filepath in _iter_version_placeholder_files(): - try: - with open(filepath, "r") as f: - content = f.read() - except (IOError, UnicodeDecodeError): - continue - - if "VERSION_NEXT_FEATURE" in content: - return True - return False - - -def determine_next_version(branch_name=None): - """Determines the next version based on git tags and the current branch.""" - if branch_name is None: - branch_name = git.get_current_branch() - - if branch_name: - release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name) - if release_match: - branch_major = int(release_match.group(1)) - branch_minor = int(release_match.group(2)) - print( - f"Detected release branch: {branch_name} (targeting" - f" {branch_major}.{branch_minor}.x)" - ) - - tags = git.get_tags() - matching_patches = [] - for tag in tags: - tag = tag.strip() - m = re.match(rf"^{branch_major}\.{branch_minor}\.(\d+)$", tag) - if m: - matching_patches.append(int(m.group(1))) - - if matching_patches: - latest_patch = max(matching_patches) - next_version = f"{branch_major}.{branch_minor}.{latest_patch + 1}" - print( - f"Latest tag on this branch is" - f" {branch_major}.{branch_minor}.{latest_patch}. Next" - f" version: {next_version}" - ) - return next_version - else: - next_version = f"{branch_major}.{branch_minor}.0" - print( - f"No stable tags found for {branch_major}.{branch_minor}.x." - f" Next version: {next_version}" - ) - return next_version - - latest_version = get_latest_version() - major, minor, patch = [int(n) for n in latest_version.split(".")] - - if should_increment_minor(): - return f"{major}.{minor + 1}.0" - else: - return f"{major}.{minor}.{patch + 1}" - - -def replace_version_next(version): - """Replaces all VERSION_NEXT_* placeholders with the new version.""" - for filepath in _iter_version_placeholder_files(): - try: - with open(filepath, "r") as f: - content = f.read() - except (IOError, UnicodeDecodeError): - continue - - if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content: - new_content = content.replace("VERSION_NEXT_FEATURE", version) - new_content = new_content.replace("VERSION_NEXT_PATCH", version) - with open(filepath, "w") as f: - f.write(new_content) - - def _semver_type(value): if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): raise argparse.ArgumentTypeError( @@ -176,65 +35,6 @@ def _semver_type(value): # ============================================================================== -def parse_metadata_line(line): - """Parses a checklist line with optional | key=value metadata.""" - match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line) - if not match: - return None - - checked = match.group(1).lower() == "x" - name = match.group(2).strip() - metadata_str = match.group(3) - - metadata = {} - if metadata_str: - pairs = metadata_str.strip().split() - for pair in pairs: - if "=" in pair: - k, v = pair.split("=", 1) - metadata[k] = v - - return { - "checked": checked, - "name": name, - "metadata": metadata, - "original_line": line, - } - - -def format_metadata_line(checked, name, metadata): - """Formats a checklist line with space-separated key=value metadata.""" - check_str = "x" if checked else " " - if not metadata: - return f"- [{check_str}] {name}" - - metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items()) - return f"- [{check_str}] {name} | {metadata_str}" - - -def update_task_in_body(body, task_name, checked, metadata): - """Updates a specific task's checked state and metadata in the issue body.""" - lines = body.splitlines() - updated_lines = [] - found = False - - for line in lines: - parsed = parse_metadata_line(line) - if parsed and parsed["name"].lower() == task_name.lower(): - updated_lines.append(format_metadata_line(checked, task_name, metadata)) - found = True - else: - updated_lines.append(line) - - if not found: - raise ValueError( - f"Task '{task_name}' not found in issue body. " - f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')" - ) - - return "\n".join(updated_lines) - - def parse_checklist_state(body): """Parses the main checklist tasks and their metadata.""" state = { @@ -366,91 +166,6 @@ def cmd_create_release_issue(args): return 0 -def cmd_prepare(args): - """Executes the prepare subcommand.""" - print("Fetching upstream to verify fresh release history...") - git.fetch(tags=True, force=True) - - # Run pre-check: verify there are no local edits - status = git.status() - if status: - print( - "Error: Local edits detected. Workspace must be completely clean" - " before running release preparation." - ) - for line in status.splitlines(): - print(f" {line}") - return 1 - print("Pre-check passed: Workspace is clean.") - - version = args.version - if version is None: - version = determine_next_version() - - print(f"Running preparation pipeline for v{version}...") - - branch_name = f"prepare-{version}" - if git.branch_exists(branch_name): - print(f"Branch {branch_name} already exists. Checking it out...") - git.checkout(branch_name) - else: - git.checkout(branch_name, create_branch=True) - - print("Updating changelog and placeholders...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - replace_version_next(version) - - modified_files = git.status() - if not modified_files: - print("No files modified by the release tool. Nothing to commit.") - return 0 - - # Stage only modified files - for line in modified_files.splitlines(): - file_path = line.strip().split()[-1] - git.add(file_path) - - git.commit(f"Prepare release {version}") - git.push("origin", branch_name) - - issue_num = args.issue - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(version) - print(f"Found active tracking issue #{issue_num} for v{version}") - except ValueError as e: - if "Multiple open tracking issues" in str(e): - print(f"Error: {e}") - return 1 - print( - f"No active tracking issue found for v{version}. Creating a new one..." - ) - template_path = pathlib.Path( - ".github/ISSUE_TEMPLATE/release_tracking_template.md" - ) - if not template_path.exists(): - raise FileNotFoundError(f"Template file not found at {template_path}") - template_content = template_path.read_text(encoding="utf-8") - issue_num = gh.create_tracking_issue(version, template_content) - - print(f"Using tracking issue #{issue_num}") - - pr_url = gh.create_pr(version, branch_name, issue_num) - pr_num = pr_url.split("/")[-1] - print(f"Created Pull Request: {pr_url} (PR #{pr_num})") - - print(f"Updating tracking issue #{issue_num} checklist status to PENDING...") - body = gh.get_issue_body(issue_num) - metadata = {"status": "pending", "pr": f"#{pr_num}"} - updated_body = update_task_in_body( - body, "Prepare Release", checked=False, metadata=metadata - ) - gh.update_issue_body(issue_num, updated_body) - print("Preparation pipeline completed successfully!") - return 0 - - def cmd_complete_prepare(args): """Executes the complete-prepare subcommand (Phase 2 PR merged).""" print(f"Completing preparation for PR #{args.pr}...") @@ -896,6 +611,12 @@ def create_parser(): type=int, help="The tracking issue number (optional, triggers automated branch/PR pipeline).", ) + prepare_parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) # Subcommand: complete-prepare complete_prep_parser = subparsers.add_parser( @@ -998,6 +719,9 @@ def main(): exit_code = cmd_promote_rc(args) except Exception as e: print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) + if hasattr(e, "__notes__"): + for note in e.__notes__: + print(note, file=sys.stderr) sys.exit(1) sys.exit(exit_code if exit_code is not None else 0) diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py new file mode 100644 index 0000000000..27bcf3d774 --- /dev/null +++ b/tools/private/release/release_issue.py @@ -0,0 +1,62 @@ +"""Helper functions for managing release tracking issues and checklists.""" + +import re + + +def parse_metadata_line(line): + """Parses a checklist line with optional | key=value metadata.""" + match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line) + if not match: + return None + + checked = match.group(1).lower() == "x" + name = match.group(2).strip() + metadata_str = match.group(3) + + metadata = {} + if metadata_str: + pairs = metadata_str.strip().split() + for pair in pairs: + if "=" in pair: + k, v = pair.split("=", 1) + metadata[k] = v + + return { + "checked": checked, + "name": name, + "metadata": metadata, + "original_line": line, + } + + +def format_metadata_line(checked, name, metadata): + """Formats a checklist line with space-separated key=value metadata.""" + check_str = "x" if checked else " " + if not metadata: + return f"- [{check_str}] {name}" + + metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items()) + return f"- [{check_str}] {name} | {metadata_str}" + + +def update_task_in_body(body, task_name, checked, metadata): + """Updates a specific task's checked state and metadata in the issue body.""" + lines = body.splitlines() + updated_lines = [] + found = False + + for line in lines: + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == task_name.lower(): + updated_lines.append(format_metadata_line(checked, task_name, metadata)) + found = True + else: + updated_lines.append(line) + + if not found: + raise ValueError( + f"Task '{task_name}' not found in issue body. " + f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')" + ) + + return "\n".join(updated_lines) diff --git a/tools/private/release/shell.py b/tools/private/release/shell.py new file mode 100644 index 0000000000..cfff53f4e4 --- /dev/null +++ b/tools/private/release/shell.py @@ -0,0 +1,29 @@ +"""Shell utility functions for the release tool.""" + +import shlex +import subprocess + + +def run_cmd(*args, check=True, capture_output=True): + """Runs a command as a subprocess with separate arguments (prints command). + + If the command fails, it raises the CalledProcessError after attaching + a detailed note explaining the failure to preserve the stack trace. + """ + cmd = [str(arg) for arg in args] + print(f"Running: {shlex.join(cmd)}") + try: + result = subprocess.run( + cmd, + check=check, + stdout=subprocess.PIPE if capture_output else None, + stderr=subprocess.PIPE if capture_output else None, + universal_newlines=True, + ) + return result.stdout.strip() if capture_output else None + except subprocess.CalledProcessError as e: + note = f"Error running command: {shlex.join(cmd)}" + if capture_output: + note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" + e.add_note(note) + raise diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 5eb7c43a71..83ecbb9c7a 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -1,29 +1,157 @@ """Utility functions for the release tool.""" -import shlex -import subprocess - - -def run_cmd(*args, check=True, capture_output=True): - """Runs a command as a subprocess with separate arguments (prints command). - - If the command fails, it raises the CalledProcessError after attaching - a detailed note explaining the failure to preserve the stack trace. - """ - cmd = [str(arg) for arg in args] - print(f"Running: {shlex.join(cmd)}") - try: - result = subprocess.run( - cmd, - check=check, - stdout=subprocess.PIPE if capture_output else None, - stderr=subprocess.PIPE if capture_output else None, - universal_newlines=True, - ) - return result.stdout.strip() if capture_output else None - except subprocess.CalledProcessError as e: - note = f"Error running command: {shlex.join(cmd)}" - if capture_output: - note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" - e.add_note(note) - raise +import fnmatch +import os +import re + +from packaging.version import parse as parse_version + +from tools.private.release import git + +_REPO_URL = "https://github.com/bazel-contrib/rules_python" + +_EXCLUDE_PATTERNS = [ + "./.git/*", + "./.github/*", + "./.bazelci/*", + "./.bcr/*", + "./bazel-*/*", + "./CONTRIBUTING.md", + "./RELEASING.md", + "./tools/private/release/*", + "./tests/tools/private/release/*", +] + + +def _iter_version_placeholder_files(): + for root, dirs, files in os.walk(".", topdown=True): + # Filter directories + dirs[:] = [ + d + for d in dirs + if not any( + fnmatch.fnmatch(os.path.join(root, d), pattern) + for pattern in _EXCLUDE_PATTERNS + ) + ] + + for filename in files: + filepath = os.path.join(root, filename) + if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS): + continue + + yield filepath + + +def get_latest_version(): + """Gets the latest version from git tags.""" + tags = git.get_tags() + versions = [ + (tag, parse_version(tag)) + for tag in tags + if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip()) + ] + if not versions: + raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.") + + versions.sort(key=lambda v: v[1]) + latest_tag, latest_version = versions[-1] + + if latest_version.is_prerelease: + raise ValueError(f"The latest version is a pre-release version: {latest_tag}") + + stable_versions = [tag for tag, version in versions if not version.is_prerelease] + if not stable_versions: + raise ValueError("No stable git tags found matching X.Y.Z format.") + + return stable_versions[-1] + + +def get_latest_rc_tag(version): + """Queries git tags and returns the highest RC tag for the version.""" + tags = git.get_tags() + pattern = rf"^{re.escape(version)}-rc\d+$" + rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] + if not rc_tags: + return None + rc_tags.sort(key=parse_version) + return rc_tags[-1] + + +def should_increment_minor(): + """Checks if the minor version should be incremented.""" + for filepath in _iter_version_placeholder_files(): + try: + with open(filepath, "r") as f: + content = f.read() + except (IOError, UnicodeDecodeError): + continue + + if "VERSION_NEXT_FEATURE" in content: + return True + return False + + +def determine_next_version(branch_name=None): + """Determines the next version based on git tags and the current branch.""" + if branch_name is None: + branch_name = git.get_current_branch() + + if branch_name: + release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name) + if release_match: + branch_major = int(release_match.group(1)) + branch_minor = int(release_match.group(2)) + print( + f"Detected release branch: {branch_name} (targeting" + f" {branch_major}.{branch_minor}.x)" + ) + + tags = git.get_tags() + matching_patches = [] + for tag in tags: + tag = tag.strip() + m = re.match(rf"^{branch_major}\.{branch_minor}\.(\d+)$", tag) + if m: + matching_patches.append(int(m.group(1))) + + if matching_patches: + latest_patch = max(matching_patches) + next_version = f"{branch_major}.{branch_minor}.{latest_patch + 1}" + print( + f"Latest tag on this branch is" + f" {branch_major}.{branch_minor}.{latest_patch}. Next" + f" version: {next_version}" + ) + return next_version + else: + next_version = f"{branch_major}.{branch_minor}.0" + print( + f"No stable tags found for {branch_major}.{branch_minor}.x." + f" Next version: {next_version}" + ) + return next_version + + latest_version = get_latest_version() + major, minor, patch = [int(n) for n in latest_version.split(".")] + + if should_increment_minor(): + return f"{major}.{minor + 1}.0" + else: + return f"{major}.{minor}.{patch + 1}" + + +def replace_version_next(version): + """Replaces all VERSION_NEXT_* placeholders with the new version.""" + for filepath in _iter_version_placeholder_files(): + try: + with open(filepath, "r") as f: + content = f.read() + except (IOError, UnicodeDecodeError): + continue + + if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content: + new_content = content.replace("VERSION_NEXT_FEATURE", version) + new_content = new_content.replace("VERSION_NEXT_PATCH", version) + with open(filepath, "w") as f: + f.write(new_content) From 8a825aae648d3cfe0ef32c0f822d00e9e8f3d5c8 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 19:16:14 -0700 Subject: [PATCH 039/179] chore(release): reuse existing PRs and avoid early exit in prep tool (#3870) Fix the release preparation tool to prevent it from exiting early when no local changes are present, ensuring the pipeline can always proceed. The tool now detects and reuses existing open branch PRs or PRs already associated with the tracking issue, avoiding the creation of duplicate PRs. --- tests/tools/private/release/release_test.py | 95 +++++++++++++++++++++ tools/private/release/gh.py | 16 ++++ tools/private/release/prepare.py | 50 ++++++++--- tools/private/release/release.py | 67 +-------------- tools/private/release/release_issue.py | 66 ++++++++++++++ 5 files changed, 215 insertions(+), 79 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 534aa2a5fd..a1467d8987 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -35,6 +35,7 @@ def _mock_git_and_gh(test_case): mock_git.branch_exists.return_value = False mock_git.tag_exists.return_value = False mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") + mock_gh.get_open_pr.return_value = None class TempDirTestCase(unittest.TestCase): @@ -799,6 +800,100 @@ def test_prepare_dry_run(self, mock_replace, mock_changelog): self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_git.add_modified_and_deleted.assert_not_called() + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_use_associated_pr_from_tracking_issue( + self, mock_replace, mock_changelog + ): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", ""] + self.mock_git.branch_exists.return_value = True + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.get_open_pr.return_value = None + # PR #456 is already associated in the tracking issue + self.mock_gh.get_issue_body.return_value = ( + "- [ ] Prepare Release | status=pending pr=#456" + ) + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True + ) + self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") + self.mock_gh.create_pr.assert_not_called() # Should NOT create a new PR + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertIn("pr=#456", call_args[1]) + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", ""] + self.mock_git.branch_exists.return_value = True + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.get_open_pr.return_value = None + # No PR associated in the tracking issue + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/789" + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True + ) + self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertIn("pr=#789", call_args[1]) + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", ""] + self.mock_git.branch_exists.return_value = True + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.get_open_pr.return_value = { + "number": 456, + "url": "https://github.com/foo/bar/pull/456", + } + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True + ) + self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") + self.mock_gh.create_pr.assert_not_called() + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertIn("pr=#456", call_args[1]) + @patch("tools.private.release.prepare.changelog_news") @patch("tools.private.release.prepare.replace_version_next") def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index f37d87f396..da86415650 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -162,6 +162,22 @@ def create_pr(version, issue_num): ) +def get_open_pr(branch_name): + """Returns PR info if an open PR exists for the given branch, else None.""" + cmd = [ + "gh", + "pr", + "list", + f"--repo={_REPO}", + f"--head={branch_name}", + "--state=open", + "--json=number,url", + ] + output = run_cmd(*cmd) + prs = json.loads(output) if output else [] + return prs[0] if prs else None + + def get_pr_info(pr_num): """Gets information about a PR, including state, merge commit, and body.""" output = run_cmd( diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 1d946bda6c..c9012e0d04 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -2,7 +2,10 @@ import pathlib from tools.private.release import changelog_news, gh, git -from tools.private.release.release_issue import update_task_in_body +from tools.private.release.release_issue import ( + parse_checklist_state, + update_task_in_body, +) from tools.private.release.utils import ( determine_next_version, replace_version_next, @@ -102,26 +105,47 @@ def cmd_prepare(args): print(f"[DRY RUN] Would push branch {branch_name} to origin") else: modified_files = git.status() - if not modified_files: + if modified_files: + # Stage all modified and deleted tracked files + git.add_modified_and_deleted() + git.commit(f"Prepare release {version}") + else: print("No files modified by the release tool. Nothing to commit.") - return 0 - - # Stage all modified and deleted tracked files - git.add_modified_and_deleted() - git.commit(f"Prepare release {version}") + print(f"Pushing branch {branch_name} to origin...") git.push("origin", branch_name, set_upstream=True) # --- Create PR --- - if args.dry_run: - target_issue = f"#{issue_num}" if issue_num else "" + # Determine if we need to create a PR or reuse an existing one + open_pr = gh.get_open_pr(branch_name) + associated_pr = None + + if not open_pr and issue_num: + body = gh.get_issue_body(issue_num) + state = parse_checklist_state(body) + associated_pr = state["prepare_release"]["pr"] + + if open_pr: + pr_num = open_pr["number"] + pr_url = open_pr["url"] + print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})") + elif associated_pr: + pr_num = associated_pr.lstrip("#") + pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}" print( - f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}" + f"PR #{pr_num} is already associated in tracking issue #{issue_num}. Using it." ) else: - pr_url = gh.create_pr(version, issue_num) - pr_num = pr_url.split("/")[-1] - print(f"Created Pull Request: {pr_url} (PR #{pr_num})") + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}" + ) + pr_num = "" + else: + pr_url = gh.create_pr(version, issue_num) + pr_num = pr_url.split("/")[-1] + print(f"Created Pull Request: {pr_url} (PR #{pr_num})") # --- Update checklist --- if args.dry_run: diff --git a/tools/private/release/release.py b/tools/private/release/release.py index d9a225d5cf..dab99e6d05 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -10,6 +10,7 @@ from tools.private.release import changelog_news, gh, git from tools.private.release.prepare import cmd_prepare from tools.private.release.release_issue import ( + parse_checklist_state, parse_metadata_line, update_task_in_body, ) @@ -35,72 +36,6 @@ def _semver_type(value): # ============================================================================== -def parse_checklist_state(body): - """Parses the main checklist tasks and their metadata.""" - state = { - "prepare_release": { - "checked": False, - "status": None, - "pr": None, - "commit": None, - }, - "create_branch": { - "checked": False, - "status": None, - "branch": None, - "commit": None, - }, - "tag_final": {"checked": False, "status": None, "tag": None, "commit": None}, - "rc_tags": {}, # Dynamically mapped: int -> metadata dict - } - - lines = body.splitlines() - for line in lines: - parsed = parse_metadata_line(line) - if not parsed: - continue - - name = parsed["name"].strip() - meta = parsed["metadata"] - checked = parsed["checked"] - name_lower = name.lower() - - if "prepare release" in name_lower: - state["prepare_release"] = { - "checked": checked, - "status": meta.get("status"), - "pr": meta.get("pr"), - "commit": meta.get("commit"), - } - elif "create release branch" in name_lower: - state["create_branch"] = { - "checked": checked, - "status": meta.get("status"), - "branch": meta.get("branch"), - "commit": meta.get("commit"), - } - elif "tag final" in name_lower: - state["tag_final"] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } - else: - # Match Tag RC - rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE) - if rc_match: - rc_num = int(rc_match.group(1)) - state["rc_tags"][rc_num] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } - - return state - - def parse_backports(body): """Parses the ## Backports checklist section.""" body = body.replace("\r\n", "\n") diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 27bcf3d774..f96b917ab9 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -60,3 +60,69 @@ def update_task_in_body(body, task_name, checked, metadata): ) return "\n".join(updated_lines) + + +def parse_checklist_state(body): + """Parses the main checklist tasks and their metadata.""" + state = { + "prepare_release": { + "checked": False, + "status": None, + "pr": None, + "commit": None, + }, + "create_branch": { + "checked": False, + "status": None, + "branch": None, + "commit": None, + }, + "tag_final": {"checked": False, "status": None, "tag": None, "commit": None}, + "rc_tags": {}, # Dynamically mapped: int -> metadata dict + } + + lines = body.splitlines() + for line in lines: + parsed = parse_metadata_line(line) + if not parsed: + continue + + name = parsed["name"].strip() + meta = parsed["metadata"] + checked = parsed["checked"] + name_lower = name.lower() + + if "prepare release" in name_lower: + state["prepare_release"] = { + "checked": checked, + "status": meta.get("status"), + "pr": meta.get("pr"), + "commit": meta.get("commit"), + } + elif "create release branch" in name_lower: + state["create_branch"] = { + "checked": checked, + "status": meta.get("status"), + "branch": meta.get("branch"), + "commit": meta.get("commit"), + } + elif "tag final" in name_lower: + state["tag_final"] = { + "checked": checked, + "status": meta.get("status"), + "tag": meta.get("tag"), + "commit": meta.get("commit"), + } + else: + # Match Tag RC + rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE) + if rc_match: + rc_num = int(rc_match.group(1)) + state["rc_tags"][rc_num] = { + "checked": checked, + "status": meta.get("status"), + "tag": meta.get("tag"), + "commit": meta.get("commit"), + } + + return state From 9af23e51c614f82709dbab55120030cd3d66ac08 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 20:53:55 -0700 Subject: [PATCH 040/179] chore(release): use force push and clean up git helpers in prep tool (#3873) This change fixes an edge case in the release preparation tool by using force push when pushing the preparation branch, resolving failures when the remote branch already exists. It also cleans up git helpers to use long option names and fail-fast on errors, with corresponding test updates. --- tests/tools/private/release/release_test.py | 10 +++++++--- tools/private/release/git.py | 13 ++++++------- tools/private/release/prepare.py | 3 ++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index a1467d8987..293b82b277 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -591,6 +591,10 @@ def setUp(self): self.mock_get_latest_version = patch( "tools.private.release.utils.get_latest_version" ).start() + self.mock_get_current_branch = patch( + "tools.private.release.git.get_current_branch" + ).start() + self.mock_get_current_branch.return_value = "main" self.addCleanup(patch.stopall) def test_no_markers(self): @@ -825,7 +829,7 @@ def test_prepare_use_associated_pr_from_tracking_issue( self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") self.mock_git.commit.assert_not_called() self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True + "origin", "prepare-2.0.0", set_upstream=True, force=True ) self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") self.mock_gh.create_pr.assert_not_called() # Should NOT create a new PR @@ -855,7 +859,7 @@ def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changel self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") self.mock_git.commit.assert_not_called() self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True + "origin", "prepare-2.0.0", set_upstream=True, force=True ) self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) @@ -886,7 +890,7 @@ def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") self.mock_git.commit.assert_not_called() self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True + "origin", "prepare-2.0.0", set_upstream=True, force=True ) self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") self.mock_gh.create_pr.assert_not_called() diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 9c2662d4e6..ed6e637d3f 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -41,11 +41,13 @@ def commit(message, amend=False, no_edit=False): run_cmd(*cmd, capture_output=False) -def push(remote, ref, set_upstream=False): +def push(remote, ref, set_upstream=False, force=False): """Pushes a reference to a remote repository.""" cmd = ["git", "push"] if set_upstream: - cmd.append("-u") + cmd.append("--set-upstream") + if force: + cmd.append("--force") cmd.extend([remote, ref]) run_cmd(*cmd, capture_output=False) @@ -128,8 +130,5 @@ def get_tags_at_head(): def get_current_branch(): - """Returns the current git branch name, or None if not in a git repo.""" - try: - return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") - except subprocess.CalledProcessError: - return None + """Returns the current git branch name.""" + return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index c9012e0d04..2d32ee4de0 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -113,7 +113,8 @@ def cmd_prepare(args): print("No files modified by the release tool. Nothing to commit.") print(f"Pushing branch {branch_name} to origin...") - git.push("origin", branch_name, set_upstream=True) + # Force push to overwrite the remote branch if it already exists (e.g. from a previous run) + git.push("origin", branch_name, set_upstream=True, force=True) # --- Create PR --- # Determine if we need to create a PR or reuse an existing one From ee0acbdcc9a010e6c3fe29df9474b5823821afd7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:05:05 +0000 Subject: [PATCH 041/179] Prepare release v2.2.0 (#3874) Work towards #3867 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 39 +++++++++++++++++++ .../python/config_settings/index.md | 2 +- docs/environment-variables.md | 2 +- docs/pypi/download.md | 2 +- news/2945.changed.md | 1 - news/3785.added.md | 5 --- news/3828.fixed.md | 2 - news/3832.fixed.md | 1 - news/3837.added.md | 1 - news/expose-venv-symlink.added.md | 2 - news/gazelle-bzl-library.changed.md | 3 -- news/loadable-symbols.added.md | 2 - news/slash-target-venv-output.fixed.md | 3 -- news/win32_version_lookup.fixed.md | 3 -- python/features.bzl | 2 +- python/private/py_info.bzl | 4 +- python/private/pypi/extension.bzl | 2 +- python/uv/private/lock.bzl | 2 +- 18 files changed, 47 insertions(+), 31 deletions(-) delete mode 100644 news/2945.changed.md delete mode 100644 news/3785.added.md delete mode 100644 news/3828.fixed.md delete mode 100644 news/3832.fixed.md delete mode 100644 news/3837.added.md delete mode 100644 news/expose-venv-symlink.added.md delete mode 100644 news/gazelle-bzl-library.changed.md delete mode 100644 news/loadable-symbols.added.md delete mode 100644 news/slash-target-venv-output.fixed.md delete mode 100644 news/win32_version_lookup.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 21108ffda2..bcb6c6ddee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,45 @@ Unreleased changes are tracked as individual files in the [news/](./news) directory, or view the [latest generated changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). +{#v2-2-0} +## [2.2.0] - 2026-06-30 + +[2.2.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.2.0 + +{#v2-2-0-changed} +### Changed +* Renamed most public bzl_library targets from `{foo}_bzl` to `{foo}` to follow +gazelle naming conventions. Deprecated aliases are left for backwards +compatibility. +* (binaries/tests) Added a deprecation warning for targets relying on implicit `__init__.py` creation. + +{#v2-2-0-fixed} +### Fixed +* Fixed a flaky error on Windows 2022 when looking up the win32 version during +site initialization by retrying the lookup +([#3721](https://github.com/bazel-contrib/rules_python/issues/3721)). +* (coverage) Skip lcov report when no data was collected. +* (pypi) Fixed `experimental_index_url` checking truthiness before envsubst +expansion. +* (rules) Fixed venv output paths for `py_binary` and `py_test` targets whose +names contain path separators so distinct targets with the same basename no +longer share the same venv output directory. + +{#v2-2-0-added} +### Added +* Added {bzl:obj}`features.loadable_symbols` to allow detecting public symbols +exported by bzl files. +* Exposed {bzl:obj}`VenvSymlinkEntry` and {bzl:obj}`VenvSymlinkKind` in +{bzl:target}`//python:py_info.bzl`. +* (pypi) Added `@pypi` repo: a unified hub of `pip.parse` hubs. +* (uv) Support for basic `uv.lock` generation via the `lock` rule +and basic support for importing the `uv.lock` file itself. Since this +may have bugs, please report this by creating new tickets. +Work towards [#2787](https://github.com/bazel-contrib/rules_python/issues/2787) +and [#1975](https://github.com/bazel-contrib/rules_python/issues/1975). + + + {#v2-1-0} ## [2.1.0] - 2026-06-17 diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md index 7908a28190..6b7a150518 100644 --- a/docs/api/rules_python/python/config_settings/index.md +++ b/docs/api/rules_python/python/config_settings/index.md @@ -362,7 +362,7 @@ Values: specified concrete PyPI hub (corresponding to a {obj}`pip.parse.hub_name` value). -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.2.0 ::: :::: diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 983ae3cb5f..3eb62221c0 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -126,7 +126,7 @@ a warning is printed indicating that the renaming occurred. If not set (defaulti to `0`), a warning is printed advising to rename the hub, and the collision is not resolved. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.2.0 ::: ::: diff --git a/docs/pypi/download.md b/docs/pypi/download.md index 6705df1f3a..161a753645 100644 --- a/docs/pypi/download.md +++ b/docs/pypi/download.md @@ -53,7 +53,7 @@ downloading the same wheels numerous times. (unified-pypi-hub)= ## Unified `@pypi` Hub for Multi-Hub Configurations -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.2.0 Unified `@pypi` hub repository for Bzlmod multi-hub configurations. ::: diff --git a/news/2945.changed.md b/news/2945.changed.md deleted file mode 100644 index c0ff002661..0000000000 --- a/news/2945.changed.md +++ /dev/null @@ -1 +0,0 @@ -(binaries/tests) Added a deprecation warning for targets relying on implicit `__init__.py` creation. diff --git a/news/3785.added.md b/news/3785.added.md deleted file mode 100644 index db0ec5602d..0000000000 --- a/news/3785.added.md +++ /dev/null @@ -1,5 +0,0 @@ -(uv) Support for basic `uv.lock` generation via the `lock` rule -and basic support for importing the `uv.lock` file itself. Since this -may have bugs, please report this by creating new tickets. -Work towards [#2787](https://github.com/bazel-contrib/rules_python/issues/2787) -and [#1975](https://github.com/bazel-contrib/rules_python/issues/1975). diff --git a/news/3828.fixed.md b/news/3828.fixed.md deleted file mode 100644 index 64de2cc925..0000000000 --- a/news/3828.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -(pypi) Fixed `experimental_index_url` checking truthiness before envsubst -expansion. diff --git a/news/3832.fixed.md b/news/3832.fixed.md deleted file mode 100644 index f1dd32df06..0000000000 --- a/news/3832.fixed.md +++ /dev/null @@ -1 +0,0 @@ -(coverage) Skip lcov report when no data was collected. diff --git a/news/3837.added.md b/news/3837.added.md deleted file mode 100644 index 6d3e4b5504..0000000000 --- a/news/3837.added.md +++ /dev/null @@ -1 +0,0 @@ -(pypi) Added `@pypi` repo: a unified hub of `pip.parse` hubs. diff --git a/news/expose-venv-symlink.added.md b/news/expose-venv-symlink.added.md deleted file mode 100644 index e339bb5d3f..0000000000 --- a/news/expose-venv-symlink.added.md +++ /dev/null @@ -1,2 +0,0 @@ -Exposed {bzl:obj}`VenvSymlinkEntry` and {bzl:obj}`VenvSymlinkKind` in -{bzl:target}`//python:py_info.bzl`. diff --git a/news/gazelle-bzl-library.changed.md b/news/gazelle-bzl-library.changed.md deleted file mode 100644 index 0d602110e3..0000000000 --- a/news/gazelle-bzl-library.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -Renamed most public bzl_library targets from `{foo}_bzl` to `{foo}` to follow -gazelle naming conventions. Deprecated aliases are left for backwards -compatibility. diff --git a/news/loadable-symbols.added.md b/news/loadable-symbols.added.md deleted file mode 100644 index 7d92df7aea..0000000000 --- a/news/loadable-symbols.added.md +++ /dev/null @@ -1,2 +0,0 @@ -Added {bzl:obj}`features.loadable_symbols` to allow detecting public symbols -exported by bzl files. diff --git a/news/slash-target-venv-output.fixed.md b/news/slash-target-venv-output.fixed.md deleted file mode 100644 index 780b498db7..0000000000 --- a/news/slash-target-venv-output.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -(rules) Fixed venv output paths for `py_binary` and `py_test` targets whose -names contain path separators so distinct targets with the same basename no -longer share the same venv output directory. diff --git a/news/win32_version_lookup.fixed.md b/news/win32_version_lookup.fixed.md deleted file mode 100644 index 6f01c9e73c..0000000000 --- a/news/win32_version_lookup.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -Fixed a flaky error on Windows 2022 when looking up the win32 version during -site initialization by retrying the lookup -([#3721](https://github.com/bazel-contrib/rules_python/issues/3721)). diff --git a/python/features.bzl b/python/features.bzl index ee850ade9e..33323b8b65 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -54,7 +54,7 @@ def _features_typedef(): A map of bzl paths to the list of public symbols they export. - :::{versionadded} VERSION_NEXT_FEATURE + :::{versionadded} 2.2.0 ::: :::: diff --git a/python/private/py_info.bzl b/python/private/py_info.bzl index da9421606a..dac1ddeff3 100644 --- a/python/private/py_info.bzl +++ b/python/private/py_info.bzl @@ -125,7 +125,7 @@ def _VenvSymlinkEntryBuilder_typedef(): :type: DepsetBuilder[File] ::: - :::{versionadded} VERSION_NEXT_FEATURE + :::{versionadded} 2.2.0 ::: """ @@ -677,7 +677,7 @@ def _PyInfoBuilder_new(): def _PyInfoBuilder_add_venv_symlink(self): """Create and return a new VenvSymlinkEntryBuilder. - :::{versionadded} VERSION_NEXT_FEATURE + :::{versionadded} 2.2.0 ::: Args: diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index 62d2c8de16..02c5ad0c34 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -845,7 +845,7 @@ means if different programs need different versions of some library, separate hubs can be created, and each program can use its respective hub's targets. Targets from different hubs should not be used together. -:::{versionchanged} VERSION_NEXT_FEATURE +:::{versionchanged} 2.2.0 Using the hub name `"pypi"` is deprecated and is changed to `{module_name}_pypi` depending on the {envvar}`RULES_PYTHON_PYPI_HUB_RESERVED` environment variable. diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index 8caf0ed3de..695591e924 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -387,7 +387,7 @@ _lock = rule( The lock rule that does the locking in a build action and also prepares information for a `bazel run` executable rule. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.2.0 ::: """, attrs = _common_attrs | { From 098379f8ee632d951e6ced14885dfa3ee078a1a2 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 23:29:00 -0700 Subject: [PATCH 042/179] chore(release): fix release label consistency in workflows and templates (#3876) This change fixes the release tracking and branch cutting workflows to use the correct "type: release" label with a space. This ensures the automation triggers correctly and matches the label expected by the release tool. --- .github/ISSUE_TEMPLATE/release_tracking_template.md | 2 +- .github/workflows/cut_release_branch.yml | 4 ++-- .github/workflows/process_backports.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md index 66beea28b7..b7d355e210 100644 --- a/.github/ISSUE_TEMPLATE/release_tracking_template.md +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -2,7 +2,7 @@ name: Release Tracking Issue about: Checklist for tracking a new release of rules_python. title: 'Release ' -labels: ['type:release'] +labels: ['type: release'] --- # Release tasks - [ ] Prepare Release | status=awaiting-preparation diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/cut_release_branch.yml index b7c7118194..9ae40d0700 100644 --- a/.github/workflows/cut_release_branch.yml +++ b/.github/workflows/cut_release_branch.yml @@ -10,8 +10,8 @@ permissions: jobs: cut_branch: - # Run only if the issue has the type:release label - if: contains(github.event.issue.labels.*.name, 'type:release') + # Run only if the issue has the type: release label + if: contains(github.event.issue.labels.*.name, 'type: release') runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/process_backports.yml b/.github/workflows/process_backports.yml index 9a42b6c62c..4d8055d3c5 100644 --- a/.github/workflows/process_backports.yml +++ b/.github/workflows/process_backports.yml @@ -14,7 +14,7 @@ permissions: jobs: process_backports: - # Always gate GHA runs to ensure we are operating on a type:release labeled issue if metadata is queried + # Always gate GHA runs to ensure we are operating on a type: release labeled issue if metadata is queried runs-on: ubuntu-latest steps: - name: Checkout repository From fbb2ccf4ca4ac5d43f2e4181469b04dc14cfb092 Mon Sep 17 00:00:00 2001 From: Gleb Kolobkov Date: Tue, 30 Jun 2026 18:00:59 -0700 Subject: [PATCH 043/179] fix: avoid stage1 bootstrap stdlib shadowing (#3854) The stage 1 system-python bootstrap imports standard-library modules before it re-execs into the configured runtime. If the target output directory contains files named like standard-library modules, such as `shutil.py` or `types.py`, the bootstrap can import those files instead and fail before user code starts. This change removes Python's unsafe script-directory prepend before those early imports, while preserving `sys.path[0]` when Python has already suppressed that prepend through `-P`/`PYTHONSAFEPATH` or isolated mode. Stage 2 now uses the same isolated-mode guard when removing or recreating the main-directory path. It also pins the Windows venv entry point template to LF line endings so the compile-pip CI dirty-worktree check is stable across checkout settings. Before this change, a generated target output could shadow bootstrap stdlib imports, and isolated-mode runs on older Python versions could lose a required stdlib path. After this change, bootstrap stdlib imports resolve from the interpreter's standard library, isolated mode preserves the interpreter-provided path, and the line-ending-sensitive CI check stays clean. Tests: ```shell bazel test --config=fast-tests \ //tests/bootstrap_impls:stdlib_shadowing_system_python_test bazel test --config=fast-tests \ //tests/bootstrap_impls:stdlib_shadowing_system_python_test \ //tests/bootstrap_impls:interpreter_args_test \ //tests/bootstrap_impls:sys_path_order_bootstrap_script_test ``` --- .bazelci/presubmit.yml | 4 +++ news/3854.fixed.md | 2 ++ python/private/python_bootstrap_template.txt | 20 +++++++++++++- python/private/stage2_bootstrap_template.py | 15 ++++++++--- tests/bootstrap_impls/BUILD.bazel | 26 +++++++++++++++++++ .../bootstrap_impls/stdlib_shadowing_test.py | 20 ++++++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 news/3854.fixed.md create mode 100644 tests/bootstrap_impls/stdlib_shadowing_test.py diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 79d2dfbc30..b3e308c7c7 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml @@ -84,6 +84,9 @@ buildifier: coverage_targets: ["..."] .coverage_targets_example_bzlmod_build_file_generation: &coverage_targets_example_bzlmod_build_file_generation coverage_targets: ["//:bzlmod_build_file_generation_test"] +.coverage_targets_bootstrap: &coverage_targets_bootstrap + coverage_targets: + - //tests/bootstrap_impls:stdlib_shadowing_system_python_test .coverage_targets_example_multi_python: &coverage_targets_example_multi_python coverage_targets: - //tests:my_lib_3_10_test @@ -216,6 +219,7 @@ tasks: bazel: 7.x ubuntu: <<: *reusable_config + <<: *coverage_targets_bootstrap name: "Default: Ubuntu, Bazel {bazel}" platform: ubuntu2204 bazel: ${{ bazel }} diff --git a/news/3854.fixed.md b/news/3854.fixed.md new file mode 100644 index 0000000000..5c38b27642 --- /dev/null +++ b/news/3854.fixed.md @@ -0,0 +1,2 @@ +(bootstrap) Fixed stage 1 bootstrap imports when target outputs shadow standard +library modules. diff --git a/python/private/python_bootstrap_template.txt b/python/private/python_bootstrap_template.txt index 38c93ec5b5..482918c038 100644 --- a/python/private/python_bootstrap_template.txt +++ b/python/private/python_bootstrap_template.txt @@ -5,13 +5,31 @@ from __future__ import absolute_import from __future__ import division from __future__ import print_function +import sys + +# By default, Python prepends the directory containing this script to +# sys.path. The stage 1 bootstrap only needs stdlib modules before it +# re-execs into the configured runtime, so avoid resolving imports from the +# target's output or runfiles package directory. This matters when that +# directory contains files with stdlib names, such as shutil.py or types.py. +# +# Python 3.11 introduced PYTHONSAFEPATH (-P), which disables the unsafe prepend. +# Isolated mode (-I) also disables it, including on older interpreters without +# safe_path. In either case, sys.path[0] is not the script directory and should +# be preserved. +if ( + not getattr(sys.flags, "safe_path", False) and + not getattr(sys.flags, "isolated", False) and + sys.path +): + del sys.path[0] + # Generated file from @rules_python//python/private:python_bootstrap_template.txt from os.path import abspath, dirname, join, basename, normpath import os import shutil import subprocess -import sys # NOTE: The sentinel strings are split (e.g., "%stage2" + "_bootstrap%") so that # the substitution logic won't replace them. This allows runtime detection of diff --git a/python/private/stage2_bootstrap_template.py b/python/private/stage2_bootstrap_template.py index b11fc76093..f445ad2b6a 100644 --- a/python/private/stage2_bootstrap_template.py +++ b/python/private/stage2_bootstrap_template.py @@ -9,11 +9,16 @@ # and is a special case of #7091. # # Python 3.11 introduced an PYTHONSAFEPATH (-P) option that disables this -# behaviour, which we set in the stage 1 bootstrap. +# behaviour, which we set in the stage 1 bootstrap. Isolated mode (-I) also +# disables it, including on older interpreters without safe_path. # So the prepended entry needs to be removed only if the above option is either # unset or not supported by the interpreter. # NOTE: This can be removed when Python 3.10 and below is no longer supported -if not getattr(sys.flags, "safe_path", False): +if ( + not getattr(sys.flags, "safe_path", False) + and not getattr(sys.flags, "isolated", False) + and sys.path +): del sys.path[0] import contextlib @@ -539,8 +544,10 @@ def main(): # means only other generated files are importable (not source files). # # To replicate this behavior, we add main's directory within the runfiles - # when safe path isn't enabled. - if not getattr(sys.flags, "safe_path", False): + # when safe path or isolated mode isn't enabled. + if not getattr(sys.flags, "safe_path", False) and not getattr( + sys.flags, "isolated", False + ): prepend_path_entries = [ os.path.join(runfiles_root, os.path.dirname(main_rel_path)) ] diff --git a/tests/bootstrap_impls/BUILD.bazel b/tests/bootstrap_impls/BUILD.bazel index ab3148db00..89cd682a6a 100644 --- a/tests/bootstrap_impls/BUILD.bazel +++ b/tests/bootstrap_impls/BUILD.bazel @@ -136,6 +136,32 @@ py_reconfig_test( main = "sys_path_order_test.py", ) +genrule( + name = "stdlib_shadowing_outputs", + outs = [ + "shutil.py", + "types.py", + ], + cmd = """ +cat > $(@D)/shutil.py <<'PY' +raise RuntimeError("target output shutil.py shadowed the stdlib shutil module") +PY +cat > $(@D)/types.py <<'PY' +raise RuntimeError("target output types.py shadowed the stdlib types module") +PY +""", +) + +py_reconfig_test( + name = "stdlib_shadowing_system_python_test", + srcs = [ + "stdlib_shadowing_test.py", + ":stdlib_shadowing_outputs", + ], + bootstrap_impl = "system_python", + main = "stdlib_shadowing_test.py", +) + py_reconfig_test( name = "main_module_test", srcs = ["main_module.py"], diff --git a/tests/bootstrap_impls/stdlib_shadowing_test.py b/tests/bootstrap_impls/stdlib_shadowing_test.py new file mode 100644 index 0000000000..fc913857db --- /dev/null +++ b/tests/bootstrap_impls/stdlib_shadowing_test.py @@ -0,0 +1,20 @@ +# Copyright 2026 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. +"""Verifies stage 1 bootstrap stdlib imports cannot be shadowed.""" + + +def test_bootstrap_reached_main(): + # If stage 1 imports target outputs such as shutil.py instead of stdlib + # modules, the process fails before this test module is executed. + pass From 865ed259b16fc77f38f08dc8a45e4bb11a44537c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:25:36 -0700 Subject: [PATCH 044/179] build(deps): bump bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml from 1.1.0 to 1.4.1 (#3822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml](https://github.com/bazel-contrib/publish-to-bcr) from 1.1.0 to 1.4.1.
Release notes

Sourced from bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml's releases.

v1.4.1

What's Changed

Full Changelog: https://github.com/bazel-contrib/publish-to-bcr/compare/v1.4.0...v1.4.1

v1.4.0

What's Changed

New Contributors

Full Changelog: https://github.com/bazel-contrib/publish-to-bcr/compare/v1.3.0...v1.4.0

v1.3.0

What's Changed

Full Changelog: https://github.com/bazel-contrib/publish-to-bcr/compare/v1.2.0...v1.3.0

v1.2.0

What's Changed

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml&package-manager=github_actions&previous-version=1.1.0&new-version=1.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5ef65e83a6..c5db587f18 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ on: type: string jobs: publish: - uses: bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml@v1.1.0 + uses: bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml@v1.4.1 with: tag_name: ${{ inputs.tag_name }} # GitHub repository which is a fork of the upstream where the Pull Request will be opened. From 3ab60b5598b0c9ef59a9dd1dd08e79d12aedd488 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 30 Jun 2026 19:29:19 -0700 Subject: [PATCH 045/179] chore(release): support custom remote in create-release-branch and refactor (#3877) - Add --remote flag to create-release-branch subcommand to support pushing to upstream locally and origin in GHA. - Factor cmd_create_release_branch out of release.py into create_release_branch.py. - Use branch_url instead of branch name in tracking issue metadata. - Update release tracking template with manual editing and backporting instructions. --- .../release_tracking_template.md | 19 ++++- .github/workflows/cut_release_branch.yml | 2 +- tests/tools/private/release/release_test.py | 73 ++++++++++++++++++ tools/private/release/BUILD.bazel | 1 + .../private/release/create_release_branch.py | 67 ++++++++++++++++ tools/private/release/release.py | 77 ++++--------------- tools/private/release/release_issue.py | 2 + tools/private/release/utils.py | 2 +- 8 files changed, 176 insertions(+), 67 deletions(-) create mode 100644 tools/private/release/create_release_branch.py diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md index b7d355e210..f573fdfacf 100644 --- a/.github/ISSUE_TEMPLATE/release_tracking_template.md +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -11,11 +11,28 @@ labels: ['type: release'] - [ ] Tag Final ## Backports - + +
+How to add backports + +To request a backport: +1. Add a new checklist item under the `## Backports` section. +2. The format must be: `- [ ] #` (e.g., `- [ ] #1234`). +3. Trigger the [Process Backports Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/process_backports.yml). +
--- *Maintainers: Automation will react to changes on this issue.* +
+Manual Editing + +You can manually edit this issue to control the release flow. +The checklist items use metadata suffix: `| key=value key2=value2`. +- **Retry Prepare Release**: Reset to `- [ ] Prepare Release | status=awaiting-preparation`. +- **Force Task Done**: Check the box `- [x]` and add appropriate metadata (e.g. `status=done`). +
+
Available Commands diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/cut_release_branch.yml index 9ae40d0700..f054f4cbc8 100644 --- a/.github/workflows/cut_release_branch.yml +++ b/.github/workflows/cut_release_branch.yml @@ -32,6 +32,6 @@ jobs: - name: Attempt Branch Creation run: | bazel run //tools/private/release -- \ - create-release-branch --issue ${{ github.event.issue.number }} + create-release-branch --issue ${{ github.event.issue.number }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 293b82b277..d199eac99e 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -18,10 +18,12 @@ def _mock_git_and_gh(test_case): # Patch bindings in modules that import them at module level patch("tools.private.release.release.git", new=mock_git).start() patch("tools.private.release.prepare.git", new=mock_git).start() + patch("tools.private.release.create_release_branch.git", new=mock_git).start() patch("tools.private.release.utils.git", new=mock_git).start() patch("tools.private.release.release.gh", new=mock_gh).start() patch("tools.private.release.prepare.gh", new=mock_gh).start() + patch("tools.private.release.create_release_branch.gh", new=mock_gh).start() mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -1218,5 +1220,76 @@ def test_promote_rc_no_rc_found(self): self.mock_gh.get_issue_body.assert_not_called() +class CmdCreateReleaseBranchTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + + def test_create_release_branch_success(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.branch_exists.return_value = False + + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.checkout.assert_any_call("abcdef12") + self.mock_git.checkout.assert_any_call("release/2.0", create_branch=True) + self.mock_git.push.assert_called_once_with("my-remote", "release/2.0") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn( + "branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0", + call_args[1], + ) + self.assertIn("commit=abcdef12", call_args[1]) + + def test_create_release_branch_prepare_not_done(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release | status=pending +- [ ] Create Release branch | status=pending +""" + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 1) + self.mock_git.fetch.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_create_release_branch_already_checked(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +""" + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 96f864141d..de2aba4b71 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -10,6 +10,7 @@ py_library( py_binary( name = "release", srcs = [ + "create_release_branch.py", "gh.py", "git.py", "prepare.py", diff --git a/tools/private/release/create_release_branch.py b/tools/private/release/create_release_branch.py new file mode 100644 index 0000000000..45fec27196 --- /dev/null +++ b/tools/private/release/create_release_branch.py @@ -0,0 +1,67 @@ +"""Subcommand to create a release branch from a merged PR commit.""" + +from tools.private.release import gh, git +from tools.private.release.release_issue import ( + RELEASE_TITLE_RE, + parse_checklist_state, + update_task_in_body, +) +from tools.private.release.utils import REPO_URL + + +def cmd_create_release_branch(args): + """Executes the create-release-branch subcommand.""" + print(f"Evaluating branch creation for tracking issue #{args.issue}...") + body = gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"]["status"] != "done" + or not state["prepare_release"]["commit"] + ): + print( + "Error: Prepare Release task is not marked 'done' with a valid commit SHA." + ) + return 1 + + if state["create_branch"]["checked"]: + print("Release branch has already been created and checked. Skipping.") + return 0 + + # Extract version from issue title + issue_title = gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + commit_sha = state["prepare_release"]["commit"] + print(f"Cutting branch {branch_name} from commit {commit_sha}...") + + # Create and push branch + git.fetch(args.remote) + git.checkout(commit_sha) + + if not git.branch_exists(branch_name): + git.checkout(branch_name, create_branch=True) + else: + git.checkout(branch_name) + git.merge(commit_sha, ff_only=True) + + git.push(args.remote, branch_name) + print(f"Successfully pushed branch {branch_name} to {args.remote}") + + # Update tracking issue checklist + print("Updating tracking issue checklist...") + branch_url = f"{REPO_URL}/tree/{branch_name}" + metadata = {"status": "done", "branch_url": branch_url, "commit": commit_sha[:8]} + updated_body = update_task_in_body( + body, "Create Release branch", checked=True, metadata=metadata + ) + gh.update_issue_body(args.issue, updated_body) + print("Create Release branch task marked complete successfully!") + return 0 diff --git a/tools/private/release/release.py b/tools/private/release/release.py index dab99e6d05..cae0053014 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -8,20 +8,20 @@ import sys from tools.private.release import changelog_news, gh, git +from tools.private.release.create_release_branch import cmd_create_release_branch from tools.private.release.prepare import cmd_prepare from tools.private.release.release_issue import ( + RELEASE_TITLE_RE, parse_checklist_state, parse_metadata_line, update_task_in_body, ) from tools.private.release.utils import ( - _REPO_URL, + REPO_URL, determine_next_version, get_latest_rc_tag, ) -_RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) - def _semver_type(value): if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): @@ -141,63 +141,6 @@ def cmd_complete_prepare(args): return 0 -def cmd_create_release_branch(args): - """Executes the create-release-branch subcommand.""" - print(f"Evaluating branch creation for tracking issue #{args.issue}...") - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) - - if ( - state["prepare_release"]["status"] != "done" - or not state["prepare_release"]["commit"] - ): - print( - "Error: Prepare Release task is not marked 'done' with a valid commit SHA." - ) - return 1 - - if state["create_branch"]["checked"]: - print("Release branch has already been created and checked. Skipping.") - return 0 - - # Extract version from issue title - issue_title = gh.get_issue_title(args.issue) - version_match = _RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - commit_sha = state["prepare_release"]["commit"] - print(f"Cutting branch {branch_name} from commit {commit_sha}...") - - # Create and push branch - git.fetch("origin") - git.checkout(commit_sha) - - if not git.branch_exists(branch_name): - git.checkout(branch_name, create_branch=True) - else: - git.checkout(branch_name) - git.merge(commit_sha, ff_only=True) - - git.push("origin", branch_name) - print(f"Successfully pushed branch {branch_name}") - - # Update tracking issue checklist - print("Updating tracking issue checklist...") - metadata = {"status": "done", "branch": branch_name, "commit": commit_sha[:8]} - updated_body = update_task_in_body( - body, "Create Release branch", checked=True, metadata=metadata - ) - gh.update_issue_body(args.issue, updated_body) - print("Create Release branch task marked complete successfully!") - return 0 - - def cmd_process_backports(args): """Executes the process-backports subcommand.""" body = gh.get_issue_body(args.issue) @@ -217,7 +160,7 @@ def cmd_process_backports(args): # Determine branch name from issue title issue_title = gh.get_issue_title(args.issue) - version_match = _RELEASE_TITLE_RE.search(issue_title) + version_match = RELEASE_TITLE_RE.search(issue_title) if not version_match: print(f"Error: Could not parse version from issue title: {issue_title}") return 1 @@ -348,7 +291,7 @@ def cmd_create_rc(args): # Resolve version and branch issue_title = gh.get_issue_title(args.issue) - version_match = _RELEASE_TITLE_RE.search(issue_title) + version_match = RELEASE_TITLE_RE.search(issue_title) if not version_match: print(f"Error: Could not parse version from issue title: {issue_title}") return 1 @@ -405,7 +348,7 @@ def cmd_create_rc(args): updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) gh.update_issue_body(args.issue, updated_body) - tag_url = f"{_REPO_URL}/releases/tag/{next_rc}" + tag_url = f"{REPO_URL}/releases/tag/{next_rc}" bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" comment_body = f"""🚀 **New Release Candidate Tagged!** @@ -489,7 +432,7 @@ def cmd_promote_rc(args): print(f"Posting comment to tracking issue #{issue_num}...") import urllib.parse - release_url = f"{_REPO_URL}/releases/tag/{version}" + release_url = f"{REPO_URL}/releases/tag/{version}" bcr_query = f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" comment_body = ( @@ -576,6 +519,12 @@ def create_parser(): required=True, help="The tracking issue number (required).", ) + create_branch_parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to create the branch on (required).", + ) # Subcommand: process-backports process_backports_parser = subparsers.add_parser( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index f96b917ab9..c170217471 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -2,6 +2,8 @@ import re +RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) + def parse_metadata_line(line): """Parses a checklist line with optional | key=value metadata.""" diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 83ecbb9c7a..6f7383f8ec 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -8,7 +8,7 @@ from tools.private.release import git -_REPO_URL = "https://github.com/bazel-contrib/rules_python" +REPO_URL = "https://github.com/bazel-contrib/rules_python" _EXCLUDE_PATTERNS = [ "./.git/*", From 2241f8b6a1175577c4403573bb5aabd5e5182f11 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 30 Jun 2026 19:59:41 -0700 Subject: [PATCH 046/179] chore(release): refactor create-rc and handle existing remote release branch (#3879) - Move cmd_create_rc logic from release.py to a new create_rc.py module. - Move parse_backports from release.py to release_issue.py. - Add required --remote flag to create-rc subcommand. - Update generate_rc.yml workflow to pass --remote origin. - In create-release-branch, detect if remote branch already exists and handle same-commit, fast-forward, and non-fast-forward cases. - Add remote_branch_exists and is_ancestor helpers to git.py. --- .github/workflows/generate_rc.yml | 2 +- tests/tools/private/release/release_test.py | 117 ++++++++++++++- tools/private/release/BUILD.bazel | 1 + tools/private/release/create_rc.py | 114 +++++++++++++++ .../private/release/create_release_branch.py | 35 +++-- tools/private/release/git.py | 18 +++ tools/private/release/release.py | 137 +----------------- tools/private/release/release_issue.py | 29 ++++ 8 files changed, 306 insertions(+), 147 deletions(-) create mode 100644 tools/private/release/create_rc.py diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/generate_rc.yml index 26685c2f62..19a80bb1de 100644 --- a/.github/workflows/generate_rc.yml +++ b/.github/workflows/generate_rc.yml @@ -34,6 +34,6 @@ jobs: - name: Attempt RC Tagging run: | bazel run //tools/private/release -- \ - create-rc --issue ${{ inputs.issue }} + create-rc --issue ${{ inputs.issue }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index d199eac99e..636c9b2589 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -3,7 +3,7 @@ import shutil import tempfile import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from tools.private.release import changelog_news, release as releaser, utils from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError @@ -19,11 +19,13 @@ def _mock_git_and_gh(test_case): patch("tools.private.release.release.git", new=mock_git).start() patch("tools.private.release.prepare.git", new=mock_git).start() patch("tools.private.release.create_release_branch.git", new=mock_git).start() + patch("tools.private.release.create_rc.git", new=mock_git).start() patch("tools.private.release.utils.git", new=mock_git).start() patch("tools.private.release.release.gh", new=mock_gh).start() patch("tools.private.release.prepare.gh", new=mock_gh).start() patch("tools.private.release.create_release_branch.gh", new=mock_gh).start() + patch("tools.private.release.create_rc.gh", new=mock_gh).start() mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -932,7 +934,7 @@ def setUp(self): def test_create_rc_success_first_rc(self): # Arrange - args = MagicMock(issue=123) + args = MagicMock(issue=123, remote="my-remote") self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -949,8 +951,12 @@ def test_create_rc_success_first_rc(self): # Assert self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD") - self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -959,10 +965,21 @@ def test_create_rc_success_first_rc(self): self.assertIn("commit=12345678", call_args[1]) self.mock_gh.post_issue_comment.assert_called_once() + comment_call_args = self.mock_gh.post_issue_comment.call_args[0] + self.assertEqual(comment_call_args[0], 123) + self.assertIn( + "**New Release Candidate Tagged!** 🐍🌿", + comment_call_args[1], + ) + self.assertIn( + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + comment_call_args[1], + ) + self.assertNotIn("🚀", comment_call_args[1]) def test_create_rc_success_next_rc(self): # Arrange - args = MagicMock(issue=123) + args = MagicMock(issue=123, remote="my-remote") self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -980,8 +997,12 @@ def test_create_rc_success_next_rc(self): # Assert self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD") - self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc1") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -989,6 +1010,17 @@ def test_create_rc_success_next_rc(self): self.assertIn("tag=2.0.0-rc1", call_args[1]) self.mock_gh.post_issue_comment.assert_called_once() + comment_call_args = self.mock_gh.post_issue_comment.call_args[0] + self.assertEqual(comment_call_args[0], 123) + self.assertIn( + "**New Release Candidate Tagged!** 🐍🌿", + comment_call_args[1], + ) + self.assertIn( + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + comment_call_args[1], + ) + self.assertNotIn("🚀", comment_call_args[1]) def test_create_rc_already_tagged(self): # Arrange @@ -1234,6 +1266,7 @@ def test_create_release_branch_success(self): - [ ] Create Release branch | status=pending """ self.mock_git.branch_exists.return_value = False + self.mock_git.remote_branch_exists.return_value = False # Act result = releaser.cmd_create_release_branch(args) @@ -1241,9 +1274,10 @@ def test_create_release_branch_success(self): # Assert self.assertEqual(result, 0) self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.checkout.assert_any_call("abcdef12") - self.mock_git.checkout.assert_any_call("release/2.0", create_branch=True) - self.mock_git.push.assert_called_once_with("my-remote", "release/2.0") + self.mock_git.checkout.assert_not_called() + self.mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -1290,6 +1324,73 @@ def test_create_release_branch_already_checked(self): self.mock_git.push.assert_not_called() self.mock_gh.update_issue_body.assert_not_called() + def test_create_release_branch_already_exists_same_commit(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "abcdef12" + + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist + + def test_create_release_branch_already_exists_fast_forward(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "oldcommit" + self.mock_git.is_ancestor.return_value = True + + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) + self.mock_gh.update_issue_body.assert_called_once() + + def test_create_release_branch_already_exists_non_ff(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "othercommit" + self.mock_git.is_ancestor.return_value = False + + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 1) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index de2aba4b71..ad2e1b4cdc 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -10,6 +10,7 @@ py_library( py_binary( name = "release", srcs = [ + "create_rc.py", "create_release_branch.py", "gh.py", "git.py", diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py new file mode 100644 index 0000000000..9cd1a3b291 --- /dev/null +++ b/tools/private/release/create_rc.py @@ -0,0 +1,114 @@ +"""Subcommand to tag and push the next release candidate.""" + +from tools.private.release import gh, git +from tools.private.release.release_issue import ( + RELEASE_TITLE_RE, + parse_backports, + parse_checklist_state, + update_task_in_body, +) +from tools.private.release.utils import ( + REPO_URL, + get_latest_rc_tag, +) + + +def cmd_create_rc(args): + """Executes the create-rc subcommand.""" + body = gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"]["status"] != "done" + or state["create_branch"]["status"] != "done" + ): + print( + "Error: Preconditions not met (release must be prepared and branch created)." + ) + return 1 + + # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done + backports = parse_backports(body) + conflicting_or_pending = [ + b for b in backports if not b["checked"] or b["status"] != "done" + ] + if conflicting_or_pending: + print( + f"Gating RC tagging: {len(conflicting_or_pending)} backports are still" + " unfinished, failed, or in conflict." + ) + return 1 + + # Resolve version and branch + issue_title = gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag + git.fetch(args.remote) + git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version) + + if not latest_rc: + next_rc_num = 0 + next_rc = f"{version}-rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_num = rc_num + 1 + next_rc = f"{version}-rc{next_rc_num}" + + # Precheck: next RC number must exist and be unchecked in the checklist + rc_tags = state.get("rc_tags", {}) + if next_rc_num not in rc_tags: + print( + f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" + f" to cut {version}-rc{next_rc_num}." + ) + return 1 + + target_rc_task = rc_tags[next_rc_num] + if target_rc_task["checked"] or target_rc_task["status"] == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist." + ) + return 1 + + # Verify HEAD is not already tagged + git.checkout(f"{args.remote}/{branch_name}") + head_tags = git.get_tags_at_head() + if any(tag.startswith(f"{version}-rc") for tag in head_tags): + print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") + return 0 + + print(f"Tagging and pushing next RC: {next_rc}...") + git.tag(next_rc, "HEAD") + git.push(args.remote, next_rc) + + commit_sha = git.get_commit_sha("HEAD") + + # Check off the appropriate "Tag RC{N}" task in the checklist + print(f"Checking off Tag RC{next_rc_num} task...") + metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} + task_name = f"Tag RC{next_rc_num}" + updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) + gh.update_issue_body(args.issue, updated_body) + + tag_url = f"{REPO_URL}/releases/tag/{next_rc}" + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" + release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" + comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 + +Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. + +- View Tag: [{next_rc}]({tag_url}) +- Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) +- Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" + gh.post_issue_comment(args.issue, comment_body) + print("RC creation completed successfully!") + return 0 diff --git a/tools/private/release/create_release_branch.py b/tools/private/release/create_release_branch.py index 45fec27196..7b12465f4c 100644 --- a/tools/private/release/create_release_branch.py +++ b/tools/private/release/create_release_branch.py @@ -42,18 +42,35 @@ def cmd_create_release_branch(args): commit_sha = state["prepare_release"]["commit"] print(f"Cutting branch {branch_name} from commit {commit_sha}...") - # Create and push branch + # Create and push branch without affecting local checkout git.fetch(args.remote) - git.checkout(commit_sha) - if not git.branch_exists(branch_name): - git.checkout(branch_name, create_branch=True) + if git.remote_branch_exists(args.remote, branch_name): + remote_ref = f"{args.remote}/{branch_name}" + remote_sha = git.get_commit_sha(remote_ref) + if remote_sha == commit_sha: + print( + f"Branch {branch_name} already exists on {args.remote} and points to {commit_sha}. Skipping push." + ) + elif git.is_ancestor(remote_ref, commit_sha): + print( + f"Branch {branch_name} exists on {args.remote} but can be fast-forwarded to {commit_sha}. Pushing..." + ) + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + git.push(args.remote, ref_spec) + else: + print( + f"Error: Branch {branch_name} already exists on {args.remote} at {remote_sha[:8]}, " + f"which is not an ancestor of {commit_sha[:8]}. Cannot fast-forward." + ) + return 1 else: - git.checkout(branch_name) - git.merge(commit_sha, ff_only=True) - - git.push(args.remote, branch_name) - print(f"Successfully pushed branch {branch_name} to {args.remote}") + print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...") + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + git.push(args.remote, ref_spec) + print( + f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}" + ) # Update tracking issue checklist print("Updating tracking issue checklist...") diff --git a/tools/private/release/git.py b/tools/private/release/git.py index ed6e637d3f..ce2bd8ca03 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -132,3 +132,21 @@ def get_tags_at_head(): def get_current_branch(): """Returns the current git branch name.""" return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") + + +def remote_branch_exists(remote, branch_name): + """Returns True if a remote branch exists.""" + try: + run_cmd("git", "show-ref", "--verify", f"refs/remotes/{remote}/{branch_name}") + return True + except subprocess.CalledProcessError: + return False + + +def is_ancestor(ancestor, descendant): + """Returns True if ancestor is an ancestor of descendant (fast-forwardable).""" + try: + run_cmd("git", "merge-base", "--is-ancestor", ancestor, descendant) + return True + except subprocess.CalledProcessError: + return False diff --git a/tools/private/release/release.py b/tools/private/release/release.py index cae0053014..7bc6f9ba05 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -8,12 +8,12 @@ import sys from tools.private.release import changelog_news, gh, git +from tools.private.release.create_rc import cmd_create_rc from tools.private.release.create_release_branch import cmd_create_release_branch from tools.private.release.prepare import cmd_prepare from tools.private.release.release_issue import ( RELEASE_TITLE_RE, - parse_checklist_state, - parse_metadata_line, + parse_backports, update_task_in_body, ) from tools.private.release.utils import ( @@ -36,35 +36,6 @@ def _semver_type(value): # ============================================================================== -def parse_backports(body): - """Parses the ## Backports checklist section.""" - body = body.replace("\r\n", "\n") - match = re.search( - r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE - ) - if not match: - return [] - - section_content = match.group(1) - items = [] - lines = section_content.splitlines() - - for line in lines: - parsed = parse_metadata_line(line) - if parsed: - items.append( - { - "pr_ref": parsed["name"], - "checked": parsed["checked"], - "status": parsed["metadata"].get("status", "PENDING"), - "rc": parsed["metadata"].get("rc"), - "commit": parsed["metadata"].get("commit"), - "metadata": parsed["metadata"], - } - ) - return items - - # ============================================================================== # Subcommand Execution Functions # ============================================================================== @@ -263,104 +234,6 @@ def cmd_process_backports(args): return 0 -def cmd_create_rc(args): - """Executes the create-rc subcommand.""" - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) - - if ( - state["prepare_release"]["status"] != "done" - or state["create_branch"]["status"] != "done" - ): - print( - "Error: Preconditions not met (release must be prepared and branch created)." - ) - return 1 - - # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done - backports = parse_backports(body) - conflicting_or_pending = [ - b for b in backports if not b["checked"] or b["status"] != "done" - ] - if conflicting_or_pending: - print( - f"Gating RC tagging: {len(conflicting_or_pending)} backports are still" - " unfinished, failed, or in conflict." - ) - return 1 - - # Resolve version and branch - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - # Determine next RC tag - git.fetch("--tags", "--force") - latest_rc = get_latest_rc_tag(version) - - if not latest_rc: - next_rc_num = 0 - next_rc = f"{version}-rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_num = rc_num + 1 - next_rc = f"{version}-rc{next_rc_num}" - - # Precheck: next RC number must exist and be unchecked in the checklist - rc_tags = state.get("rc_tags", {}) - if next_rc_num not in rc_tags: - print( - f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" - f" to cut {version}-rc{next_rc_num}." - ) - return 1 - - target_rc_task = rc_tags[next_rc_num] - if target_rc_task["checked"] or target_rc_task["status"] == "done": - print( - f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist." - ) - return 1 - - # Verify HEAD is not already tagged - git.checkout(branch_name) - head_tags = git.get_tags_at_head() - if any(tag.startswith(f"{version}-rc") for tag in head_tags): - print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") - return 0 - - print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc, "HEAD") - git.push("origin", next_rc) - - commit_sha = git.get_commit_sha("HEAD") - - # Check off the appropriate "Tag RC{N}" task in the checklist - print(f"Checking off Tag RC{next_rc_num} task...") - metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} - task_name = f"Tag RC{next_rc_num}" - updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) - gh.update_issue_body(args.issue, updated_body) - - tag_url = f"{REPO_URL}/releases/tag/{next_rc}" - bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" - comment_body = f"""🚀 **New Release Candidate Tagged!** - -Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. - -View Tag: [{next_rc}]({tag_url}) -Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})""" - gh.post_issue_comment(args.issue, comment_body) - print("RC creation completed successfully!") - return 0 - - def cmd_promote_rc(args): """Executes the promote-rc subcommand (Phase 3).""" version = args.version @@ -549,6 +422,12 @@ def create_parser(): required=True, help="The tracking issue number (required).", ) + create_rc_parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push the RC tag to (required).", + ) # Subcommand: promote-rc promote_parser = subparsers.add_parser( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index c170217471..eb7414e39a 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -128,3 +128,32 @@ def parse_checklist_state(body): } return state + + +def parse_backports(body): + """Parses the ## Backports checklist section.""" + body = body.replace("\r\n", "\n") + match = re.search( + r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE + ) + if not match: + return [] + + section_content = match.group(1) + items = [] + lines = section_content.splitlines() + + for line in lines: + parsed = parse_metadata_line(line) + if parsed: + items.append( + { + "pr_ref": parsed["name"], + "checked": parsed["checked"], + "status": parsed["metadata"].get("status", "PENDING"), + "rc": parsed["metadata"].get("rc"), + "commit": parsed["metadata"].get("commit"), + "metadata": parsed["metadata"], + } + ) + return items From dc0d97765d63829a536879bc5c5ac6526ec4d6b7 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 30 Jun 2026 23:14:17 -0700 Subject: [PATCH 047/179] chore(release): various fixes to release tool and workflows (#3880) The release tool previously calculated incorrect RC versions or failed when local tags were out of sync with the remote repository. To resolve this: - Query remote tags directly via `git ls-remote` to determine the next RC version. - Fetch upstream tags before determining the next version in `promote-rc` to avoid stale local state. - Remove local checkout and the "already tagged" check in `create-rc` to simplify the tagging process. - Exclude release tool directories from the workflow version marker check. --- .github/workflows/check_version_markers.sh | 3 +- tests/tools/private/release/release_test.py | 72 +++++++++------------ tools/private/release/create_rc.py | 14 ++-- tools/private/release/git.py | 33 ++++++++-- tools/private/release/release.py | 11 ++-- tools/private/release/utils.py | 7 +- 6 files changed, 75 insertions(+), 65 deletions(-) diff --git a/.github/workflows/check_version_markers.sh b/.github/workflows/check_version_markers.sh index b8d35aec9d..15a0a67dc8 100755 --- a/.github/workflows/check_version_markers.sh +++ b/.github/workflows/check_version_markers.sh @@ -20,9 +20,8 @@ grep_exit_code=0 # Exclude CONTRIBUTING.md, RELEASING.md because they document how to use these strings. grep --exclude=CONTRIBUTING.md \ --exclude=RELEASING.md \ - --exclude=release.py \ - --exclude=release_test.py \ --exclude-dir=.* \ + --exclude-dir=release \ VERSION_NEXT_ -r || grep_exit_code=$? if [[ $grep_exit_code -eq 0 ]]; then diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 636c9b2589..d06b9097a9 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -34,7 +34,8 @@ def _mock_git_and_gh(test_case): # Apply safe defaults mock_git.get_current_branch.return_value = None mock_git.get_tags.return_value = [] - mock_git.get_tags_at_head.return_value = [] + mock_git.get_remote_tags.return_value = [] + mock_git.status.return_value = "" mock_git.branch_exists.return_value = False mock_git.tag_exists.return_value = False @@ -588,6 +589,17 @@ def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") + @patch("tools.private.release.git.get_remote_tags") + def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): + mock_get_remote_tags.return_value = [ + "2.0.0-rc0", + "2.0.0-rc2", + "2.0.0-rc1", + "2.1.0-rc0", + ] + self.assertEqual(utils.get_latest_rc_tag("2.0.0", remote="origin"), "2.0.0-rc2") + mock_get_remote_tags.assert_called_once_with("origin") + class DetermineNextVersionTest(TempDirTestCase): def setUp(self): @@ -942,8 +954,7 @@ def test_create_rc_success_first_rc(self): - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [ ] Tag RC0 | status=pending """ - self.mock_git.get_tags.return_value = [] - self.mock_git.get_tags_at_head.return_value = [] + self.mock_git.get_remote_tags.return_value = [] self.mock_git.get_commit_sha.return_value = "1234567890" # Act @@ -954,9 +965,10 @@ def test_create_rc_success_first_rc(self): self.mock_git.fetch.assert_has_calls( [call("my-remote"), call("my-remote", tags=True, force=True)] ) - self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") - self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -988,8 +1000,7 @@ def test_create_rc_success_next_rc(self): - [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 - [ ] Tag RC1 | status=pending """ - self.mock_git.get_tags.return_value = ["2.0.0-rc0"] - self.mock_git.get_tags_at_head.return_value = [] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] self.mock_git.get_commit_sha.return_value = "1234567890" # Act @@ -1000,9 +1011,10 @@ def test_create_rc_success_next_rc(self): self.mock_git.fetch.assert_has_calls( [call("my-remote"), call("my-remote", tags=True, force=True)] ) - self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") - self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") + self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -1022,28 +1034,6 @@ def test_create_rc_success_next_rc(self): ) self.assertNotIn("🚀", comment_call_args[1]) - def test_create_rc_already_tagged(self): - # Arrange - args = MagicMock(issue=123) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -- [ ] Tag RC0 | status=pending -""" - self.mock_git.get_tags.return_value = [] - self.mock_git.get_tags_at_head.return_value = ["2.0.0-rc0"] - - # Act - result = releaser.cmd_create_rc(args) - - # Assert - self.assertEqual(result, 0) - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - class CmdPromoteRcTest(unittest.TestCase): def setUp(self): @@ -1052,7 +1042,7 @@ def setUp(self): def test_promote_rc_success(self): # Arrange args = MagicMock(version="2.0.0", issue=123, dry_run=False) - self.mock_git.get_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False initial_body = "- [ ] Tag Final" @@ -1088,7 +1078,7 @@ def test_promote_rc_success(self): def test_promote_rc_resolve_issue_success(self): # Arrange args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = None self.mock_gh.get_release_tracking_issue.return_value = 123 @@ -1124,7 +1114,8 @@ def test_promote_rc_defaults_to_determine_next_version(self): # Arrange args = MagicMock(version=None, issue=123, dry_run=False) self.mock_git.get_current_branch.return_value = "release/2.0" - self.mock_git.get_tags.return_value = ["2.0.0", "2.0.1-rc0"] + self.mock_git.get_tags.return_value = ["2.0.0"] + self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"] self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.tag_exists.return_value = False initial_body = "- [ ] Tag Final" @@ -1136,7 +1127,8 @@ def test_promote_rc_defaults_to_determine_next_version(self): # Assert self.assertEqual(result, 0) self.mock_git.get_current_branch.assert_called_once() - self.assertTrue(self.mock_git.get_tags.call_count >= 2) + self.mock_git.get_tags.assert_called_once() + self.mock_git.get_remote_tags.assert_called_once_with("upstream") self.mock_git.checkout.assert_not_called() self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0") @@ -1159,7 +1151,7 @@ def test_promote_rc_defaults_to_determine_next_version(self): def test_promote_rc_dry_run_success(self): # Arrange args = MagicMock(version="2.0.0", issue=123, dry_run=True) - self.mock_git.get_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False initial_body = "- [ ] Tag Final" @@ -1183,7 +1175,7 @@ def test_promote_rc_dry_run_success(self): def test_promote_rc_tag_already_exists(self): # Arrange args = MagicMock(version="2.0.0", issue=123) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = True # Act @@ -1200,7 +1192,7 @@ def test_promote_rc_tag_already_exists(self): def test_promote_rc_issue_not_found(self): # Arrange args = MagicMock(version="2.0.0", issue=None) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( "Not found" @@ -1220,7 +1212,7 @@ def test_promote_rc_issue_not_found(self): def test_promote_rc_issue_malformed(self): # Arrange args = MagicMock(version="2.0.0", issue=123) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_git.get_commit_sha.return_value = "abcdef123456" initial_body = "malformed body" @@ -1240,7 +1232,7 @@ def test_promote_rc_issue_malformed(self): def test_promote_rc_no_rc_found(self): # Arrange args = MagicMock(version="2.0.0", issue=123) - self.mock_git.get_tags.return_value = [] + self.mock_git.get_remote_tags.return_value = [] # Act result = releaser.cmd_promote_rc(args) diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 9cd1a3b291..5273ca8195 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -53,7 +53,7 @@ def cmd_create_rc(args): # Determine next RC tag git.fetch(args.remote) git.fetch(args.remote, tags=True, force=True) - latest_rc = get_latest_rc_tag(version) + latest_rc = get_latest_rc_tag(version, remote=args.remote) if not latest_rc: next_rc_num = 0 @@ -79,19 +79,13 @@ def cmd_create_rc(args): ) return 1 - # Verify HEAD is not already tagged - git.checkout(f"{args.remote}/{branch_name}") - head_tags = git.get_tags_at_head() - if any(tag.startswith(f"{version}-rc") for tag in head_tags): - print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") - return 0 + target_ref = f"{args.remote}/{branch_name}" + commit_sha = git.get_commit_sha(target_ref) print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc, "HEAD") + git.tag(next_rc, target_ref) git.push(args.remote, next_rc) - commit_sha = git.get_commit_sha("HEAD") - # Check off the appropriate "Tag RC{N}" task in the checklist print(f"Checking off Tag RC{next_rc_num} task...") metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} diff --git a/tools/private/release/git.py b/tools/private/release/git.py index ce2bd8ca03..f7021b6f5c 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -123,12 +123,6 @@ def sort_commits_chronologically(shas): return output.splitlines() if output else [] -def get_tags_at_head(): - """Returns a list of tags pointing at the current HEAD commit.""" - output = run_cmd("git", "tag", "--points-at", "HEAD") - return output.splitlines() if output else [] - - def get_current_branch(): """Returns the current git branch name.""" return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") @@ -150,3 +144,30 @@ def is_ancestor(ancestor, descendant): return True except subprocess.CalledProcessError: return False + + +def get_remote_tags(remote: str) -> list[str]: + """Returns a list of tags present on the specified remote repository. + + Args: + remote: The name of the git remote to query (e.g., 'origin', 'upstream'). + + Returns: + A list of tag names (strings) found on the remote, excluding peeled tags. + """ + output = run_cmd("git", "ls-remote", "--tags", remote) + tags = [] + for line in output.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 2: + continue + ref = parts[1] + if ref.startswith("refs/tags/"): + tag = ref[len("refs/tags/") :] + # Skip peeled tags (e.g. tag^{}) to avoid + # duplicate tag names in the output. + if not tag.endswith("^{}"): + tags.append(tag) + return tags diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 7bc6f9ba05..e1ebf8ec2e 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -141,8 +141,8 @@ def cmd_process_backports(args): branch_name = f"release/{branch_version}" # Determine next RC tag to write to backport metadata - git.fetch("--tags", "--force") - latest_rc = get_latest_rc_tag(version) + git.fetch("origin", tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote="origin") if not latest_rc: next_rc_suffix = "rc0" else: @@ -236,13 +236,14 @@ def cmd_process_backports(args): def cmd_promote_rc(args): """Executes the promote-rc subcommand (Phase 3).""" + # Fetch from upstream to ensure we have the latest tags + git.fetch("upstream", tags=True, force=True) + version = args.version if version is None: version = determine_next_version() - # Fetch from upstream to ensure we have the latest tags - git.fetch("upstream", tags=True, force=True) - latest_rc = get_latest_rc_tag(version) + latest_rc = get_latest_rc_tag(version, remote="upstream") if not latest_rc: print(f"Error: No release candidate tags found matching {version}-rc*") return 1 diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 6f7383f8ec..2d050022b1 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -67,9 +67,12 @@ def get_latest_version(): return stable_versions[-1] -def get_latest_rc_tag(version): +def get_latest_rc_tag(version, remote=None): """Queries git tags and returns the highest RC tag for the version.""" - tags = git.get_tags() + if remote: + tags = git.get_remote_tags(remote) + else: + tags = git.get_tags() pattern = rf"^{re.escape(version)}-rc\d+$" rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] if not rc_tags: From 30a05831ff8cda98b7fe62fb48fdbf70a01faf09 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 16:28:37 -0700 Subject: [PATCH 048/179] feat(pypi): add pip.dep to declare abstract pypi dependencies (#3850) Introduce the pip.dep tag class to allow modules to declare abstract PyPI dependencies. These declarations ensure the target structure for a PyPI package is created, but provide a no-op implementation that passes analysis time, but fails at execution time. Along the way, create an agent rule for `*.bzl` files to help guide it in creating better bzl files. --- .agents/rules/bzl.md | 10 ++ docs/pypi/download.md | 35 +++++++ news/pip-dep-tag-class.added.md | 4 + python/private/pypi/extension.bzl | 45 +++++++++ python/private/pypi/missing_package.bzl | 9 +- tests/integration/unified_pypi/BUILD.bazel | 13 +++ tests/integration/unified_pypi/MODULE.bazel | 4 + .../unified_pypi/bin_declared_only.py | 2 + tests/integration/unified_pypi_test.py | 25 ++++- tests/pypi/extension/extension_tests.bzl | 93 ++++++++++++++++++- 10 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 .agents/rules/bzl.md create mode 100644 news/pip-dep-tag-class.added.md create mode 100644 tests/integration/unified_pypi/bin_declared_only.py diff --git a/.agents/rules/bzl.md b/.agents/rules/bzl.md new file mode 100644 index 0000000000..10aca72184 --- /dev/null +++ b/.agents/rules/bzl.md @@ -0,0 +1,10 @@ +--- +trigger: glob +description: Starlark / Bazel .bzl file coding style rules +globs: *.bzl +--- + +# Starlark Rules + +* Use triple-quoted strings for multi-line rule doc args. +* Don't use backslash line continuation in rule doc args. diff --git a/docs/pypi/download.md b/docs/pypi/download.md index 161a753645..88c2dd296d 100644 --- a/docs/pypi/download.md +++ b/docs/pypi/download.md @@ -121,6 +121,41 @@ Shared library targets can simply depend on the unified hub (e.g., `@pypi//numpy`), and the dependency will automatically resolve to the correct wheel version from the active hub during the build. +### Declaring Abstract Dependencies (pip.dep) + +:::{versionadded} VERSION_NEXT_FEATURE +Declaring abstract PyPI dependencies via `pip.dep` tags. +::: + +Sometimes a shared library target or a ruleset needs to depend on a PyPI +package (e.g., `@pypi//numpy`), but does not want to force a specific package +version or a concrete `requirements.txt` lock file on its consumers. + +Instead of calling `pip.parse()`, the module can declare its dependency using +the `pip.dep` tag: + +```starlark +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") + +# Declare an abstract dependency on 'numpy' and specify extra targets that +# are expected to be available in the package. +pip.dep( + name = "numpy", + extra_targets = ["extra-alias"], +) +``` + +This ensures that the target structure `@pypi//numpy` (and +`@pypi//numpy:extra-alias`) exists in the unified `@pypi` hub repository, so the +declaring module can compile and analyze successfully without needing any local +requirements file. + +The actual concrete implementation and version of the package must be provided +by a downstream module calling `pip.parse`. + +If a downstream module attempts to build a target that depends on an abstract +dependency, but has not provided a concrete implementation for it via any +`pip.parse` call, the build will fail at execution time. As with any repository rule or extension, if you would like to ensure that `pip_parse` is diff --git a/news/pip-dep-tag-class.added.md b/news/pip-dep-tag-class.added.md new file mode 100644 index 0000000000..5306186081 --- /dev/null +++ b/news/pip-dep-tag-class.added.md @@ -0,0 +1,4 @@ +(pypi) Added a `dep` tag class to the `pip` bzlmod extension. This allows +modules to declare abstract PyPI dependencies, ensuring target structures +exist in the unified hub, while allowing other modules to provide the +concrete implementation via `pip.parse`. diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index 02c5ad0c34..094cac7f3f 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -424,6 +424,15 @@ You cannot use both the additive_build_content and additive_build_content_file a pip_attr = pip_attr, ) + # dict[str package, dict[str, None] extra_targets] + declared_deps = {} + for mod in module_ctx.modules: + for dep_attr in mod.tags.dep: + name = normalize_name(dep_attr.name) + targets = declared_deps.setdefault(name, {}) + for target in dep_attr.extra_targets: + targets[target] = None + # Keeps track of all the hub's whl repos across the different versions. # dict[hub, dict[whl, dict[version, str pip]]] # Where hub, whl, and pip are the repo names @@ -448,6 +457,7 @@ You cannot use both the additive_build_content and additive_build_content_file a return struct( config = config, + declared_deps = declared_deps, default_hub = config.default_hub or renamed_default_hub, exposed_packages = exposed_packages, extra_aliases = extra_aliases, @@ -492,6 +502,15 @@ def _create_unified_hub_repo(mods): if hub_name not in extra_aliases[qual_alias]: extra_aliases[qual_alias].append(hub_name) + for norm_pkg, extra_targets in mods.declared_deps.items(): + if norm_pkg not in packages: + packages[norm_pkg] = [] + + for target_name in extra_targets: + qual_alias = "%s:%s" % (norm_pkg, target_name) + if qual_alias not in extra_aliases: + extra_aliases[qual_alias] = [] + unified_hub_repo( name = "pypi", default_hub = mods.default_hub or (hubs[0] if hubs else ""), @@ -1015,6 +1034,31 @@ Apply any overrides (e.g. patches) to a given Python distribution defined by other tags in this extension.""", ) +_dep_tag = tag_class( + attrs = { + "extra_targets": attr.string_list( + doc = """\ +A list of extra target names in the package that are expected to be available. +See {obj}`pip.parse.extra_hub_aliases`. +""", + default = [], + ), + "name": attr.string( + doc = "The name of a pypi package. Note that the name is normalized.", + mandatory = True, + ), + }, + doc = """\ +Declare an abstract PyPI dependency to ensure its target structure exists in the unified hub. + +This is useful for targets or rules that need to depend on a package (e.g., `@pypi//numpy`) +but do not want to force a specific version or concrete requirements lock file on their +consumers. The concrete version and implementation must be provided by downstreams calling +`pip.parse`. If they are not, the target will still be defined, but it will result in an +execution-phase error when built. +""", +) + pypi = module_extension( environ = ["RULES_PYTHON_PYPI_HUB_RESERVED"], doc = """\ @@ -1065,6 +1109,7 @@ terms used in this extension. ::: """, ), + "dep": _dep_tag, "override": _override_tag, "parse": tag_class( attrs = _pip_parse_ext_attrs(), diff --git a/python/private/pypi/missing_package.bzl b/python/private/pypi/missing_package.bzl index c59f754b10..46823e813b 100644 --- a/python/private/pypi/missing_package.bzl +++ b/python/private/pypi/missing_package.bzl @@ -6,12 +6,19 @@ load("//python/private:reexports.bzl", "BuiltinPyInfo") def _missing_package_error_impl(ctx): out = ctx.actions.declare_file(ctx.label.name + ".error") + if ctx.attr.hub_name: + hub_clause = ' when building under PyPI hub "{hub}". Try adding it to the requirements of this hub (e.g. requirements_lock or requirements_by_platform in pip.parse)'.format( + hub = ctx.attr.hub_name, + ) + else: + hub_clause = ' because no default PyPI hub was configured. Try designating a default hub via pip.default(default_hub = "...") or select a hub using --@rules_python//python/config_settings:venv' + # Register an action that fails when Bazel attempts to stage/build this file ctx.actions.run_shell( outputs = [out], command = "echo 'ERROR: PyPI package \"{pkg}\" is not available{hub_clause}.' >&2 && exit 1".format( pkg = ctx.attr.package_name, - hub_clause = (' when building under PyPI hub "%s"' % ctx.attr.hub_name) if ctx.attr.hub_name else " because no PyPI hub or default hub is requested", + hub_clause = hub_clause, ), ) diff --git a/tests/integration/unified_pypi/BUILD.bazel b/tests/integration/unified_pypi/BUILD.bazel index 8a37d4b153..c0b9905fa6 100644 --- a/tests/integration/unified_pypi/BUILD.bazel +++ b/tests/integration/unified_pypi/BUILD.bazel @@ -46,3 +46,16 @@ py_binary( }, deps = ["@pypi//six"], ) + +py_binary( + name = "bin_declared_only", + srcs = ["bin_declared_only.py"], + deps = ["@pypi//declared_only_pkg"], +) + +py_binary( + name = "bin_declared_only_alias", + srcs = ["bin_declared_only.py"], + main = "bin_declared_only.py", + deps = ["@pypi//declared_only_pkg:declared-only-alias"], +) diff --git a/tests/integration/unified_pypi/MODULE.bazel b/tests/integration/unified_pypi/MODULE.bazel index 0d0f44f61c..6a4b87e015 100644 --- a/tests/integration/unified_pypi/MODULE.bazel +++ b/tests/integration/unified_pypi/MODULE.bazel @@ -45,4 +45,8 @@ pip.parse( use_repo(pip, "pypi_b") pip.default(default_hub = "pypi_b") +pip.dep( + name = "declared-only-pkg", + extra_targets = ["declared-only-alias"], +) use_repo(pip, "pypi") diff --git a/tests/integration/unified_pypi/bin_declared_only.py b/tests/integration/unified_pypi/bin_declared_only.py new file mode 100644 index 0000000000..0b03607e77 --- /dev/null +++ b/tests/integration/unified_pypi/bin_declared_only.py @@ -0,0 +1,2 @@ +# Dummy file for integration test +print("declared_only") diff --git a/tests/integration/unified_pypi_test.py b/tests/integration/unified_pypi_test.py index 707ab13444..a19a9fcfbe 100644 --- a/tests/integration/unified_pypi_test.py +++ b/tests/integration/unified_pypi_test.py @@ -30,7 +30,7 @@ def test_disjoint_package_cquery_succeeds_but_build_fails(self): ) self.assert_result_matches( result, - 'ERROR: PyPI package "six" is not available when building under PyPI hub "pypi_a".', + 'ERROR: PyPI package "six" is not available when building under PyPI hub "pypi_a"\\. Try adding it to the requirements of this hub', ) def test_sibling_extra_alias_cquery_succeeds_but_build_fails(self): @@ -43,7 +43,7 @@ def test_sibling_extra_alias_cquery_succeeds_but_build_fails(self): ) self.assert_result_matches( result, - 'ERROR: PyPI package "colorama:my_colorama" is not available when building under PyPI hub "pypi_b".', + 'ERROR: PyPI package "colorama:my_colorama" is not available when building under PyPI hub "pypi_b"\\. Try adding it to the requirements of this hub', ) @contextlib.contextmanager @@ -74,6 +74,27 @@ def test_invalid_default_hub_fails_evaluation(self): "default_hub 'invalid_hub' is not a defined PyPI hub", ) + def test_unimplemented_declared_dep_fails_build(self): + # Even though cquery succeeds: + self.run_bazel("cquery", "//:bin_declared_only") + + # Build must fail because the package is not implemented by any concrete hub + result = self.run_bazel("build", "//:bin_declared_only", check=False) + self.assertNotEqual(result.exit_code, 0) + self.assert_result_matches( + result, + 'ERROR: PyPI package "declared_only_pkg" is not available when building under PyPI hub "pypi_b"\\. Try adding it to the requirements of this hub', + ) + + def test_unimplemented_declared_dep_alias_fails_build(self): + # Build must fail for alias too + result = self.run_bazel("build", "//:bin_declared_only_alias", check=False) + self.assertNotEqual(result.exit_code, 0) + self.assert_result_matches( + result, + 'ERROR: PyPI package "declared_only_pkg:declared-only-alias" is not available when building under PyPI hub "pypi_b"\\. Try adding it to the requirements of this hub', + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/pypi/extension/extension_tests.bzl b/tests/pypi/extension/extension_tests.bzl index bc4c0bcb5b..c2e0c8b60f 100644 --- a/tests/pypi/extension/extension_tests.bzl +++ b/tests/pypi/extension/extension_tests.bzl @@ -90,7 +90,13 @@ _default_tags_default = [ }.items() ] -def _mod(*, name, default = _default_tags_default, parse = [], override = [], whl_mods = [], is_root = True): +def _dep(*, name, extra_targets = []): + return struct( + name = name, + extra_targets = extra_targets, + ) + +def _mod(*, name, default = _default_tags_default, parse = [], override = [], whl_mods = [], dep = [], is_root = True): return struct( name = name, tags = struct( @@ -98,6 +104,7 @@ def _mod(*, name, default = _default_tags_default, parse = [], override = [], wh override = override, whl_mods = whl_mods, default = default, + dep = dep, ), is_root = is_root, ) @@ -106,6 +113,7 @@ def _parse_modules(env, **kwargs): return env.expect.that_struct( parse_modules(**kwargs), attrs = dict( + declared_deps = subjects.dict, default_hub = subjects.str, exposed_packages = subjects.dict, hub_group_map = subjects.dict, @@ -435,6 +443,89 @@ def _test_default_hub_precedence(env): _tests.append(_test_default_hub_precedence) +def _test_extension_dep(env): + pypi = _parse_modules( + env, + module_ctx = _pypi_mock_mctx( + _mod( + name = "my_module", + dep = [ + _dep( + name = "declared-pkg", + extra_targets = ["declared-alias"], + ), + ], + ), + os_name = "linux", + arch_name = "x86_64", + ), + available_interpreters = {}, + minor_mapping = {}, + ) + + pypi.declared_deps().contains_exactly({"declared_pkg": {"declared-alias": None}}) + pypi.exposed_packages().contains_exactly({}) + pypi.hub_group_map().contains_exactly({}) + pypi.hub_whl_map().contains_exactly({}) + pypi.whl_libraries().contains_exactly({}) + pypi.whl_mods().contains_exactly({}) + +_tests.append(_test_extension_dep) + +def _test_extension_dep_coexists_with_concrete_hub(env): + pypi = _parse_modules( + env, + module_ctx = _pypi_mock_mctx( + _mod( + name = "my_module", + parse = [ + _parse( + hub_name = "pypi_a", + python_version = "3.15", + simpleapi_skip = ["simple"], + requirements_lock = "requirements.txt", + ), + ], + dep = [ + _dep( + name = "simple", + extra_targets = ["extra-target"], + ), + ], + ), + os_name = "linux", + arch_name = "x86_64", + ), + available_interpreters = { + "python_3_15_host": "unit_test_interpreter_target", + }, + minor_mapping = {"3.15": "3.15.19"}, + ) + + pypi.declared_deps().contains_exactly({"simple": {"extra-target": None}}) + pypi.exposed_packages().contains_exactly({"pypi_a": ["simple"]}) + pypi.hub_group_map().contains_exactly({"pypi_a": {}}) + pypi.hub_whl_map().contains_exactly({"pypi_a": { + "simple": { + "pypi_a_315_simple": [ + whl_config_setting( + version = "3.15", + ), + ], + }, + }}) + pypi.whl_libraries().contains_exactly({ + "pypi_a_315_simple": { + "config_load": "@pypi_a//:config.bzl", + "dep_template": "@pypi_a//{name}:{target}", + "python_interpreter_target": "unit_test_interpreter_target", + "requirement": "simple==0.0.1 --hash=sha256:deadbeef --hash=sha256:deadbaaf", + }, + }) + pypi.whl_mods().contains_exactly({}) + +_tests.append(_test_extension_dep_coexists_with_concrete_hub) + def extension_test_suite(name): """Create the test suite. From cc799f17f5bf5ce8bb97f82f8bb649961138378d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 21:06:29 -0700 Subject: [PATCH 049/179] chore(release): refactor process-backports and various release tool fixes (#3881) This PR refactors the `process-backports` subcommand, improves its dry-run validation, and fixes a bug in `create-rc`. ### Why the changes are made - The `process-backports` logic was previously embedded in the main `release.py` script, making it difficult to maintain and extend. - `process-backports` lacked a robust dry-run validation to safely verify backport applicability without leaving the workspace dirty. - `process-backports` could fail with checkout ambiguity if the release branch existed on multiple remotes. - `create-rc` crashed with a fatal subscripting error when trying to access `BackportTask` objects as dictionaries after their recent refactoring. - `create-rc` tracking issue comments lacked a direct link to the BCR entry for the new version. --- tests/tools/private/release/release_test.py | 338 +++++++++++++++++++- tools/private/release/BUILD.bazel | 1 + tools/private/release/create_rc.py | 10 +- tools/private/release/gh.py | 45 ++- tools/private/release/git.py | 56 +++- tools/private/release/process_backports.py | 242 ++++++++++++++ tools/private/release/release.py | 145 +-------- tools/private/release/release_issue.py | 56 +++- 8 files changed, 724 insertions(+), 169 deletions(-) create mode 100644 tools/private/release/process_backports.py diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index d06b9097a9..1307f5e78b 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -1,3 +1,4 @@ +import datetime import os import pathlib import shutil @@ -5,7 +6,7 @@ import unittest from unittest.mock import MagicMock, call, patch -from tools.private.release import changelog_news, release as releaser, utils +from tools.private.release import changelog_news, git, release as releaser, utils from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError @@ -20,12 +21,14 @@ def _mock_git_and_gh(test_case): patch("tools.private.release.prepare.git", new=mock_git).start() patch("tools.private.release.create_release_branch.git", new=mock_git).start() patch("tools.private.release.create_rc.git", new=mock_git).start() + patch("tools.private.release.process_backports.git", new=mock_git).start() patch("tools.private.release.utils.git", new=mock_git).start() patch("tools.private.release.release.gh", new=mock_gh).start() patch("tools.private.release.prepare.gh", new=mock_gh).start() patch("tools.private.release.create_release_branch.gh", new=mock_gh).start() patch("tools.private.release.create_rc.gh", new=mock_gh).start() + patch("tools.private.release.process_backports.gh", new=mock_gh).start() mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -984,7 +987,19 @@ def test_create_rc_success_first_rc(self): comment_call_args[1], ) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)", + comment_call_args[1], + ) + self.assertIn( + "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) self.assertNotIn("🚀", comment_call_args[1]) @@ -1029,11 +1044,68 @@ def test_create_rc_success_next_rc(self): comment_call_args[1], ) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)", + comment_call_args[1], + ) + self.assertIn( + "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) self.assertNotIn("🚀", comment_call_args[1]) + def test_create_rc_gating_on_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [ ] #124 | status=pending +""" + # Act + result = releaser.cmd_create_rc(args) + + # Assert + self.assertEqual(result, 1) + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + + def test_create_rc_with_finished_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [x] #124 | status=done rc=rc0 commit=abcdef12 +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = releaser.cmd_create_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + class CmdPromoteRcTest(unittest.TestCase): def setUp(self): @@ -1384,5 +1456,265 @@ def test_create_release_branch_already_exists_non_ff(self): self.mock_gh.update_issue_body.assert_not_called() +class CmdProcessBackportsTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.mock_changelog_news = patch( + "tools.private.release.process_backports.changelog_news" + ).start() + self.addCleanup(patch.stopall) + + def test_process_backports_no_pending(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_body.return_value = "No backports here" + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_git.fetch.assert_not_called() + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_success(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("origin", tags=True, force=True), call("origin")] + ) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_changelog_news.update_changelog.assert_called_once_with( + "2.0.0", "2026-07-01" + ) + self.mock_git.add.assert_called_once_with("CHANGELOG.md", "news/") + self.mock_git.commit.assert_called_once_with( + 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True + ) + self.mock_git.push.assert_called_once_with("origin", "release/2.0") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [x] #124 | status=done rc=rc0 commit=12345678", call_args[1]) + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_dry_run(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=True) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("origin", tags=True, force=True), call("origin")] + ) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_changelog_news.update_changelog.assert_called_once_with( + "2.0.0", "2026-07-01" + ) + self.mock_git.commit.assert_called_once_with( + 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True + ) + self.mock_git.reset_hard.assert_called_once_with("12345678") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_process_backports_ignored_and_failed_states(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +- [ ] #125 | status=pending +- [ ] #126 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.status = "open-pr" + elif item.pr_ref == "#125": + item.status = "draft-pr" + elif item.pr_ref == "#126": + item.status = "error-closed-pr" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 1) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #126 | status=error-closed-pr", call_args[1]) + self.assertNotIn("status=open-pr", call_args[1]) + self.assertNotIn("status=draft-pr", call_args[1]) + self.mock_git.checkout.assert_not_called() + self.mock_git.cherry_pick.assert_not_called() + + def test_process_backports_ignored_error_status(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=error-merge-conflict +- [ ] #125 | status=error-some-other-error +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_gh.get_merge_commits_for_prs.return_value = [] + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_gh.get_merge_commits_for_prs.assert_not_called() + self.mock_git.checkout.assert_not_called() + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_cherry_pick_failed(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 1) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_git.cherry_pick_abort.assert_called_once() + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #124 | status=error-merge-conflict", call_args[1]) + + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_not_called() + + +class GitCheckoutTest(unittest.TestCase): + @patch("tools.private.release.git.run_cmd") + def test_checkout_simple(self, mock_run_cmd): + git.checkout("my-branch") + mock_run_cmd.assert_called_once_with( + "git", "checkout", "my-branch", capture_output=False + ) + + @patch("tools.private.release.git.branch_exists") + @patch("tools.private.release.git.run_cmd") + def test_checkout_track_remote_new_branch(self, mock_run_cmd, mock_branch_exists): + mock_branch_exists.return_value = False + + git.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + mock_run_cmd.assert_called_once_with( + "git", "checkout", "--track", "origin/my-branch", capture_output=False + ) + + @patch("tools.private.release.git.reset_hard") + @patch("tools.private.release.git.branch_exists") + @patch("tools.private.release.git.run_cmd") + def test_checkout_track_remote_existing_branch( + self, mock_run_cmd, mock_branch_exists, mock_reset_hard + ): + mock_branch_exists.return_value = True + + git.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + mock_run_cmd.assert_called_once_with( + "git", "checkout", "my-branch", capture_output=False + ) + mock_reset_hard.assert_called_once_with("origin/my-branch") + + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index ad2e1b4cdc..1ae52cf37a 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -15,6 +15,7 @@ py_binary( "gh.py", "git.py", "prepare.py", + "process_backports.py", "release.py", "release_issue.py", "shell.py", diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 5273ca8195..a4593323aa 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -30,7 +30,7 @@ def cmd_create_rc(args): # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done backports = parse_backports(body) conflicting_or_pending = [ - b for b in backports if not b["checked"] or b["status"] != "done" + b for b in backports if not b.checked or b.status != "done" ] if conflicting_or_pending: print( @@ -94,15 +94,17 @@ def cmd_create_rc(args): gh.update_issue_body(args.issue, updated_body) tag_url = f"{REPO_URL}/releases/tag/{next_rc}" + bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. -- View Tag: [{next_rc}]({tag_url}) -- Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) -- Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" +- [Github Release {next_rc}]({tag_url}) +- BCR Entry: [rules_python@{version}]({bcr_entry_url}) +- [BCR PRs]({bcr_search_url}) +- [Release workflow status]({release_workflow_url})""" gh.post_issue_comment(args.issue, comment_body) print("RC creation completed successfully!") return 0 diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index da86415650..cec20ca2d6 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -4,6 +4,7 @@ import os import tempfile +from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd _REPO = "bazel-contrib/rules_python" @@ -179,13 +180,13 @@ def get_open_pr(branch_name): def get_pr_info(pr_num): - """Gets information about a PR, including state, merge commit, and body.""" + """Gets information about a PR, including state, merge commit, body, and draft status.""" output = run_cmd( "gh", "pr", "view", str(pr_num), - "--json=state,mergeCommit,body", + "--json=state,mergeCommit,body,isDraft", ) return json.loads(output) if output else {} @@ -202,30 +203,44 @@ def post_issue_comment(issue_num, comment_body): ) -def resolve_backport_commits(pending_items): +def get_merge_commits_for_prs(pending_items: list[BackportTask]) -> list[BackportTask]: """Resolves PR references in pending backports to their merge commit SHAs. - Marks unmerged PRs or resolution failures with status='unmerged-pr'. + Updates item.status based on PR state if it cannot be resolved. """ resolved_items = [] for item in pending_items: - pr_num = item["pr_ref"].lstrip("#") + pr_num = item.pr_ref.lstrip("#") print(f"Resolving PR #{pr_num} to merge commit...") try: pr_info = get_pr_info(pr_num) - if not pr_info or pr_info.get("state") != "MERGED": - state = pr_info.get("state", "UNKNOWN") - print(f"PR #{pr_num} is not merged (state: {state}). Gating.") - item["status"] = "unmerged-pr" + if not pr_info: + print(f"PR #{pr_num} not found. Gating.") + item.status = "error-not-found" else: - merge_commit = pr_info.get("mergeCommit") - if merge_commit and "oid" in merge_commit: - item["commit"] = merge_commit["oid"] + state = pr_info.get("state") + is_draft = pr_info.get("isDraft", False) + if state == "OPEN" or is_draft: + print( + f"PR #{pr_num} is open or draft (state: {state}, draft: {is_draft}). Ignoring." + ) + item.status = "open-pr" if not is_draft else "draft-pr" + elif state == "CLOSED": + print(f"PR #{pr_num} is closed but not merged. Gating.") + item.status = "error-closed-pr" + elif state == "MERGED": + merge_commit = pr_info.get("mergeCommit") + if merge_commit and "oid" in merge_commit: + item.commit = merge_commit["oid"] + item.status = "resolved" + else: + print(f"PR #{pr_num} has no merge commit SHA. Gating.") + item.status = "error-no-merge-commit" else: - print(f"PR #{pr_num} has no merge commit SHA. Gating.") - item["status"] = "unmerged-pr" + print(f"PR #{pr_num} has unknown state: {state}. Gating.") + item.status = "error-unknown" except Exception as e: print(f"Error resolving PR #{pr_num}: {e}. Gating.") - item["status"] = "unmerged-pr" + item.status = "error-resolution-failed" resolved_items.append(item) return resolved_items diff --git a/tools/private/release/git.py b/tools/private/release/git.py index f7021b6f5c..795710fd8c 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -11,12 +11,34 @@ def get_tags(): return output.splitlines() if output else [] -def checkout(ref, create_branch=False): - """Checks out a git reference (tag, branch, or commit).""" +def checkout( + ref: str, create_branch: bool = False, track_remote: str | None = None +) -> None: + """Checks out a git reference (tag, branch, or commit). + + Args: + ref: The git reference (tag, branch, or commit) to checkout. + create_branch: If True, creates the branch before checking it out. + track_remote: If specified, checks out the branch tracking this remote's + corresponding branch. + """ + cmd = ["git", "checkout"] if create_branch: - run_cmd("git", "checkout", "-b", ref, capture_output=False) + cmd.append("-b") + + should_reset_hard = False + if track_remote: + if branch_exists(ref): + cmd.append(ref) + should_reset_hard = True + else: + cmd.extend(["--track", f"{track_remote}/{ref}"]) else: - run_cmd("git", "checkout", ref, capture_output=False) + cmd.append(ref) + run_cmd(*cmd, capture_output=False) + + if should_reset_hard: + reset_hard(f"{track_remote}/{ref}") def add(*files): @@ -75,8 +97,12 @@ def tag(tag_name, commit_ref): run_cmd("git", "tag", tag_name, commit_ref, capture_output=False) -def cherry_pick(sha): - """Cherry-picks a commit using -x to append the original commit info.""" +def cherry_pick(sha: str) -> None: + """Cherry-picks a commit. + + Args: + sha: The commit SHA to cherry-pick. + """ run_cmd("git", "cherry-pick", "-x", sha, capture_output=False) @@ -85,6 +111,15 @@ def cherry_pick_abort(): run_cmd("git", "cherry-pick", "--abort", capture_output=False) +def reset_hard(ref: str = "HEAD") -> None: + """Resets the index and working tree to a specific reference. + + Args: + ref: The git reference to reset to. Defaults to 'HEAD'. + """ + run_cmd("git", "reset", "--hard", ref, capture_output=False) + + def status(): """Returns the output of git status --porcelain.""" return run_cmd("git", "status", "--porcelain") @@ -99,6 +134,15 @@ def get_commit_sha(ref="HEAD", short=False): return run_cmd(*cmd) +def get_commit_message(ref: str = "HEAD") -> str: + """Returns the commit message of a given reference. + + Args: + ref: The git reference to get the message from. Defaults to 'HEAD'. + """ + return run_cmd("git", "log", "-1", "--format=%B", ref) + + def branch_exists(branch_name): """Returns True if a local branch exists.""" try: diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py new file mode 100644 index 0000000000..373e1848ec --- /dev/null +++ b/tools/private/release/process_backports.py @@ -0,0 +1,242 @@ +"""Subcommand to process pending backports.""" + +import datetime +from typing import Any + +from tools.private.release import changelog_news, gh, git +from tools.private.release.release_issue import ( + RELEASE_TITLE_RE, + parse_backports, + update_task_in_body, +) +from tools.private.release.utils import get_latest_rc_tag + + +def _process_pr_commit_infos( + pr_commit_infos, body, issue, dry_run +) -> tuple[list[str], dict[str, Any], list[str], list[str], str]: + shas = [] + sha_to_item = {} + failed_prs = [] + ignored_prs = [] + for item in pr_commit_infos: + if item.commit: + sha = item.commit + sha_to_item[sha] = item + shas.append(sha) + elif item.status in ("open-pr", "draft-pr"): + print(f"PR {item.pr_ref} is open or draft. Ignoring.") + ignored_prs.append(item.pr_ref) + else: + failed_prs.append(item.pr_ref) + status_to_set = item.status or "error-unmerged-pr" + if dry_run: + print( + f"[DRY RUN] Would update tracking issue checklist for unresolved PR {item.pr_ref} to status={status_to_set}" + ) + else: + print( + f"Updating tracking issue checklist for unresolved PR {item.pr_ref}..." + ) + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": status_to_set}, + ) + gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for unresolved PR {item.pr_ref}: {e}" + ) + return shas, sha_to_item, failed_prs, ignored_prs, body + + +def _cherry_pick_and_update_prs( + sorted_shas, + sha_to_item, + body, + issue, + remote, + dry_run, + version, + branch_name, + next_rc_suffix, +) -> tuple[list[str], str]: + failed_prs = [] + for sha in sorted_shas: + item = sha_to_item[sha] + print(f"Cherry-picking {item.pr_ref} / {sha}...") + try: + git.cherry_pick(sha) + + # Perform news processing (merging news/ files into the changelog) + print(f"Merging news fragments into changelog for PR {item.pr_ref}...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + + # Stage changelog changes and news/ deletions + git.add("CHANGELOG.md", "news/") + + # Amend cherry-pick commit to include news merging and deletions, + # and reference the release tracking issue. + print(f"Amending cherry-pick commit for PR {item.pr_ref}...") + current_msg = git.get_commit_message("HEAD") + new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" + git.commit(new_msg, amend=True) + + if not dry_run: + # Push amended commit + git.push(remote, branch_name) + + new_sha = git.get_commit_sha("HEAD", short=True) + metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} + print(f"Updating tracking issue checklist for PR {item.pr_ref}...") + try: + body = update_task_in_body( + body, item.pr_ref, checked=True, metadata=metadata + ) + gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for PR {item.pr_ref}: {e}" + ) + print(f"Success: backported {item.pr_ref} / {sha} to {branch_name}") + else: + print( + f"[DRY RUN] Success: {item.pr_ref} / {sha} can be backported without error." + ) + print( + f"[DRY RUN] Would update tracking issue checklist for PR {item.pr_ref} to status=done" + ) + except Exception as e: + print(f"ERROR: Conflict or error on {sha}: {e}. Aborting.") + try: + git.cherry_pick_abort() + except Exception: + pass + failed_prs.append(item.pr_ref) + + if dry_run: + print( + f"[DRY RUN] Would update tracking issue checklist for failed PR {item.pr_ref} to status=error-merge-conflict" + ) + else: + print( + f"Updating tracking issue checklist for failed PR {item.pr_ref}..." + ) + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": "error-merge-conflict"}, + ) + gh.update_issue_body(issue, body) + print( + f"Updated back port of {item.pr_ref} to status=error-merge-conflict (unchecked)" + ) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for failed PR {item.pr_ref}: {e}" + ) + return failed_prs, body + + +def cmd_process_backports(args): + """Executes the process-backports subcommand.""" + body = gh.get_issue_body(args.issue) + items = parse_backports(body) + + pending_items = [ + item + for item in items + if not item.checked and not item.status.startswith("error-") + ] + + if not pending_items: + print("No pending backports found.") + return 0 + + print(f"Found {len(pending_items)} pending backports to process.") + + # Determine branch name from issue title + issue_title = gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag to write to backport metadata + git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote=args.remote) + if not latest_rc: + next_rc_suffix = "rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_suffix = f"rc{rc_num + 1}" + + # Resolve PRs to merge commits using gh helper. + pr_commit_infos = gh.get_merge_commits_for_prs(pending_items) + + shas, sha_to_item, failed_prs, ignored_prs, body = _process_pr_commit_infos( + pr_commit_infos, body, args.issue, args.dry_run + ) + + if not shas: + print("No valid merge commits to process.") + if failed_prs: + print("Failed PRs:") + for pr in failed_prs: + print(f"- {pr}") + return 1 + return 0 + + # Verify workspace is clean before proceeding + if git.status(): + print( + "ERROR: Git workspace is dirty. Please commit or stash changes before running backports." + ) + return 1 + + # Sort chronologically using git helper + sorted_shas = git.sort_commits_chronologically(shas) + + git.fetch(args.remote) + git.checkout(branch_name, track_remote=args.remote) + start_sha = git.get_commit_sha("HEAD") + + try: + new_failed_prs, body = _cherry_pick_and_update_prs( + sorted_shas, + sha_to_item, + body, + args.issue, + args.remote, + args.dry_run, + version, + branch_name, + next_rc_suffix, + ) + failed_prs.extend(new_failed_prs) + finally: + if args.dry_run: + print(f"[DRY RUN] Resetting branch {branch_name} to {start_sha}") + git.reset_hard(start_sha) + + if failed_prs: + print("ERROR: One or more cherry-picks/resolutions failed:") + for pr in failed_prs: + print(f"- {pr}") + return 1 + + if args.dry_run: + print("Dry run completed successfully. No errors found.") + else: + print("All backports successfully processed!") + return 0 diff --git a/tools/private/release/release.py b/tools/private/release/release.py index e1ebf8ec2e..a6c5b3f88b 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -1,19 +1,17 @@ """A tool to perform release steps.""" import argparse -import datetime import os import pathlib import re import sys -from tools.private.release import changelog_news, gh, git +from tools.private.release import gh, git from tools.private.release.create_rc import cmd_create_rc from tools.private.release.create_release_branch import cmd_create_release_branch from tools.private.release.prepare import cmd_prepare +from tools.private.release.process_backports import cmd_process_backports from tools.private.release.release_issue import ( - RELEASE_TITLE_RE, - parse_backports, update_task_in_body, ) from tools.private.release.utils import ( @@ -31,11 +29,6 @@ def _semver_type(value): return value -# ============================================================================== -# Checklist Parser and Formatter (Using new | key=value syntax) -# ============================================================================== - - # ============================================================================== # Subcommand Execution Functions # ============================================================================== @@ -112,128 +105,6 @@ def cmd_complete_prepare(args): return 0 -def cmd_process_backports(args): - """Executes the process-backports subcommand.""" - body = gh.get_issue_body(args.issue) - items = parse_backports(body) - - pending_items = [ - item - for item in items - if not item["checked"] and item["status"] != "merge-conflict" - ] - - if not pending_items: - print("No pending backports found.") - return 0 - - print(f"Found {len(pending_items)} pending backports to process.") - - # Determine branch name from issue title - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - # Determine next RC tag to write to backport metadata - git.fetch("origin", tags=True, force=True) - latest_rc = get_latest_rc_tag(version, remote="origin") - if not latest_rc: - next_rc_suffix = "rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_suffix = f"rc{rc_num + 1}" - - # Resolve PRs to merge commits using gh helper - resolved_items = gh.resolve_backport_commits(pending_items) - - shas = [] - sha_to_item = {} - any_failed = False - for item in resolved_items: - if item.get("commit"): - sha = item["commit"] - sha_to_item[sha] = item - shas.append(sha) - else: - any_failed = True - body = update_task_in_body( - body, - item["pr_ref"], - checked=False, - metadata={"status": item.get("status", "failed")}, - ) - gh.update_issue_body(args.issue, body) - - if not shas: - print("No valid merge commits to process.") - if any_failed: - return 1 - return 0 - - # Sort chronologically using git helper - sorted_shas = git.sort_commits_chronologically(shas) - - git.fetch("origin") - git.checkout(branch_name) - - for sha in sorted_shas: - item = sha_to_item[sha] - print(f"Cherry-picking {sha} (PR {item['pr_ref']})...") - try: - git.cherry_pick(sha) - - # Perform news processing (merging news/ files into the changelog) - print(f"Merging news fragments into changelog for PR {item['pr_ref']}...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - - # Stage changelog changes and news/ deletions - git.add("CHANGELOG.md", "news/") - - # Amend cherry-pick commit to include news merging and deletions - print(f"Amending cherry-pick commit for PR {item['pr_ref']}...") - git.commit("", amend=True, no_edit=True) - - # Push amended commit - git.push("origin", branch_name) - - new_sha = git.get_commit_sha("HEAD", short=True) - metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} - body = update_task_in_body( - body, item["pr_ref"], checked=True, metadata=metadata - ) - gh.update_issue_body(args.issue, body) - print(f"Applied: SUCCESS {new_sha}") - except Exception as e: - print(f"Conflict or error on {sha}: {e}. Aborting.") - try: - git.cherry_pick_abort() - except Exception: - pass - any_failed = True - - body = update_task_in_body( - body, - item["pr_ref"], - checked=False, - metadata={"status": "merge-conflict"}, - ) - gh.update_issue_body(args.issue, body) - print("Updated backport item to status=merge-conflict (unchecked)") - - if any_failed: - print("One or more cherry-picks/resolutions failed.") - return 1 - print("All backports successfully processed!") - return 0 - - def cmd_promote_rc(args): """Executes the promote-rc subcommand (Phase 3).""" # Fetch from upstream to ensure we have the latest tags @@ -411,6 +282,18 @@ def create_parser(): required=True, help="The tracking issue number (required).", ) + process_backports_parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push changes to (required).", + ) + process_backports_parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) # Subcommand: create-rc create_rc_parser = subparsers.add_parser( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index eb7414e39a..adc0d88086 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -1,7 +1,43 @@ -"""Helper functions for managing release tracking issues and checklists.""" - import re + +class BackportTask: + """Represents a backport task from the tracking issue checklist.""" + + def __init__( + self, + pr_ref: str, + checked: bool, + status: str, + rc: str | None = None, + commit: str | None = None, + metadata: dict[str, str] | None = None, + ): + """Initializes a BackportTask. + + Args: + pr_ref: The PR reference (e.g. '#123'). + checked: Whether the checklist item is checked. + status: The status of the backport (e.g. 'pending', 'done', + 'error-merge-conflict'). + rc: The release candidate version this PR was backported to. + commit: The cherry-pick commit SHA. + metadata: Raw metadata parsed from the checklist line. + """ + self.pr_ref = pr_ref + self.checked = checked + self.status = status + self.rc = rc + self.commit = commit + self.metadata = metadata or {} + + def __repr__(self): + return ( + f"BackportTask(pr_ref={self.pr_ref!r}, checked={self.checked!r}, " + f"status={self.status!r}, rc={self.rc!r}, commit={self.commit!r})" + ) + + RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) @@ -147,13 +183,13 @@ def parse_backports(body): parsed = parse_metadata_line(line) if parsed: items.append( - { - "pr_ref": parsed["name"], - "checked": parsed["checked"], - "status": parsed["metadata"].get("status", "PENDING"), - "rc": parsed["metadata"].get("rc"), - "commit": parsed["metadata"].get("commit"), - "metadata": parsed["metadata"], - } + BackportTask( + pr_ref=parsed["name"], + checked=parsed["checked"], + status=parsed["metadata"].get("status", "pending"), + rc=parsed["metadata"].get("rc"), + commit=parsed["metadata"].get("commit"), + metadata=parsed["metadata"], + ) ) return items From 839deafd3cd87798ea142ec0c1012675f9cbbfb3 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 00:30:15 -0700 Subject: [PATCH 050/179] chore(release): factor code, make testable, clarify workflow names (#3882) This refactors the release tool code to be more testable and broken up into smaller files. Workflows are also renamed to better match what they do and the command they run. --- ...rged.yml => release_complete_prepare.yaml} | 2 +- ...generate_rc.yml => release_create_rc.yaml} | 2 +- ...yml => release_create_release_branch.yaml} | 2 +- ...epare_release.yml => release_prepare.yaml} | 2 +- ...rts.yml => release_process_backports.yaml} | 2 +- ...promote_rc.yml => release_promote_rc.yaml} | 2 +- tests/tools/private/release/release_test.py | 172 +++--- tools/private/release/BUILD.bazel | 5 + tools/private/release/__init__.py | 1 + tools/private/release/complete_prepare.py | 81 +++ tools/private/release/create_rc.py | 216 ++++--- .../private/release/create_release_branch.py | 171 ++++-- tools/private/release/create_release_issue.py | 59 ++ .../private/release/determine_next_version.py | 30 + tools/private/release/gh.py | 547 +++++++++++------- tools/private/release/git.py | 543 ++++++++++------- tools/private/release/prepare.py | 321 +++++----- tools/private/release/process_backports.py | 470 ++++++++------- tools/private/release/promote_rc.py | 143 +++++ tools/private/release/release.py | 360 +----------- tools/private/release/release_issue.py | 124 ++-- tools/private/release/shell.py | 8 +- tools/private/release/utils.py | 16 +- 23 files changed, 1910 insertions(+), 1369 deletions(-) rename .github/workflows/{on_prepare_release_pr_merged.yml => release_complete_prepare.yaml} (96%) rename .github/workflows/{generate_rc.yml => release_create_rc.yaml} (97%) rename .github/workflows/{cut_release_branch.yml => release_create_release_branch.yaml} (96%) rename .github/workflows/{prepare_release.yml => release_prepare.yaml} (97%) rename .github/workflows/{process_backports.yml => release_process_backports.yaml} (96%) rename .github/workflows/{promote_rc.yml => release_promote_rc.yaml} (96%) create mode 100644 tools/private/release/__init__.py create mode 100644 tools/private/release/complete_prepare.py create mode 100644 tools/private/release/create_release_issue.py create mode 100644 tools/private/release/determine_next_version.py create mode 100644 tools/private/release/promote_rc.py diff --git a/.github/workflows/on_prepare_release_pr_merged.yml b/.github/workflows/release_complete_prepare.yaml similarity index 96% rename from .github/workflows/on_prepare_release_pr_merged.yml rename to .github/workflows/release_complete_prepare.yaml index 8ad4d1055c..6d8bc8fd03 100644 --- a/.github/workflows/on_prepare_release_pr_merged.yml +++ b/.github/workflows/release_complete_prepare.yaml @@ -1,4 +1,4 @@ -name: On PR Merged (Release Prepared) +name: "Release: Complete Prepare" on: pull_request: diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/release_create_rc.yaml similarity index 97% rename from .github/workflows/generate_rc.yml rename to .github/workflows/release_create_rc.yaml index 19a80bb1de..2e22ed1413 100644 --- a/.github/workflows/generate_rc.yml +++ b/.github/workflows/release_create_rc.yaml @@ -1,4 +1,4 @@ -name: Generate RC Tag +name: "Release: Create RC" on: workflow_dispatch: diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/release_create_release_branch.yaml similarity index 96% rename from .github/workflows/cut_release_branch.yml rename to .github/workflows/release_create_release_branch.yaml index f054f4cbc8..ea030a7085 100644 --- a/.github/workflows/cut_release_branch.yml +++ b/.github/workflows/release_create_release_branch.yaml @@ -1,4 +1,4 @@ -name: Cut Release Branch +name: "Release: Create Release Branch" on: issues: diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/release_prepare.yaml similarity index 97% rename from .github/workflows/prepare_release.yml rename to .github/workflows/release_prepare.yaml index b080f121eb..5c9a0b8f49 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/release_prepare.yaml @@ -1,4 +1,4 @@ -name: Prepare Release +name: "Release: Prepare" on: workflow_dispatch: diff --git a/.github/workflows/process_backports.yml b/.github/workflows/release_process_backports.yaml similarity index 96% rename from .github/workflows/process_backports.yml rename to .github/workflows/release_process_backports.yaml index 4d8055d3c5..ac6ea99d80 100644 --- a/.github/workflows/process_backports.yml +++ b/.github/workflows/release_process_backports.yaml @@ -1,4 +1,4 @@ -name: Process Backports +name: "Release: Process Backports" on: workflow_dispatch: diff --git a/.github/workflows/promote_rc.yml b/.github/workflows/release_promote_rc.yaml similarity index 96% rename from .github/workflows/promote_rc.yml rename to .github/workflows/release_promote_rc.yaml index d5e697f6d7..b668a15338 100644 --- a/.github/workflows/promote_rc.yml +++ b/.github/workflows/release_promote_rc.yaml @@ -1,4 +1,4 @@ -name: Promote RC to Final Release +name: "Release: Promote RC" on: workflow_dispatch: diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 1307f5e78b..96bb65280f 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -6,8 +6,17 @@ import unittest from unittest.mock import MagicMock, call, patch -from tools.private.release import changelog_news, git, release as releaser, utils -from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError +from tools.private.release import changelog_news, release as releaser, utils +from tools.private.release.create_rc import CreateRc +from tools.private.release.create_release_branch import CreateReleaseBranch +from tools.private.release.gh import ( + MultipleTrackingIssuesError, + NoTrackingIssueError, +) +from tools.private.release.git import Git +from tools.private.release.prepare import Prepare +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.promote_rc import PromoteRc def _mock_git_and_gh(test_case): @@ -16,19 +25,9 @@ def _mock_git_and_gh(test_case): test_case.mock_git = mock_git test_case.mock_gh = mock_gh - # Patch bindings in modules that import them at module level - patch("tools.private.release.release.git", new=mock_git).start() - patch("tools.private.release.prepare.git", new=mock_git).start() - patch("tools.private.release.create_release_branch.git", new=mock_git).start() - patch("tools.private.release.create_rc.git", new=mock_git).start() - patch("tools.private.release.process_backports.git", new=mock_git).start() - patch("tools.private.release.utils.git", new=mock_git).start() - - patch("tools.private.release.release.gh", new=mock_gh).start() - patch("tools.private.release.prepare.gh", new=mock_gh).start() - patch("tools.private.release.create_release_branch.gh", new=mock_gh).start() - patch("tools.private.release.create_rc.gh", new=mock_gh).start() - patch("tools.private.release.process_backports.gh", new=mock_gh).start() + # Mock Git inside utils.py since it instantiates it locally + patch("tools.private.release.utils.Git", return_value=mock_git).start() + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -528,12 +527,12 @@ def test_invalid_version(self): class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_success(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] self.assertEqual(utils.get_latest_version(), "1.0.0") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_rc_is_latest(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -541,7 +540,7 @@ def test_get_latest_version_rc_is_latest(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] with self.assertRaisesRegex( @@ -549,7 +548,7 @@ def test_get_latest_version_no_tags(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["v1.0", "latest"] with self.assertRaisesRegex( @@ -557,7 +556,7 @@ def test_get_latest_version_no_matching_tags(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_only_rc_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -567,17 +566,17 @@ def test_get_latest_version_only_rc_tags(self, mock_get_tags): class GetLatestRcTagTest(unittest.TestCase): - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0"] self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_success(self, mock_get_tags): mock_get_tags.return_value = [ "2.0.0-rc0", @@ -587,12 +586,12 @@ def test_get_latest_rc_tag_success(self, mock_get_tags): ] self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") - @patch("tools.private.release.git.get_remote_tags") + @patch("tools.private.release.git.Git.get_remote_tags") def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): mock_get_remote_tags.return_value = [ "2.0.0-rc0", @@ -611,7 +610,7 @@ def setUp(self): "tools.private.release.utils.get_latest_version" ).start() self.mock_get_current_branch = patch( - "tools.private.release.git.get_current_branch" + "tools.private.release.git.Git.get_current_branch" ).start() self.mock_get_current_branch.return_value = "main" self.addCleanup(patch.stopall) @@ -657,8 +656,8 @@ def test_both_markers(self): self.assertEqual(next_version, "1.3.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_existing_tags( self, mock_get_tags, mock_get_branch ): @@ -669,8 +668,8 @@ def test_determine_next_version_on_release_branch_with_existing_tags( self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_no_tags( self, mock_get_tags, mock_get_branch ): @@ -681,8 +680,8 @@ def test_determine_next_version_on_release_branch_no_tags( self.assertEqual(next_version, "0.38.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_active_rc( self, mock_get_tags, mock_get_branch ): @@ -695,8 +694,8 @@ def test_determine_next_version_on_release_branch_with_active_rc( # Should target 0.37.0, not 0.37.1 self.assertEqual(next_version, "0.37.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( self, mock_get_tags, mock_get_branch ): @@ -709,7 +708,7 @@ def test_determine_next_version_on_release_branch_with_stable_and_active_patch_r # Should target 0.37.1, not 0.37.2 self.assertEqual(next_version, "0.37.1") - @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.Git.get_current_branch") def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): mock_get_branch.return_value = "main" # Should fallback to default behavior (which uses mock_get_latest_version from setUp) @@ -739,7 +738,7 @@ def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -768,7 +767,7 @@ def test_prepare_success_create_issue(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -791,7 +790,7 @@ def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -810,7 +809,7 @@ def test_prepare_dry_run(self, mock_replace, mock_changelog): self.mock_gh.get_release_tracking_issue.return_value = 123 # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -841,7 +840,7 @@ def test_prepare_use_associated_pr_from_tracking_issue( ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -871,7 +870,7 @@ def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changel self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/789" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -902,7 +901,7 @@ def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -933,7 +932,7 @@ def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -961,7 +960,7 @@ def test_create_rc_success_first_rc(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1019,7 +1018,7 @@ def test_create_rc_success_next_rc(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1075,7 +1074,7 @@ def test_create_rc_gating_on_backports(self): - [ ] #124 | status=pending """ # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1099,7 +1098,7 @@ def test_create_rc_with_finished_backports(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1121,7 +1120,7 @@ def test_promote_rc_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1159,7 +1158,7 @@ def test_promote_rc_resolve_issue_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1194,7 +1193,7 @@ def test_promote_rc_defaults_to_determine_next_version(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1230,7 +1229,7 @@ def test_promote_rc_dry_run_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1251,7 +1250,7 @@ def test_promote_rc_tag_already_exists(self): self.mock_git.tag_exists.return_value = True # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1271,7 +1270,7 @@ def test_promote_rc_issue_not_found(self): ) # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1291,7 +1290,7 @@ def test_promote_rc_issue_malformed(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1307,7 +1306,7 @@ def test_promote_rc_no_rc_found(self): self.mock_git.get_remote_tags.return_value = [] # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1333,7 +1332,7 @@ def test_create_release_branch_success(self): self.mock_git.remote_branch_exists.return_value = False # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1362,7 +1361,7 @@ def test_create_release_branch_prepare_not_done(self): - [ ] Create Release branch | status=pending """ # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1380,7 +1379,7 @@ def test_create_release_branch_already_checked(self): - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 """ # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1401,7 +1400,7 @@ def test_create_release_branch_already_exists_same_commit(self): self.mock_git.get_commit_sha.return_value = "abcdef12" # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1423,7 +1422,7 @@ def test_create_release_branch_already_exists_fast_forward(self): self.mock_git.is_ancestor.return_value = True # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1447,7 +1446,7 @@ def test_create_release_branch_already_exists_non_ff(self): self.mock_git.is_ancestor.return_value = False # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1468,7 +1467,7 @@ def test_process_backports_no_pending(self): args = MagicMock(issue=123, remote="origin", dry_run=False) self.mock_gh.get_issue_body.return_value = "No backports here" - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_gh.get_issue_body.assert_called_once_with(123) @@ -1502,7 +1501,7 @@ def mock_resolve(items): self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( @@ -1554,7 +1553,7 @@ def mock_resolve(items): self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( @@ -1601,7 +1600,7 @@ def mock_resolve(items): self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 1) self.mock_gh.update_issue_body.assert_called_once() @@ -1628,7 +1627,7 @@ def test_process_backports_ignored_error_status(self): self.mock_git.get_remote_tags.return_value = [] self.mock_gh.get_merge_commits_for_prs.return_value = [] - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_gh.get_merge_commits_for_prs.assert_not_called() @@ -1661,7 +1660,7 @@ def mock_resolve(items): self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 1) self.mock_git.checkout.assert_called_once_with( @@ -1680,38 +1679,41 @@ def mock_resolve(items): class GitCheckoutTest(unittest.TestCase): - @patch("tools.private.release.git.run_cmd") - def test_checkout_simple(self, mock_run_cmd): - git.checkout("my-branch") - mock_run_cmd.assert_called_once_with( - "git", "checkout", "my-branch", capture_output=False + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_checkout_simple(self): + self.git.checkout("my-branch") + self.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False ) - @patch("tools.private.release.git.branch_exists") - @patch("tools.private.release.git.run_cmd") - def test_checkout_track_remote_new_branch(self, mock_run_cmd, mock_branch_exists): + @patch("tools.private.release.git.Git.branch_exists") + def test_checkout_track_remote_new_branch(self, mock_branch_exists): mock_branch_exists.return_value = False - git.checkout("my-branch", track_remote="origin") + self.git.checkout("my-branch", track_remote="origin") mock_branch_exists.assert_called_once_with("my-branch") - mock_run_cmd.assert_called_once_with( - "git", "checkout", "--track", "origin/my-branch", capture_output=False + self.mock_run_git.assert_called_once_with( + "checkout", "--track", "origin/my-branch", capture_output=False ) - @patch("tools.private.release.git.reset_hard") - @patch("tools.private.release.git.branch_exists") - @patch("tools.private.release.git.run_cmd") + @patch("tools.private.release.git.Git.reset_hard") + @patch("tools.private.release.git.Git.branch_exists") def test_checkout_track_remote_existing_branch( - self, mock_run_cmd, mock_branch_exists, mock_reset_hard + self, mock_branch_exists, mock_reset_hard ): mock_branch_exists.return_value = True - git.checkout("my-branch", track_remote="origin") + self.git.checkout("my-branch", track_remote="origin") mock_branch_exists.assert_called_once_with("my-branch") - mock_run_cmd.assert_called_once_with( - "git", "checkout", "my-branch", capture_output=False + self.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False ) mock_reset_hard.assert_called_once_with("origin/my-branch") diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 1ae52cf37a..7ea3d2e535 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -10,12 +10,17 @@ py_library( py_binary( name = "release", srcs = [ + "__init__.py", + "complete_prepare.py", "create_rc.py", "create_release_branch.py", + "create_release_issue.py", + "determine_next_version.py", "gh.py", "git.py", "prepare.py", "process_backports.py", + "promote_rc.py", "release.py", "release_issue.py", "shell.py", diff --git a/tools/private/release/__init__.py b/tools/private/release/__init__.py new file mode 100644 index 0000000000..e68f0391f8 --- /dev/null +++ b/tools/private/release/__init__.py @@ -0,0 +1 @@ +"""Release tools package.""" diff --git a/tools/private/release/complete_prepare.py b/tools/private/release/complete_prepare.py new file mode 100644 index 0000000000..7657738aad --- /dev/null +++ b/tools/private/release/complete_prepare.py @@ -0,0 +1,81 @@ +"""Subcommand to mark preparation task as complete.""" + +import re + +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import update_task_in_body + + +class CompletePrepare: + """Class to mark preparation task as complete.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the complete-prepare subcommand (Phase 2 PR merged).""" + args = self.args + print(f"Completing preparation for PR #{args.pr}...") + + pr_info = self.gh.get_pr_info(args.pr) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") + return 1 + + # Resolve issue number from PR body + pr_body = pr_info.get("body", "") + match = re.search(r"Work towards #(\d+)", pr_body) + if not match: + match = re.search(r"#(\d+)", pr_body) + if not match: + print( + f"Error: Could not determine tracking issue number from PR" + f" #{args.pr} body: {pr_body}" + ) + return 1 + + issue_num = int(match.group(1)) + print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") + + commit_sha = pr_info["mergeCommit"]["oid"] + short_commit = commit_sha[:8] + print( + f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue..." + ) + + # Update checklist: mark Prepare Release as done (checked) and set SUCCESS + body = self.gh.get_issue_body(issue_num) + metadata = { + "status": "done", + "pr": f"#{args.pr}", + "commit": short_commit, + } + updated_body = update_task_in_body( + body, "Prepare Release", checked=True, metadata=metadata + ) + self.gh.update_issue_body(issue_num, updated_body) + print("Prepare Release task marked complete successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for complete-prepare subcommand.""" + parser = subparsers.add_parser( + "complete-prepare", + help="Mark the Prepare Release task as complete in the tracking issue.", + ) + parser.add_argument( + "--pr", + type=int, + required=True, + help="The merged preparation PR number.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index a4593323aa..ea4d8d7450 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -1,6 +1,7 @@ """Subcommand to tag and push the next release candidate.""" -from tools.private.release import gh, git +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_backports, @@ -13,91 +14,104 @@ ) -def cmd_create_rc(args): - """Executes the create-rc subcommand.""" - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) - - if ( - state["prepare_release"]["status"] != "done" - or state["create_branch"]["status"] != "done" - ): - print( - "Error: Preconditions not met (release must be prepared and branch created)." - ) - return 1 - - # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done - backports = parse_backports(body) - conflicting_or_pending = [ - b for b in backports if not b.checked or b.status != "done" - ] - if conflicting_or_pending: - print( - f"Gating RC tagging: {len(conflicting_or_pending)} backports are still" - " unfinished, failed, or in conflict." - ) - return 1 - - # Resolve version and branch - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - # Determine next RC tag - git.fetch(args.remote) - git.fetch(args.remote, tags=True, force=True) - latest_rc = get_latest_rc_tag(version, remote=args.remote) - - if not latest_rc: - next_rc_num = 0 - next_rc = f"{version}-rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_num = rc_num + 1 - next_rc = f"{version}-rc{next_rc_num}" - - # Precheck: next RC number must exist and be unchecked in the checklist - rc_tags = state.get("rc_tags", {}) - if next_rc_num not in rc_tags: - print( - f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" - f" to cut {version}-rc{next_rc_num}." +class CreateRc: + """Class to tag and push the next release candidate.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the create-rc subcommand.""" + args = self.args + body = self.gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"].status != "done" + or state["create_branch"].status != "done" + ): + print( + "Error: Preconditions not met (release must be prepared and" + " branch created)." + ) + return 1 + + # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done + backports = parse_backports(body) + conflicting_or_pending = [ + b for b in backports if not b.checked or b.status != "done" + ] + if conflicting_or_pending: + print( + f"Gating RC tagging: {len(conflicting_or_pending)} backports" + " are still unfinished, failed, or in conflict." + ) + return 1 + + # Resolve version and branch + issue_title = self.gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag + self.git.fetch(args.remote) + self.git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote=args.remote) + + if not latest_rc: + next_rc_num = 0 + next_rc = f"{version}-rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_num = rc_num + 1 + next_rc = f"{version}-rc{next_rc_num}" + + # Precheck: next RC number must exist and be unchecked in the checklist + rc_tags = state.get("rc_tags", {}) + if next_rc_num not in rc_tags: + print( + f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" + f" to cut {version}-rc{next_rc_num}." + ) + return 1 + + target_rc_task = rc_tags[next_rc_num] + if target_rc_task.checked or target_rc_task.status == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in" + " the checklist." + ) + return 1 + + target_ref = f"{args.remote}/{branch_name}" + commit_sha = self.git.get_commit_sha(target_ref) + + print(f"Tagging and pushing next RC: {next_rc}...") + self.git.tag(next_rc, target_ref) + self.git.push(args.remote, next_rc) + + # Check off the appropriate "Tag RC{N}" task in the checklist + print(f"Checking off Tag RC{next_rc_num} task...") + metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} + task_name = f"Tag RC{next_rc_num}" + updated_body = update_task_in_body( + body, task_name, checked=True, metadata=metadata ) - return 1 - - target_rc_task = rc_tags[next_rc_num] - if target_rc_task["checked"] or target_rc_task["status"] == "done": - print( - f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist." - ) - return 1 - - target_ref = f"{args.remote}/{branch_name}" - commit_sha = git.get_commit_sha(target_ref) + self.gh.update_issue_body(args.issue, updated_body) - print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc, target_ref) - git.push(args.remote, next_rc) - - # Check off the appropriate "Tag RC{N}" task in the checklist - print(f"Checking off Tag RC{next_rc_num} task...") - metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} - task_name = f"Tag RC{next_rc_num}" - updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) - gh.update_issue_body(args.issue, updated_body) - - tag_url = f"{REPO_URL}/releases/tag/{next_rc}" - bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" - bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" - release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" - comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 + tag_url = f"{REPO_URL}/releases/tag/{next_rc}" + bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" + release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" + comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. @@ -105,6 +119,34 @@ def cmd_create_rc(args): - BCR Entry: [rules_python@{version}]({bcr_entry_url}) - [BCR PRs]({bcr_search_url}) - [Release workflow status]({release_workflow_url})""" - gh.post_issue_comment(args.issue, comment_body) - print("RC creation completed successfully!") - return 0 + self.gh.post_issue_comment(args.issue, comment_body) + print("RC creation completed successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-rc subcommand.""" + parser = subparsers.add_parser( + "create-rc", + help="Tags the next RC on the release branch if no backports remain.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push the RC tag to (required).", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/create_release_branch.py b/tools/private/release/create_release_branch.py index 7b12465f4c..7a9d7eec37 100644 --- a/tools/private/release/create_release_branch.py +++ b/tools/private/release/create_release_branch.py @@ -1,6 +1,7 @@ """Subcommand to create a release branch from a merged PR commit.""" -from tools.private.release import gh, git +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_checklist_state, @@ -9,76 +10,122 @@ from tools.private.release.utils import REPO_URL -def cmd_create_release_branch(args): - """Executes the create-release-branch subcommand.""" - print(f"Evaluating branch creation for tracking issue #{args.issue}...") - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) +class CreateReleaseBranch: + """Class to create a release branch from a merged PR commit.""" - if ( - state["prepare_release"]["status"] != "done" - or not state["prepare_release"]["commit"] - ): - print( - "Error: Prepare Release task is not marked 'done' with a valid commit SHA." - ) - return 1 + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh - if state["create_branch"]["checked"]: - print("Release branch has already been created and checked. Skipping.") - return 0 + def run(self) -> int: + """Executes the create-release-branch subcommand.""" + args = self.args + print(f"Evaluating branch creation for tracking issue #{args.issue}...") + body = self.gh.get_issue_body(args.issue) + state = parse_checklist_state(body) - # Extract version from issue title - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 + if ( + state["prepare_release"].status != "done" + or not state["prepare_release"].commit + ): + print( + "Error: Prepare Release task is not marked 'done' with a valid" + " commit SHA." + ) + return 1 - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" + if state["create_branch"].checked: + print("Release branch has already been created and checked. Skipping.") + return 0 - commit_sha = state["prepare_release"]["commit"] - print(f"Cutting branch {branch_name} from commit {commit_sha}...") + # Extract version from issue title + issue_title = self.gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 - # Create and push branch without affecting local checkout - git.fetch(args.remote) + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" - if git.remote_branch_exists(args.remote, branch_name): - remote_ref = f"{args.remote}/{branch_name}" - remote_sha = git.get_commit_sha(remote_ref) - if remote_sha == commit_sha: - print( - f"Branch {branch_name} already exists on {args.remote} and points to {commit_sha}. Skipping push." - ) - elif git.is_ancestor(remote_ref, commit_sha): - print( - f"Branch {branch_name} exists on {args.remote} but can be fast-forwarded to {commit_sha}. Pushing..." - ) - ref_spec = f"{commit_sha}:refs/heads/{branch_name}" - git.push(args.remote, ref_spec) + commit_sha = state["prepare_release"].commit + print(f"Cutting branch {branch_name} from commit {commit_sha}...") + + # Create and push branch without affecting local checkout + self.git.fetch(args.remote) + + if self.git.remote_branch_exists(args.remote, branch_name): + remote_ref = f"{args.remote}/{branch_name}" + remote_sha = self.git.get_commit_sha(remote_ref) + if remote_sha == commit_sha: + print( + f"Branch {branch_name} already exists on {args.remote} and" + f" points to {commit_sha}. Skipping push." + ) + elif self.git.is_ancestor(remote_ref, commit_sha): + print( + f"Branch {branch_name} exists on {args.remote} but can be" + f" fast-forwarded to {commit_sha}. Pushing..." + ) + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + self.git.push(args.remote, ref_spec) + else: + print( + f"Error: Branch {branch_name} already exists on" + f" {args.remote} at {remote_sha[:8]}, which is not an" + f" ancestor of {commit_sha[:8]}. Cannot fast-forward." + ) + return 1 else: + print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...") + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + self.git.push(args.remote, ref_spec) print( - f"Error: Branch {branch_name} already exists on {args.remote} at {remote_sha[:8]}, " - f"which is not an ancestor of {commit_sha[:8]}. Cannot fast-forward." + f"Successfully pushed branch {branch_name} pointing to" + f" {commit_sha} to {args.remote}" ) - return 1 - else: - print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...") - ref_spec = f"{commit_sha}:refs/heads/{branch_name}" - git.push(args.remote, ref_spec) - print( - f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}" + + # Update tracking issue checklist + print("Updating tracking issue checklist...") + branch_url = f"{REPO_URL}/tree/{branch_name}" + metadata = { + "status": "done", + "branch_url": branch_url, + "commit": commit_sha[:8], + } + updated_body = update_task_in_body( + body, "Create Release branch", checked=True, metadata=metadata + ) + self.gh.update_issue_body(args.issue, updated_body) + print("Create Release branch task marked complete successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-release-branch subcommand.""" + parser = subparsers.add_parser( + "create-release-branch", + help="Create the release branch pointing to the merged PR commit.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to create the branch on (required).", ) + parser.set_defaults(command=cls.run_from_args) - # Update tracking issue checklist - print("Updating tracking issue checklist...") - branch_url = f"{REPO_URL}/tree/{branch_name}" - metadata = {"status": "done", "branch_url": branch_url, "commit": commit_sha[:8]} - updated_body = update_task_in_body( - body, "Create Release branch", checked=True, metadata=metadata - ) - gh.update_issue_body(args.issue, updated_body) - print("Create Release branch task marked complete successfully!") - return 0 + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py new file mode 100644 index 0000000000..2d00d7369a --- /dev/null +++ b/tools/private/release/create_release_issue.py @@ -0,0 +1,59 @@ +"""Subcommand to create a release tracking issue.""" + +import pathlib + +from tools.private.release.gh import GitHub +from tools.private.release.utils import determine_next_version, semver_type + + +class CreateReleaseIssue: + """Class to create a release tracking issue.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the create-release-issue subcommand.""" + version = self.args.version + if version is None: + version = determine_next_version() + + # Concurrency check + open_issues = self.gh.get_open_tracking_issues() + if open_issues: + print("Error: A release is already in progress. Active tracking issues:") + for issue in open_issues: + print(f"- {issue['title']}: {issue['url']}") + return 1 + + template_path = pathlib.Path( + ".github/ISSUE_TEMPLATE/release_tracking_template.md" + ) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + template_content = template_path.read_text(encoding="utf-8") + + issue_num = self.gh.create_tracking_issue(version, template_content) + print(f"Created tracking issue #{issue_num} for v{version}") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-release-issue subcommand.""" + parser = subparsers.add_parser( + "create-release-issue", + help="Search for open releases and create a new tracking issue.", + ) + parser.add_argument( + "--version", + type=semver_type, + help="The release version (e.g., 0.38.0). If not provided, determined automatically.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/determine_next_version.py b/tools/private/release/determine_next_version.py new file mode 100644 index 0000000000..541daddfe7 --- /dev/null +++ b/tools/private/release/determine_next_version.py @@ -0,0 +1,30 @@ +"""Subcommand to determine the next version.""" + +from tools.private.release.utils import determine_next_version + + +class DetermineNextVersion: + """Class to determine the next version.""" + + def __init__(self, args, git=None, gh=None): + self.args = args + + def run(self) -> int: + """Executes the determine-next-version subcommand.""" + version = determine_next_version() + print(version) + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for determine-next-version subcommand.""" + parser = subparsers.add_parser( + "determine-next-version", + help="Determine the next version and print it, without making any changes.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + return cls(args).run() diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index cec20ca2d6..8b938aecf6 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -7,9 +7,6 @@ from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd -_REPO = "bazel-contrib/rules_python" -_LABEL = "type: release" - class MultipleTrackingIssuesError(ValueError): """Raised when multiple open tracking issues are found for a version.""" @@ -23,224 +20,352 @@ class NoTrackingIssueError(ValueError): pass -def list_issues(*, fields, label=None, state=None, search=None): - """Helper to list issues using gh CLI.""" - cmd = ["gh", "issue", "list", f"--repo={_REPO}"] - if label: - cmd.append(f"--label={label}") - if state: - cmd.append(f"--state={state}") - if search: - cmd.append(f"--search={search}") - cmd.append(f"--json={fields}") - - output = run_cmd(*cmd) - return json.loads(output) if output else [] - - -def get_open_tracking_issues(version=None): - """Returns a list of open tracking issues with the 'type: release' label.""" - search = f'"Release {version}" in:title' if version else None - return list_issues( - label=_LABEL, - state="open", - search=search, - fields="number,title,url", - ) - - -def get_release_tracking_issue(version): - """Resolves the tracking issue number for a given version. - - Searches for an open issue with label 'type: release' and 'Release ' in the title. - Raises ValueError if 0 or multiple issues are found. - """ - matching_issues = get_open_tracking_issues(version) - - exact_matches = [] - for issue in matching_issues: - if issue["title"] == f"Release {version}": - exact_matches.append(issue) - - if not exact_matches: - raise NoTrackingIssueError( - f"No open tracking issue found matching 'Release {version}' " - f"in repo {_REPO} with label '{_LABEL}'" +class GitHub: + """GitHub CLI helper class for the release tool.""" + + def __init__(self, repo: str = "bazel-contrib/rules_python"): + """Initializes the GitHub helper. + + Args: + repo: The GitHub repository to operate on. + """ + self.repo = repo + self.label = "type: release" + + def _run_gh( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh' command. + + Args: + *args: Arguments for 'gh' (excluding 'gh'). + check: If True, raises CalledProcessError on failure. + capture_output: If True, captures and returns stdout. + + Returns: + The stdout of the command, stripped, or None. + """ + return run_cmd("gh", *args, check=check, capture_output=capture_output) + + def _gh_issue( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh issue' command.""" + return self._run_gh( + "issue", + *args, + f"--repo={self.repo}", + check=check, + capture_output=capture_output, + ) + + def _gh_pr( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh pr' command.""" + return self._run_gh( + "pr", + *args, + f"--repo={self.repo}", + check=check, + capture_output=capture_output, ) - if len(exact_matches) > 1: - urls = [issue["url"] for issue in exact_matches] - raise MultipleTrackingIssuesError( - f"Multiple open tracking issues found for version {version} " - f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls) + + def list_issues( + self, + *, + fields: str, + label: str | None = None, + state: str | None = None, + search: str | None = None, + ) -> list[dict]: + """Helper to list issues using gh CLI. + + Args: + fields: Comma-separated list of fields to return. + label: Filter by label. + state: Filter by state (open, closed, all). + search: Search query. + + Returns: + A list of dictionaries representing the issues. + """ + cmd = ["list"] + if label: + cmd.append(f"--label={label}") + if state: + cmd.append(f"--state={state}") + if search: + cmd.append(f"--search={search}") + cmd.append(f"--json={fields}") + + output = self._gh_issue(*cmd) + return json.loads(output) if output else [] + + def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: + """Returns a list of open tracking issues with the 'type: release' label. + + Args: + version: Optional version to filter by. + + Returns: + A list of open tracking issues. + """ + search = f'"Release {version}" in:title' if version else None + return self.list_issues( + label=self.label, + state="open", + search=search, + fields="number,title,url", ) - return exact_matches[0]["number"] + def get_release_tracking_issue(self, version: str) -> int: + """Resolves the tracking issue number for a given version. + + Searches for an open issue with label 'type: release' and 'Release + ' in the title. + + Args: + version: The version to find the tracking issue for. + + Returns: + The tracking issue number. + + Raises: + NoTrackingIssueError: If no open tracking issue is found. + MultipleTrackingIssuesError: If multiple open tracking issues are + found. + """ + matching_issues = self.get_open_tracking_issues(version) + + exact_matches = [] + for issue in matching_issues: + if issue["title"] == f"Release {version}": + exact_matches.append(issue) + + if not exact_matches: + raise NoTrackingIssueError( + f"No open tracking issue found matching 'Release {version}' " + f"in repo {self.repo} with label '{self.label}'" + ) + if len(exact_matches) > 1: + urls = [issue["url"] for issue in exact_matches] + raise MultipleTrackingIssuesError( + f"Multiple open tracking issues found for version {version} " + f"in repo {self.repo} with label '{self.label}':\n" + "\n".join(urls) + ) + + return exact_matches[0]["number"] + + def create_tracking_issue(self, version: str, template_content: str) -> int: + """Creates a new release tracking issue from template content. + + Strips YAML frontmatter if present. + + Args: + version: The version to create the tracking issue for. + template_content: The markdown template content for the issue body. + + Returns: + The created issue number. + """ + # Strip YAML frontmatter if present + issue_body = template_content + if template_content.startswith("---"): + parts = template_content.split("---", 2) + if len(parts) >= 3: + issue_body = parts[2].strip() + + # Write body to a secure temporary file to pass to the CLI + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(issue_body) + temp_path = f.name + try: + output = self._gh_issue( + "create", + f"--title=Release {version}", + f"--label={self.label}", + f"--body-file={temp_path}", + ) + if not output: + raise RuntimeError("Failed to get issue URL from gh issue create") + issue_url = output.strip() + issue_num = int(issue_url.split("/")[-1]) + return issue_num + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + def get_issue_body(self, issue_num: int) -> str: + """Fetches the body of a specific issue. + + Args: + issue_num: The issue number. + + Returns: + The issue body markdown. + """ + output = self._gh_issue( + "view", + str(issue_num), + "--json=body", + "--jq=.body", + ) + return output if output else "" -def create_tracking_issue(version, template_content): - """Creates a new release tracking issue from template content (strips YAML frontmatter).""" - # Strip YAML frontmatter if present - issue_body = template_content - if template_content.startswith("---"): - parts = template_content.split("---", 2) - if len(parts) >= 3: - issue_body = parts[2].strip() + def get_issue_title(self, issue_num: int) -> str: + """Fetches the title of a specific issue. - # Write body to a secure temporary file to pass to the CLI - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: - f.write(issue_body) - temp_path = f.name + Args: + issue_num: The issue number. - try: - output = run_cmd( - "gh", - "issue", + Returns: + The issue title. + """ + output = self._gh_issue( + "view", + str(issue_num), + "--json=title", + ) + return json.loads(output)["title"] if output else "" + + def update_issue_body(self, issue_num: int, body: str) -> None: + """Updates the body of a specific issue. + + Args: + issue_num: The issue number. + body: The new issue body markdown. + """ + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(body) + temp_path = f.name + try: + self._gh_issue( + "edit", + str(issue_num), + f"--body-file={temp_path}", + capture_output=False, + ) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + def create_pr(self, version: str, issue_num: int) -> str: + """Creates a pull request for release preparation. + + Args: + version: The version being prepared. + issue_num: The associated tracking issue number. + + Returns: + The URL of the created PR. + """ + output = self._gh_pr( "create", - f"--title=Release {version}", - f"--label={_LABEL}", - f"--body-file={temp_path}", + f"--title=Prepare release v{version}", + f"--body=Work towards #{issue_num}", + "--base=main", ) - issue_url = output.strip() - issue_num = int(issue_url.split("/")[-1]) - return issue_num - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - - -def get_issue_body(issue_num): - """Fetches the body of a specific issue.""" - return run_cmd( - "gh", - "issue", - "view", - str(issue_num), - "--json=body", - "--jq=.body", - ) - - -def get_issue_title(issue_num): - """Fetches the title of a specific issue.""" - output = run_cmd( - "gh", - "issue", - "view", - str(issue_num), - "--json=title", - ) - return json.loads(output)["title"] if output else "" - - -def update_issue_body(issue_num, body): - """Updates the body of a specific issue.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: - f.write(body) - temp_path = f.name - try: - run_cmd( - "gh", - "issue", - "edit", + return output if output else "" + + def get_open_pr(self, branch_name: str) -> dict | None: + """Returns PR info if an open PR exists for the given branch. + + Args: + branch_name: The head branch name of the PR. + + Returns: + A dictionary with 'number' and 'url' of the PR, or None. + """ + output = self._gh_pr( + "list", + f"--head={branch_name}", + "--state=open", + "--json=number,url", + ) + prs = json.loads(output) if output else [] + return prs[0] if prs else None + + def get_pr_info(self, pr_num: int) -> dict: + """Gets information about a PR. + + Includes state, merge commit, body, and draft status. + + Args: + pr_num: The PR number. + + Returns: + A dictionary containing the PR info. + """ + output = self._gh_pr( + "view", + str(pr_num), + "--json=state,mergeCommit,body,isDraft", + ) + return json.loads(output) if output else {} + + def post_issue_comment(self, issue_num: int, comment_body: str) -> None: + """Posts a comment to a specific issue. + + Args: + issue_num: The issue number. + comment_body: The comment body markdown. + """ + self._gh_issue( + "comment", str(issue_num), - f"--body-file={temp_path}", + f"--body={comment_body}", capture_output=False, ) - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - - -def create_pr(version, issue_num): - """Creates a pull request for release preparation.""" - return run_cmd( - "gh", - "pr", - "create", - f"--title=Prepare release v{version}", - f"--body=Work towards #{issue_num}", - "--base=main", - ) - - -def get_open_pr(branch_name): - """Returns PR info if an open PR exists for the given branch, else None.""" - cmd = [ - "gh", - "pr", - "list", - f"--repo={_REPO}", - f"--head={branch_name}", - "--state=open", - "--json=number,url", - ] - output = run_cmd(*cmd) - prs = json.loads(output) if output else [] - return prs[0] if prs else None - - -def get_pr_info(pr_num): - """Gets information about a PR, including state, merge commit, body, and draft status.""" - output = run_cmd( - "gh", - "pr", - "view", - str(pr_num), - "--json=state,mergeCommit,body,isDraft", - ) - return json.loads(output) if output else {} - - -def post_issue_comment(issue_num, comment_body): - """Posts a comment to a specific issue.""" - run_cmd( - "gh", - "issue", - "comment", - str(issue_num), - f"--body={comment_body}", - capture_output=False, - ) - - -def get_merge_commits_for_prs(pending_items: list[BackportTask]) -> list[BackportTask]: - """Resolves PR references in pending backports to their merge commit SHAs. - - Updates item.status based on PR state if it cannot be resolved. - """ - resolved_items = [] - for item in pending_items: - pr_num = item.pr_ref.lstrip("#") - print(f"Resolving PR #{pr_num} to merge commit...") - try: - pr_info = get_pr_info(pr_num) - if not pr_info: - print(f"PR #{pr_num} not found. Gating.") - item.status = "error-not-found" - else: - state = pr_info.get("state") - is_draft = pr_info.get("isDraft", False) - if state == "OPEN" or is_draft: - print( - f"PR #{pr_num} is open or draft (state: {state}, draft: {is_draft}). Ignoring." - ) - item.status = "open-pr" if not is_draft else "draft-pr" - elif state == "CLOSED": - print(f"PR #{pr_num} is closed but not merged. Gating.") - item.status = "error-closed-pr" - elif state == "MERGED": - merge_commit = pr_info.get("mergeCommit") - if merge_commit and "oid" in merge_commit: - item.commit = merge_commit["oid"] - item.status = "resolved" - else: - print(f"PR #{pr_num} has no merge commit SHA. Gating.") - item.status = "error-no-merge-commit" + + def get_merge_commits_for_prs( + self, pending_items: list[BackportTask] + ) -> list[BackportTask]: + """Resolves PR references in pending backports to their merge commit SHAs. + + Updates item.status based on PR state if it cannot be resolved. + + Args: + pending_items: A list of BackportTask items to resolve. + + Returns: + The list of resolved BackportTask items. + """ + resolved_items = [] + for item in pending_items: + pr_num = int(item.pr_ref.lstrip("#")) + print(f"Resolving PR #{pr_num} to merge commit...") + try: + pr_info = self.get_pr_info(pr_num) + if not pr_info: + print(f"PR #{pr_num} not found. Gating.") + item.status = "error-not-found" else: - print(f"PR #{pr_num} has unknown state: {state}. Gating.") - item.status = "error-unknown" - except Exception as e: - print(f"Error resolving PR #{pr_num}: {e}. Gating.") - item.status = "error-resolution-failed" - resolved_items.append(item) - return resolved_items + state = pr_info.get("state") + is_draft = pr_info.get("isDraft", False) + if state == "OPEN" or is_draft: + print( + f"PR #{pr_num} is open or draft (state: {state}," + f" draft: {is_draft}). Ignoring." + ) + item.status = "open-pr" if not is_draft else "draft-pr" + elif state == "CLOSED": + print(f"PR #{pr_num} is closed but not merged. Gating.") + item.status = "error-closed-pr" + elif state == "MERGED": + merge_commit = pr_info.get("mergeCommit") + if merge_commit and "oid" in merge_commit: + item.commit = merge_commit["oid"] + item.status = "resolved" + else: + print(f"PR #{pr_num} has no merge commit SHA. Gating.") + item.status = "error-no-merge-commit" + else: + print(f"PR #{pr_num} has unknown state: {state}. Gating.") + item.status = "error-unknown" + except Exception as e: + print(f"Error resolving PR #{pr_num}: {e}. Gating.") + item.status = "error-resolution-failed" + resolved_items.append(item) + return resolved_items diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 795710fd8c..69edba3565 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -5,213 +5,344 @@ from tools.private.release.shell import run_cmd -def get_tags(): - """Returns a list of all git tags in the repository.""" - output = run_cmd("git", "tag") - return output.splitlines() if output else [] - - -def checkout( - ref: str, create_branch: bool = False, track_remote: str | None = None -) -> None: - """Checks out a git reference (tag, branch, or commit). - - Args: - ref: The git reference (tag, branch, or commit) to checkout. - create_branch: If True, creates the branch before checking it out. - track_remote: If specified, checks out the branch tracking this remote's - corresponding branch. +class Git: + """Git helper class for the release tool. + + Operates on a specific git repository path. """ - cmd = ["git", "checkout"] - if create_branch: - cmd.append("-b") - should_reset_hard = False - if track_remote: - if branch_exists(ref): - cmd.append(ref) - should_reset_hard = True + def __init__(self, repo: str): + """Initializes the Git helper. + + Args: + repo: The path to the git repository. + """ + self._repo = repo + + def _run_git( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a git command in the repository directory. + + Args: + *args: Arguments passed to the git command. + check: If True, raises CalledProcessError on failure. + capture_output: If True, captures and returns stdout. + + Returns: + The stdout of the command, stripped, or None if capture_output is + False. + """ + return run_cmd( + "git", + *args, + check=check, + capture_output=capture_output, + cwd=self._repo, + ) + + def get_tags(self) -> list[str]: + """Returns a list of all git tags in the repository. + + Returns: + A list of tag names (strings). + """ + output = self._run_git("tag") + return output.splitlines() if output else [] + + def checkout( + self, + ref: str, + create_branch: bool = False, + track_remote: str | None = None, + ) -> None: + """Checks out a git reference (tag, branch, or commit). + + Args: + ref: The git reference (tag, branch, or commit) to checkout. + create_branch: If True, creates the branch before checking it out. + track_remote: If specified, checks out the branch tracking this + remote's corresponding branch. + """ + cmd = ["checkout"] + if create_branch: + cmd.append("-b") + + should_reset_hard = False + if track_remote: + if self.branch_exists(ref): + cmd.append(ref) + should_reset_hard = True + else: + cmd.extend(["--track", f"{track_remote}/{ref}"]) else: - cmd.extend(["--track", f"{track_remote}/{ref}"]) - else: + cmd.append(ref) + self._run_git(*cmd, capture_output=False) + + if should_reset_hard: + self.reset_hard(f"{track_remote}/{ref}") + + def add(self, *files: str) -> None: + """Stages files for commit. + + Args: + *files: Paths to files to stage. + """ + self._run_git("add", *files, capture_output=False) + + def add_modified_and_deleted(self) -> None: + """Stages all modified and deleted tracked files.""" + self._run_git("add", "--update", capture_output=False) + + def commit(self, message: str, amend: bool = False, no_edit: bool = False) -> None: + """Commits staged changes, optionally amending the previous commit. + + Args: + message: The commit message. + amend: If True, amends the previous commit. + no_edit: If True, uses the existing commit message without editing. + """ + cmd = ["commit"] + if amend: + cmd.append("--amend") + if no_edit: + cmd.append("--no-edit") + if message: + cmd.extend(["-m", message]) + self._run_git(*cmd, capture_output=False) + + def push( + self, + remote: str, + ref: str, + set_upstream: bool = False, + force: bool = False, + ) -> None: + """Pushes a reference to a remote repository. + + Args: + remote: The remote repository name (e.g., 'origin'). + ref: The reference to push (e.g., a branch name). + set_upstream: If True, sets the upstream tracking branch. + force: If True, force pushes the changes. + """ + cmd = ["push"] + if set_upstream: + cmd.append("--set-upstream") + if force: + cmd.append("--force") + cmd.extend([remote, ref]) + self._run_git(*cmd, capture_output=False) + + def fetch( + self, remote: str = "origin", tags: bool = False, force: bool = False + ) -> None: + """Fetches updates from a remote repository. + + Args: + remote: The remote repository name. Defaults to 'origin'. + tags: If True, fetches all tags. + force: If True, force fetches updates. + """ + cmd = ["fetch", remote] + if tags: + cmd.append("--tags") + if force: + cmd.append("--force") + self._run_git(*cmd, capture_output=False) + + def merge(self, commit_ref: str, ff_only: bool = True) -> None: + """Merges a commit into the current branch. + + Args: + commit_ref: The commit reference to merge. + ff_only: If True, only allows fast-forward merges. + """ + cmd = ["merge", commit_ref] + if ff_only: + cmd.append("--ff-only") + self._run_git(*cmd, capture_output=False) + + def tag(self, tag_name: str, commit_ref: str) -> None: + """Creates a local tag pointing to a specific commit. + + Args: + tag_name: The name of the tag to create. + commit_ref: The commit reference the tag should point to. + """ + self._run_git("tag", tag_name, commit_ref, capture_output=False) + + def cherry_pick(self, sha: str) -> None: + """Cherry-picks a commit. + + Args: + sha: The commit SHA to cherry-pick. + """ + self._run_git("cherry-pick", "-x", sha, capture_output=False) + + def cherry_pick_abort(self) -> None: + """Aborts an in-progress cherry-pick operation.""" + self._run_git("cherry-pick", "--abort", capture_output=False) + + def reset_hard(self, ref: str = "HEAD") -> None: + """Resets the index and working tree to a specific reference. + + Args: + ref: The git reference to reset to. Defaults to 'HEAD'. + """ + self._run_git("reset", "--hard", ref, capture_output=False) + + def status(self) -> str: + """Returns the output of git status --porcelain. + + Returns: + The porcelain status output. + """ + output = self._run_git("status", "--porcelain") + return output if output else "" + + def get_commit_sha(self, ref: str = "HEAD", short: bool = False) -> str: + """Returns the commit SHA of a given reference. + + Args: + ref: The git reference. Defaults to 'HEAD'. + short: If True, returns a short SHA. + + Returns: + The commit SHA. + """ + cmd = ["rev-parse"] + if short: + cmd.append("--short") cmd.append(ref) - run_cmd(*cmd, capture_output=False) - - if should_reset_hard: - reset_hard(f"{track_remote}/{ref}") - - -def add(*files): - """Stages files for commit.""" - run_cmd("git", "add", *files, capture_output=False) - - -def add_modified_and_deleted(): - """Stages all modified and deleted tracked files.""" - run_cmd("git", "add", "--update", capture_output=False) - - -def commit(message, amend=False, no_edit=False): - """Commits staged changes, optionally amending the previous commit.""" - cmd = ["git", "commit"] - if amend: - cmd.append("--amend") - if no_edit: - cmd.append("--no-edit") - if message: - cmd.extend(["-m", message]) - run_cmd(*cmd, capture_output=False) - - -def push(remote, ref, set_upstream=False, force=False): - """Pushes a reference to a remote repository.""" - cmd = ["git", "push"] - if set_upstream: - cmd.append("--set-upstream") - if force: - cmd.append("--force") - cmd.extend([remote, ref]) - run_cmd(*cmd, capture_output=False) - - -def fetch(remote="origin", tags=False, force=False): - """Fetches updates from a remote repository.""" - cmd = ["git", "fetch", remote] - if tags: - cmd.append("--tags") - if force: - cmd.append("--force") - run_cmd(*cmd, capture_output=False) - - -def merge(commit_ref, ff_only=True): - """Merges a commit into the current branch.""" - cmd = ["git", "merge", commit_ref] - if ff_only: - cmd.append("--ff-only") - run_cmd(*cmd, capture_output=False) - - -def tag(tag_name, commit_ref): - """Creates a local tag pointing to a specific commit.""" - run_cmd("git", "tag", tag_name, commit_ref, capture_output=False) - - -def cherry_pick(sha: str) -> None: - """Cherry-picks a commit. - - Args: - sha: The commit SHA to cherry-pick. - """ - run_cmd("git", "cherry-pick", "-x", sha, capture_output=False) - - -def cherry_pick_abort(): - """Aborts an in-progress cherry-pick operation.""" - run_cmd("git", "cherry-pick", "--abort", capture_output=False) - - -def reset_hard(ref: str = "HEAD") -> None: - """Resets the index and working tree to a specific reference. - - Args: - ref: The git reference to reset to. Defaults to 'HEAD'. - """ - run_cmd("git", "reset", "--hard", ref, capture_output=False) - - -def status(): - """Returns the output of git status --porcelain.""" - return run_cmd("git", "status", "--porcelain") - - -def get_commit_sha(ref="HEAD", short=False): - """Returns the commit SHA of a given reference.""" - cmd = ["git", "rev-parse"] - if short: - cmd.append("--short") - cmd.append(ref) - return run_cmd(*cmd) - - -def get_commit_message(ref: str = "HEAD") -> str: - """Returns the commit message of a given reference. - - Args: - ref: The git reference to get the message from. Defaults to 'HEAD'. - """ - return run_cmd("git", "log", "-1", "--format=%B", ref) - - -def branch_exists(branch_name): - """Returns True if a local branch exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/heads/{branch_name}") - return True - except subprocess.CalledProcessError: - return False - - -def tag_exists(tag_name): - """Returns True if a local tag exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/tags/{tag_name}") - return True - except subprocess.CalledProcessError: - return False - - -def sort_commits_chronologically(shas): - """Sorts a list of commit SHAs chronologically (oldest first).""" - output = run_cmd("git", "log", "--no-walk", "--reverse", "--format=%H", *shas) - return output.splitlines() if output else [] - - -def get_current_branch(): - """Returns the current git branch name.""" - return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") - - -def remote_branch_exists(remote, branch_name): - """Returns True if a remote branch exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/remotes/{remote}/{branch_name}") - return True - except subprocess.CalledProcessError: - return False - - -def is_ancestor(ancestor, descendant): - """Returns True if ancestor is an ancestor of descendant (fast-forwardable).""" - try: - run_cmd("git", "merge-base", "--is-ancestor", ancestor, descendant) - return True - except subprocess.CalledProcessError: - return False - - -def get_remote_tags(remote: str) -> list[str]: - """Returns a list of tags present on the specified remote repository. - - Args: - remote: The name of the git remote to query (e.g., 'origin', 'upstream'). - - Returns: - A list of tag names (strings) found on the remote, excluding peeled tags. - """ - output = run_cmd("git", "ls-remote", "--tags", remote) - tags = [] - for line in output.splitlines(): - if not line: - continue - parts = line.split() - if len(parts) < 2: - continue - ref = parts[1] - if ref.startswith("refs/tags/"): - tag = ref[len("refs/tags/") :] - # Skip peeled tags (e.g. tag^{}) to avoid - # duplicate tag names in the output. - if not tag.endswith("^{}"): - tags.append(tag) - return tags + output = self._run_git(*cmd) + return output if output else "" + + def get_commit_message(self, ref: str = "HEAD") -> str: + """Returns the commit message of a given reference. + + Args: + ref: The git reference. Defaults to 'HEAD'. + + Returns: + The commit message. + """ + output = self._run_git("log", "-1", "--format=%B", ref) + return output if output else "" + + def branch_exists(self, branch_name: str) -> bool: + """Returns True if a local branch exists. + + Args: + branch_name: The name of the branch to check. + + Returns: + True if the branch exists, False otherwise. + """ + try: + self._run_git("show-ref", "--verify", f"refs/heads/{branch_name}") + return True + except subprocess.CalledProcessError: + return False + + def tag_exists(self, tag_name: str) -> bool: + """Returns True if a local tag exists. + + Args: + tag_name: The name of the tag to check. + + Returns: + True if the tag exists, False otherwise. + """ + try: + self._run_git("show-ref", "--verify", f"refs/tags/{tag_name}") + return True + except subprocess.CalledProcessError: + return False + + def sort_commits_chronologically(self, shas: list[str]) -> list[str]: + """Sorts a list of commit SHAs chronologically (oldest first). + + Args: + shas: A list of commit SHAs to sort. + + Returns: + The sorted list of commit SHAs. + """ + output = self._run_git("log", "--no-walk", "--reverse", "--format=%H", *shas) + return output.splitlines() if output else [] + + def get_current_branch(self) -> str: + """Returns the current git branch name. + + Returns: + The current branch name. + """ + output = self._run_git("rev-parse", "--abbrev-ref", "HEAD") + return output if output else "" + + def remote_branch_exists(self, remote: str, branch_name: str) -> bool: + """Returns True if a remote branch exists. + + Args: + remote: The name of the remote. + branch_name: The name of the branch. + + Returns: + True if the remote branch exists, False otherwise. + """ + try: + self._run_git( + "show-ref", + "--verify", + f"refs/remotes/{remote}/{branch_name}", + ) + return True + except subprocess.CalledProcessError: + return False + + def is_ancestor(self, ancestor: str, descendant: str) -> bool: + """Returns True if ancestor is an ancestor of descendant. + + Args: + ancestor: The commit reference that might be an ancestor. + descendant: The commit reference that might be a descendant. + + Returns: + True if ancestor is an ancestor of descendant, False otherwise. + """ + try: + self._run_git("merge-base", "--is-ancestor", ancestor, descendant) + return True + except subprocess.CalledProcessError: + return False + + def get_remote_tags(self, remote: str) -> list[str]: + """Returns a list of tags present on the specified remote repository. + + Args: + remote: The name of the git remote to query (e.g., 'origin', + 'upstream'). + + Returns: + A list of tag names (strings) found on the remote, excluding peeled + tags. + """ + output = self._run_git("ls-remote", "--tags", remote) + tags = [] + if not output: + return tags + for line in output.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 2: + continue + ref = parts[1] + if ref.startswith("refs/tags/"): + tag = ref[len("refs/tags/") :] + # Skip peeled tags (e.g. tag^{}) to avoid + # duplicate tag names in the output. + if not tag.endswith("^{}"): + tags.append(tag) + return tags diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 2d32ee4de0..610f2369e7 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -1,7 +1,16 @@ +"""Subcommand to prepare the release (updates changelog, placeholders).""" + +import argparse import datetime import pathlib -from tools.private.release import changelog_news, gh, git +from tools.private.release import changelog_news +from tools.private.release.gh import ( + GitHub, + MultipleTrackingIssuesError, + NoTrackingIssueError, +) +from tools.private.release.git import Git from tools.private.release.release_issue import ( parse_checklist_state, update_task_in_body, @@ -9,161 +18,215 @@ from tools.private.release.utils import ( determine_next_version, replace_version_next, + semver_type, ) -def cmd_prepare(args): - """Executes the prepare subcommand.""" - print("Fetching upstream to verify fresh release history...") - git.fetch(tags=True, force=True) +class Prepare: + """Class to prepare the release.""" - # Run pre-check: verify there are no local edits - status = git.status() - if status: - print( - "Error: Local edits detected. Workspace must be completely clean" - " before running release preparation." - ) - for line in status.splitlines(): - print(f" {line}") - return 1 - print("Pre-check passed: Workspace is clean.") + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh - version = args.version - if version is None: - version = determine_next_version() + def run(self) -> int: + """Executes the prepare subcommand.""" + args = self.args + print("Fetching upstream to verify fresh release history...") + self.git.fetch(tags=True, force=True) - print(f"Running preparation pipeline for {version}...") + # Run pre-check: verify there are no local edits + status = self.git.status() + if status: + print( + "Error: Local edits detected. Workspace must be completely clean" + " before running release preparation." + ) + for line in status.splitlines(): + print(f" {line}") + return 1 + print("Pre-check passed: Workspace is clean.") - # 1. Find or create tracking issue (EARLY) - # We do this before any write operations (branch creation, commit, push) - issue_num = args.issue + version = args.version + if version is None: + version = determine_next_version() - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(version) + print(f"Running preparation pipeline for {version}...") + + # 1. Find or create tracking issue (EARLY) + # We do this before any write operations (branch creation, commit, push) + issue_num = args.issue + + if not issue_num: + try: + issue_num = self.gh.get_release_tracking_issue(version) + print(f"Tracking issue: #{issue_num}") + except MultipleTrackingIssuesError as e: + print(f"Error: {e}") + return 1 + except NoTrackingIssueError: + # Not found, we need the template + template_path = pathlib.Path( + ".github/ISSUE_TEMPLATE/release_tracking_template.md" + ) + if not template_path.exists(): + raise FileNotFoundError( + f"Template file not found at {template_path}" + ) + template_content = template_path.read_text(encoding="utf-8") + + if args.dry_run: + print( + f"[DRY RUN] No active tracking issue found for" + f" {version}. Would create a new one." + ) + print(f"[DRY RUN] Title: Release {version}\n{template_content}") + issue_num = None # Keep it None for dry-run prints later + else: + print( + f"No active tracking issue found for {version}." + " Creating a new one..." + ) + issue_num = self.gh.create_tracking_issue(version, template_content) + print(f"Tracking issue: #{issue_num}") + else: print(f"Tracking issue: #{issue_num}") - except gh.MultipleTrackingIssuesError as e: - print(f"Error: {e}") - return 1 - except gh.NoTrackingIssueError: - # Not found, we need the template - template_path = pathlib.Path( - ".github/ISSUE_TEMPLATE/release_tracking_template.md" - ) - if not template_path.exists(): - raise FileNotFoundError(f"Template file not found at {template_path}") - template_content = template_path.read_text(encoding="utf-8") + branch_name = f"prepare-{version}" + + # 2. Interleaved git and write operations + + # --- Branch selection/creation --- + if self.git.branch_exists(branch_name): if args.dry_run: print( - f"[DRY RUN] No active tracking issue found for {version}. Would create a new one." + f"[DRY RUN] Branch {branch_name} already exists. Would" + " checkout existing branch." ) - print(f"[DRY RUN] Title: Release {version}\n{template_content}") - issue_num = None # Keep it None for dry-run prints later else: - print( - f"No active tracking issue found for {version}. Creating a new one..." - ) - issue_num = gh.create_tracking_issue(version, template_content) - print(f"Tracking issue: #{issue_num}") - else: - print(f"Tracking issue: #{issue_num}") - - branch_name = f"prepare-{version}" - - # 2. Interleaved git and write operations + print(f"Branch {branch_name} already exists. Checking it out...") + self.git.checkout(branch_name) + else: + if args.dry_run: + print(f"[DRY RUN] Would create and checkout branch {branch_name}") + else: + self.git.checkout(branch_name, create_branch=True) - # --- Branch selection/creation --- - if git.branch_exists(branch_name): + # --- Update files --- if args.dry_run: print( - f"[DRY RUN] Branch {branch_name} already exists. Would checkout existing branch." + f"[DRY RUN] Would update CHANGELOG.md and version placeholders" + f" for {version}" ) else: - print(f"Branch {branch_name} already exists. Checking it out...") - git.checkout(branch_name) - else: + print("Updating changelog and placeholders...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + replace_version_next(version) + + # --- Commit and Push --- if args.dry_run: - print(f"[DRY RUN] Would create and checkout branch {branch_name}") + print(f"[DRY RUN] Would push branch {branch_name} to origin") else: - git.checkout(branch_name, create_branch=True) - - # --- Update files --- - if args.dry_run: - print( - f"[DRY RUN] Would update CHANGELOG.md and version placeholders for {version}" - ) - else: - print("Updating changelog and placeholders...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - replace_version_next(version) - - # --- Commit and Push --- - if args.dry_run: - print(f"[DRY RUN] Would push branch {branch_name} to origin") - else: - modified_files = git.status() - if modified_files: - # Stage all modified and deleted tracked files - git.add_modified_and_deleted() - git.commit(f"Prepare release {version}") + modified_files = self.git.status() + if modified_files: + # Stage all modified and deleted tracked files + self.git.add_modified_and_deleted() + self.git.commit(f"Prepare release {version}") + else: + print("No files modified by the release tool. Nothing to commit.") + + print(f"Pushing branch {branch_name} to origin...") + # Force push to overwrite the remote branch if it already exists (e.g. from a previous run) + self.git.push("origin", branch_name, set_upstream=True, force=True) + + # --- Create PR --- + # Determine if we need to create a PR or reuse an existing one + open_pr = self.gh.get_open_pr(branch_name) + associated_pr = None + + if not open_pr and issue_num: + body = self.gh.get_issue_body(issue_num) + state = parse_checklist_state(body) + associated_pr = state["prepare_release"].pr + + if open_pr: + pr_num = open_pr["number"] + pr_url = open_pr["url"] + print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})") + elif associated_pr: + pr_num = associated_pr.lstrip("#") + pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}" + print( + f"PR #{pr_num} is already associated in tracking issue" + f" #{issue_num}. Using it." + ) else: - print("No files modified by the release tool. Nothing to commit.") - - print(f"Pushing branch {branch_name} to origin...") - # Force push to overwrite the remote branch if it already exists (e.g. from a previous run) - git.push("origin", branch_name, set_upstream=True, force=True) - - # --- Create PR --- - # Determine if we need to create a PR or reuse an existing one - open_pr = gh.get_open_pr(branch_name) - associated_pr = None - - if not open_pr and issue_num: - body = gh.get_issue_body(issue_num) - state = parse_checklist_state(body) - associated_pr = state["prepare_release"]["pr"] - - if open_pr: - pr_num = open_pr["number"] - pr_url = open_pr["url"] - print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})") - elif associated_pr: - pr_num = associated_pr.lstrip("#") - pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}" - print( - f"PR #{pr_num} is already associated in tracking issue #{issue_num}. Using it." - ) - else: + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would create Pull Request for branch" + f" {branch_name} targeting issue {target_issue}" + ) + pr_num = "" + else: + pr_url = self.gh.create_pr(version, issue_num) + pr_num = pr_url.split("/")[-1] + print(f"Created Pull Request: {pr_url} (PR #{pr_num})") + + # --- Update checklist --- if args.dry_run: target_issue = f"#{issue_num}" if issue_num else "" print( - f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}" + f"[DRY RUN] Would update tracking issue {target_issue} checklist" + " 'Prepare Release' task status to PENDING" ) - pr_num = "" else: - pr_url = gh.create_pr(version, issue_num) - pr_num = pr_url.split("/")[-1] - print(f"Created Pull Request: {pr_url} (PR #{pr_num})") - - # --- Update checklist --- - if args.dry_run: - target_issue = f"#{issue_num}" if issue_num else "" - print( - f"[DRY RUN] Would update tracking issue {target_issue} checklist 'Prepare Release' task status to PENDING" + print( + f"Updating tracking issue #{issue_num} checklist 'Prepare" + " Release' task status to PENDING..." + ) + body = self.gh.get_issue_body(issue_num) + metadata = {"status": "pending", "pr": f"#{pr_num}"} + updated_body = update_task_in_body( + body, "Prepare Release", checked=False, metadata=metadata + ) + self.gh.update_issue_body(issue_num, updated_body) + print("Preparation pipeline completed successfully!") + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for prepare subcommand.""" + parser = subparsers.add_parser( + "prepare", + help="Prepare the release (updates changelog, placeholders).", ) - else: - print( - f"Updating tracking issue #{issue_num} checklist 'Prepare Release' task status to PENDING..." + parser.add_argument( + "version", + nargs="?", + type=semver_type, + help="The new release version (e.g., 0.28.0). If not provided, " + "it will be determined automatically.", ) - body = gh.get_issue_body(issue_num) - metadata = {"status": "pending", "pr": f"#{pr_num}"} - updated_body = update_task_in_body( - body, "Prepare Release", checked=False, metadata=metadata + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional, triggers automated branch/PR pipeline).", ) - gh.update_issue_body(issue_num, updated_body) - print("Preparation pipeline completed successfully!") - - return 0 + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 373e1848ec..21af2d5154 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -1,9 +1,12 @@ """Subcommand to process pending backports.""" +import argparse import datetime from typing import Any -from tools.private.release import changelog_news, gh, git +from tools.private.release import changelog_news +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_backports, @@ -12,231 +15,290 @@ from tools.private.release.utils import get_latest_rc_tag -def _process_pr_commit_infos( - pr_commit_infos, body, issue, dry_run -) -> tuple[list[str], dict[str, Any], list[str], list[str], str]: - shas = [] - sha_to_item = {} - failed_prs = [] - ignored_prs = [] - for item in pr_commit_infos: - if item.commit: - sha = item.commit - sha_to_item[sha] = item - shas.append(sha) - elif item.status in ("open-pr", "draft-pr"): - print(f"PR {item.pr_ref} is open or draft. Ignoring.") - ignored_prs.append(item.pr_ref) - else: - failed_prs.append(item.pr_ref) - status_to_set = item.status or "error-unmerged-pr" - if dry_run: - print( - f"[DRY RUN] Would update tracking issue checklist for unresolved PR {item.pr_ref} to status={status_to_set}" - ) +class ProcessBackports: + """Class to process pending backports.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def _process_pr_commit_infos( + self, pr_commit_infos, body, issue, dry_run + ) -> tuple[list[str], dict[str, Any], list[str], list[str], str]: + shas = [] + sha_to_item = {} + failed_prs = [] + ignored_prs = [] + for item in pr_commit_infos: + if item.commit: + sha = item.commit + sha_to_item[sha] = item + shas.append(sha) + elif item.status in ("open-pr", "draft-pr"): + print(f"PR {item.pr_ref} is open or draft. Ignoring.") + ignored_prs.append(item.pr_ref) else: - print( - f"Updating tracking issue checklist for unresolved PR {item.pr_ref}..." - ) - try: - body = update_task_in_body( - body, - item.pr_ref, - checked=False, - metadata={"status": status_to_set}, + failed_prs.append(item.pr_ref) + status_to_set = item.status or "error-unmerged-pr" + if dry_run: + print( + f"[DRY RUN] Would update tracking issue checklist for" + f" unresolved PR {item.pr_ref} to status={status_to_set}" ) - gh.update_issue_body(issue, body) - except Exception as e: + else: print( - f"ERROR: Failed to update tracking issue for unresolved PR {item.pr_ref}: {e}" + f"Updating tracking issue checklist for unresolved PR" + f" {item.pr_ref}..." ) - return shas, sha_to_item, failed_prs, ignored_prs, body - - -def _cherry_pick_and_update_prs( - sorted_shas, - sha_to_item, - body, - issue, - remote, - dry_run, - version, - branch_name, - next_rc_suffix, -) -> tuple[list[str], str]: - failed_prs = [] - for sha in sorted_shas: - item = sha_to_item[sha] - print(f"Cherry-picking {item.pr_ref} / {sha}...") - try: - git.cherry_pick(sha) - - # Perform news processing (merging news/ files into the changelog) - print(f"Merging news fragments into changelog for PR {item.pr_ref}...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - - # Stage changelog changes and news/ deletions - git.add("CHANGELOG.md", "news/") - - # Amend cherry-pick commit to include news merging and deletions, - # and reference the release tracking issue. - print(f"Amending cherry-pick commit for PR {item.pr_ref}...") - current_msg = git.get_commit_message("HEAD") - new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" - git.commit(new_msg, amend=True) - - if not dry_run: - # Push amended commit - git.push(remote, branch_name) - - new_sha = git.get_commit_sha("HEAD", short=True) - metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} - print(f"Updating tracking issue checklist for PR {item.pr_ref}...") - try: - body = update_task_in_body( - body, item.pr_ref, checked=True, metadata=metadata + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": status_to_set}, + ) + self.gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for" + f" unresolved PR {item.pr_ref}: {e}" + ) + return shas, sha_to_item, failed_prs, ignored_prs, body + + def _cherry_pick_and_update_prs( + self, + sorted_shas, + sha_to_item, + body, + issue, + remote, + dry_run, + version, + branch_name, + next_rc_suffix, + ) -> tuple[list[str], str]: + failed_prs = [] + for sha in sorted_shas: + item = sha_to_item[sha] + print(f"Cherry-picking {item.pr_ref} / {sha}...") + try: + self.git.cherry_pick(sha) + + # Perform news processing (merging news/ files into the changelog) + print(f"Merging news fragments into changelog for PR {item.pr_ref}...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + + # Stage changelog changes and news/ deletions + self.git.add("CHANGELOG.md", "news/") + + # Amend cherry-pick commit to include news merging and deletions, + # and reference the release tracking issue. + print(f"Amending cherry-pick commit for PR {item.pr_ref}...") + current_msg = self.git.get_commit_message("HEAD") + new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" + self.git.commit(new_msg, amend=True) + + if not dry_run: + # Push amended commit + self.git.push(remote, branch_name) + + new_sha = self.git.get_commit_sha("HEAD", short=True) + metadata = { + "status": "done", + "rc": next_rc_suffix, + "commit": new_sha, + } + print(f"Updating tracking issue checklist for PR {item.pr_ref}...") + try: + body = update_task_in_body( + body, item.pr_ref, checked=True, metadata=metadata + ) + self.gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for PR" + f" {item.pr_ref}: {e}" + ) + print(f"Success: backported {item.pr_ref} / {sha} to {branch_name}") + else: + print( + f"[DRY RUN] Success: {item.pr_ref} / {sha} can be" + f" backported without error." ) - gh.update_issue_body(issue, body) - except Exception as e: print( - f"ERROR: Failed to update tracking issue for PR {item.pr_ref}: {e}" + f"[DRY RUN] Would update tracking issue checklist for" + f" PR {item.pr_ref} to status=done" ) - print(f"Success: backported {item.pr_ref} / {sha} to {branch_name}") - else: - print( - f"[DRY RUN] Success: {item.pr_ref} / {sha} can be backported without error." - ) - print( - f"[DRY RUN] Would update tracking issue checklist for PR {item.pr_ref} to status=done" - ) - except Exception as e: - print(f"ERROR: Conflict or error on {sha}: {e}. Aborting.") - try: - git.cherry_pick_abort() - except Exception: - pass - failed_prs.append(item.pr_ref) - - if dry_run: - print( - f"[DRY RUN] Would update tracking issue checklist for failed PR {item.pr_ref} to status=error-merge-conflict" - ) - else: - print( - f"Updating tracking issue checklist for failed PR {item.pr_ref}..." - ) + except Exception as e: + print(f"ERROR: Conflict or error on {sha}: {e}. Aborting.") try: - body = update_task_in_body( - body, - item.pr_ref, - checked=False, - metadata={"status": "error-merge-conflict"}, - ) - gh.update_issue_body(issue, body) + self.git.cherry_pick_abort() + except Exception: + pass + failed_prs.append(item.pr_ref) + + if dry_run: print( - f"Updated back port of {item.pr_ref} to status=error-merge-conflict (unchecked)" + f"[DRY RUN] Would update tracking issue checklist for" + f" failed PR {item.pr_ref} to status=error-merge-conflict" ) - except Exception as e: + else: print( - f"ERROR: Failed to update tracking issue for failed PR {item.pr_ref}: {e}" + f"Updating tracking issue checklist for failed PR" + f" {item.pr_ref}..." ) - return failed_prs, body + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": "error-merge-conflict"}, + ) + self.gh.update_issue_body(issue, body) + print( + f"Updated back port of {item.pr_ref} to" + f" status=error-merge-conflict (unchecked)" + ) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for" + f" failed PR {item.pr_ref}: {e}" + ) + return failed_prs, body + def run(self) -> int: + """Executes the process-backports subcommand.""" + args = self.args + body = self.gh.get_issue_body(args.issue) + items = parse_backports(body) -def cmd_process_backports(args): - """Executes the process-backports subcommand.""" - body = gh.get_issue_body(args.issue) - items = parse_backports(body) + pending_items = [ + item + for item in items + if not item.checked and not item.status.startswith("error-") + ] - pending_items = [ - item - for item in items - if not item.checked and not item.status.startswith("error-") - ] + if not pending_items: + print("No pending backports found.") + return 0 - if not pending_items: - print("No pending backports found.") - return 0 + print(f"Found {len(pending_items)} pending backports to process.") + + # Determine branch name from issue title + issue_title = self.gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag to write to backport metadata + self.git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote=args.remote) + if not latest_rc: + next_rc_suffix = "rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_suffix = f"rc{rc_num + 1}" + + # Resolve PRs to merge commits using gh helper. + pr_commit_infos = self.gh.get_merge_commits_for_prs(pending_items) + + shas, sha_to_item, failed_prs, ignored_prs, body = ( + self._process_pr_commit_infos( + pr_commit_infos, body, args.issue, args.dry_run + ) + ) + + if not shas: + print("No valid merge commits to process.") + if failed_prs: + print("Failed PRs:") + for pr in failed_prs: + print(f"- {pr}") + return 1 + return 0 + + # Verify workspace is clean before proceeding + if self.git.status(): + print( + "ERROR: Git workspace is dirty. Please commit or stash changes" + " before running backports." + ) + return 1 + + # Sort chronologically using git helper + sorted_shas = self.git.sort_commits_chronologically(shas) + + self.git.fetch(args.remote) + self.git.checkout(branch_name, track_remote=args.remote) + start_sha = self.git.get_commit_sha("HEAD") + + try: + new_failed_prs, body = self._cherry_pick_and_update_prs( + sorted_shas, + sha_to_item, + body, + args.issue, + args.remote, + args.dry_run, + version, + branch_name, + next_rc_suffix, + ) + failed_prs.extend(new_failed_prs) + finally: + if args.dry_run: + print(f"[DRY RUN] Resetting branch {branch_name} to {start_sha}") + self.git.reset_hard(start_sha) - print(f"Found {len(pending_items)} pending backports to process.") - - # Determine branch name from issue title - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - # Determine next RC tag to write to backport metadata - git.fetch(args.remote, tags=True, force=True) - latest_rc = get_latest_rc_tag(version, remote=args.remote) - if not latest_rc: - next_rc_suffix = "rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_suffix = f"rc{rc_num + 1}" - - # Resolve PRs to merge commits using gh helper. - pr_commit_infos = gh.get_merge_commits_for_prs(pending_items) - - shas, sha_to_item, failed_prs, ignored_prs, body = _process_pr_commit_infos( - pr_commit_infos, body, args.issue, args.dry_run - ) - - if not shas: - print("No valid merge commits to process.") if failed_prs: - print("Failed PRs:") + print("ERROR: One or more cherry-picks/resolutions failed:") for pr in failed_prs: print(f"- {pr}") return 1 + + if args.dry_run: + print("Dry run completed successfully. No errors found.") + else: + print("All backports successfully processed!") return 0 - # Verify workspace is clean before proceeding - if git.status(): - print( - "ERROR: Git workspace is dirty. Please commit or stash changes before running backports." + @classmethod + def add_parser(cls, subparsers): + """Adds parser for process-backports subcommand.""" + parser = subparsers.add_parser( + "process-backports", + help="Cherry-pick pending backports listed in the tracking issue.", ) - return 1 - - # Sort chronologically using git helper - sorted_shas = git.sort_commits_chronologically(shas) - - git.fetch(args.remote) - git.checkout(branch_name, track_remote=args.remote) - start_sha = git.get_commit_sha("HEAD") - - try: - new_failed_prs, body = _cherry_pick_and_update_prs( - sorted_shas, - sha_to_item, - body, - args.issue, - args.remote, - args.dry_run, - version, - branch_name, - next_rc_suffix, + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", ) - failed_prs.extend(new_failed_prs) - finally: - if args.dry_run: - print(f"[DRY RUN] Resetting branch {branch_name} to {start_sha}") - git.reset_hard(start_sha) - - if failed_prs: - print("ERROR: One or more cherry-picks/resolutions failed:") - for pr in failed_prs: - print(f"- {pr}") - return 1 - - if args.dry_run: - print("Dry run completed successfully. No errors found.") - else: - print("All backports successfully processed!") - return 0 + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push changes to (required).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py new file mode 100644 index 0000000000..877a65b485 --- /dev/null +++ b/tools/private/release/promote_rc.py @@ -0,0 +1,143 @@ +"""Subcommand to promote a release candidate to final release.""" + +import argparse +import urllib.parse + +from tools.private.release.gh import GitHub +from tools.private.release.git import Git +from tools.private.release.release_issue import update_task_in_body +from tools.private.release.utils import ( + REPO_URL, + determine_next_version, + get_latest_rc_tag, + semver_type, +) + + +class PromoteRc: + """Class to promote a release candidate to final release.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the promote-rc subcommand (Phase 3).""" + args = self.args + # Fetch from upstream to ensure we have the latest tags + self.git.fetch("upstream", tags=True, force=True) + + version = args.version + if version is None: + version = determine_next_version() + + latest_rc = get_latest_rc_tag(version, remote="upstream") + if not latest_rc: + print(f"Error: No release candidate tags found matching {version}-rc*") + return 1 + + # Verify final tag doesn't already exist + if self.git.tag_exists(version): + print(f"Error: Final tag {version} already exists.") + return 1 + + # Verify issue can be found + issue_num = args.issue + if not issue_num: + try: + issue_num = self.gh.get_release_tracking_issue(version) + except ValueError as e: + print(f"Error: {e}") + return 1 + except Exception as e: + print(f"Error: Unexpected error finding tracking issue: {e}") + return 1 + + # Get commit SHA of the RC tag (which will be the same for the final tag) + commit_sha = self.git.get_commit_sha(latest_rc) + + # Verify issue is in the right format by trying to prepare the update + print(f"Verifying tracking issue #{issue_num} format...") + body = self.gh.get_issue_body(issue_num) + metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} + try: + updated_body = update_task_in_body( + body, "Tag Final", checked=True, metadata=metadata + ) + except ValueError as e: + print(f"Error: Tracking issue #{issue_num} is malformed: {e}") + return 1 + + # All pre-conditions met, perform modifications + if args.dry_run: + print( + f"[DRY RUN] Pre-conditions passed successfully for promoting" + f" {latest_rc} to {version}." + ) + print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") + print(f"[DRY RUN] Would push tag {version} to upstream") + print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") + print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") + return 0 + + print( + f"Promoting {latest_rc} to final release {version} (commit" + f" {commit_sha[:8]}) using tracking issue #{issue_num}..." + ) + + # Tag the specific commit without checkout, and push to upstream + self.git.tag(version, commit_sha) + self.git.push("upstream", version) + + print(f"Updating tracking issue #{issue_num} checklist...") + self.gh.update_issue_body(issue_num, updated_body) + + print(f"Posting comment to tracking issue #{issue_num}...") + + release_url = f"{REPO_URL}/releases/tag/{version}" + bcr_query = ( + f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' + ) + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" + comment_body = ( + f"Version {version} has been tagged.\n\n" + f"- **Release Page**: {release_url}\n" + f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" + ) + self.gh.post_issue_comment(issue_num, comment_body) + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for promote-rc subcommand.""" + parser = subparsers.add_parser( + "promote-rc", + help="Promote the latest RC to final release.", + ) + parser.add_argument( + "version", + nargs="?", + type=semver_type, + help="The final version to release (e.g., 0.38.0).", + ) + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/release.py b/tools/private/release/release.py index a6c5b3f88b..a6f3543e71 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -2,192 +2,27 @@ import argparse import os -import pathlib -import re import sys -from tools.private.release import gh, git -from tools.private.release.create_rc import cmd_create_rc -from tools.private.release.create_release_branch import cmd_create_release_branch -from tools.private.release.prepare import cmd_prepare -from tools.private.release.process_backports import cmd_process_backports -from tools.private.release.release_issue import ( - update_task_in_body, -) -from tools.private.release.utils import ( - REPO_URL, - determine_next_version, - get_latest_rc_tag, -) - - -def _semver_type(value): - if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): - raise argparse.ArgumentTypeError( - f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)" - ) - return value - - -# ============================================================================== -# Subcommand Execution Functions -# ============================================================================== - - -def cmd_determine_next_version(args): - """Executes the determine-next-version subcommand.""" - version = determine_next_version() - print(version) - return 0 - - -def cmd_create_release_issue(args): - """Executes the create-release-issue subcommand.""" - version = args.version - if version is None: - version = determine_next_version() - - # Concurrency check - open_issues = gh.get_open_tracking_issues() - if open_issues: - print("Error: A release is already in progress. Active tracking issues:") - for issue in open_issues: - print(f"- {issue['title']}: {issue['url']}") - return 1 - - template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md") - if not template_path.exists(): - raise FileNotFoundError(f"Template file not found at {template_path}") - template_content = template_path.read_text(encoding="utf-8") - - issue_num = gh.create_tracking_issue(version, template_content) - print(f"Created tracking issue #{issue_num} for v{version}") - return 0 - - -def cmd_complete_prepare(args): - """Executes the complete-prepare subcommand (Phase 2 PR merged).""" - print(f"Completing preparation for PR #{args.pr}...") - - pr_info = gh.get_pr_info(args.pr) - if not pr_info or pr_info.get("state") != "MERGED": - state = pr_info.get("state", "UNKNOWN") - print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") - return 1 - - # Resolve issue number from PR body - pr_body = pr_info.get("body", "") - match = re.search(r"Work towards #(\d+)", pr_body) - if not match: - match = re.search(r"#(\d+)", pr_body) - if not match: - print( - f"Error: Could not determine tracking issue number from PR #{args.pr}" - f" body: {pr_body}" - ) - return 1 - - issue_num = int(match.group(1)) - print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") - - commit_sha = pr_info["mergeCommit"]["oid"] - short_commit = commit_sha[:8] - print(f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue...") - - # Update checklist: mark Prepare Release as done (checked) and set SUCCESS - body = gh.get_issue_body(issue_num) - metadata = {"status": "done", "pr": f"#{args.pr}", "commit": short_commit} - updated_body = update_task_in_body( - body, "Prepare Release", checked=True, metadata=metadata - ) - gh.update_issue_body(issue_num, updated_body) - print("Prepare Release task marked complete successfully!") - return 0 - - -def cmd_promote_rc(args): - """Executes the promote-rc subcommand (Phase 3).""" - # Fetch from upstream to ensure we have the latest tags - git.fetch("upstream", tags=True, force=True) - - version = args.version - if version is None: - version = determine_next_version() - - latest_rc = get_latest_rc_tag(version, remote="upstream") - if not latest_rc: - print(f"Error: No release candidate tags found matching {version}-rc*") - return 1 - - # Verify final tag doesn't already exist - if git.tag_exists(version): - print(f"Error: Final tag {version} already exists.") - return 1 - - # Verify issue can be found - issue_num = args.issue - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(version) - except ValueError as e: - print(f"Error: {e}") - return 1 - except Exception as e: - print(f"Error: Unexpected error finding tracking issue: {e}") - return 1 - - # Get commit SHA of the RC tag (which will be the same for the final tag) - commit_sha = git.get_commit_sha(latest_rc) - - # Verify issue is in the right format by trying to prepare the update - print(f"Verifying tracking issue #{issue_num} format...") - body = gh.get_issue_body(issue_num) - metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} - try: - updated_body = update_task_in_body( - body, "Tag Final", checked=True, metadata=metadata - ) - except ValueError as e: - print(f"Error: Tracking issue #{issue_num} is malformed: {e}") - return 1 - - # All pre-conditions met, perform modifications - if args.dry_run: - print( - f"[DRY RUN] Pre-conditions passed successfully for promoting {latest_rc} to {version}." - ) - print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") - print(f"[DRY RUN] Would push tag {version} to upstream") - print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") - print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") - return 0 - - print( - f"Promoting {latest_rc} to final release {version} (commit" - f" {commit_sha[:8]}) using tracking issue #{issue_num}..." - ) - - # Tag the specific commit without checkout, and push to upstream - git.tag(version, commit_sha) - git.push("upstream", version) - - print(f"Updating tracking issue #{issue_num} checklist...") - gh.update_issue_body(issue_num, updated_body) - - print(f"Posting comment to tracking issue #{issue_num}...") - import urllib.parse - - release_url = f"{REPO_URL}/releases/tag/{version}" - bcr_query = f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' - bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" - comment_body = ( - f"Version {version} has been tagged.\n\n" - f"- **Release Page**: {release_url}\n" - f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" - ) - gh.post_issue_comment(issue_num, comment_body) - - return 0 +from tools.private.release.complete_prepare import CompletePrepare +from tools.private.release.create_rc import CreateRc +from tools.private.release.create_release_branch import CreateReleaseBranch +from tools.private.release.create_release_issue import CreateReleaseIssue +from tools.private.release.determine_next_version import DetermineNextVersion +from tools.private.release.prepare import Prepare +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.promote_rc import PromoteRc + +cmds = [ + DetermineNextVersion, + CreateReleaseIssue, + Prepare, + CompletePrepare, + CreateReleaseBranch, + ProcessBackports, + CreateRc, + PromoteRc, +] def create_parser(): @@ -200,141 +35,8 @@ def create_parser(): dest="command", required=True, help="Subcommands" ) - # Subcommand: determine-next-version - subparsers.add_parser( - "determine-next-version", - help="Determine the next version and print it, without making any changes.", - ) - - # Subcommand: create-release-issue - create_issue_parser = subparsers.add_parser( - "create-release-issue", - help="Search for open releases and create a new tracking issue.", - ) - create_issue_parser.add_argument( - "--version", - type=_semver_type, - help="The release version (e.g., 0.38.0). If not provided, determined automatically.", - ) - - # Subcommand: prepare - prepare_parser = subparsers.add_parser( - "prepare", - help="Prepare the release (updates changelog, placeholders).", - ) - prepare_parser.add_argument( - "version", - nargs="?", - type=_semver_type, - help="The new release version (e.g., 0.28.0). If not provided, " - "it will be determined automatically.", - ) - prepare_parser.add_argument( - "--issue", - type=int, - help="The tracking issue number (optional, triggers automated branch/PR pipeline).", - ) - prepare_parser.add_argument( - "--dry-run", - action=argparse.BooleanOptionalAction, - default=True, - help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", - ) - - # Subcommand: complete-prepare - complete_prep_parser = subparsers.add_parser( - "complete-prepare", - help="Mark the Prepare Release task as complete in the tracking issue.", - ) - complete_prep_parser.add_argument( - "--pr", - type=int, - required=True, - help="The merged preparation PR number.", - ) - - # Subcommand: create-release-branch - create_branch_parser = subparsers.add_parser( - "create-release-branch", - help="Create the release branch pointing to the merged PR commit.", - ) - create_branch_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - create_branch_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to create the branch on (required).", - ) - - # Subcommand: process-backports - process_backports_parser = subparsers.add_parser( - "process-backports", - help="Cherry-pick pending backports listed in the tracking issue.", - ) - process_backports_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - process_backports_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to push changes to (required).", - ) - process_backports_parser.add_argument( - "--dry-run", - action=argparse.BooleanOptionalAction, - default=True, - help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", - ) - - # Subcommand: create-rc - create_rc_parser = subparsers.add_parser( - "create-rc", - help="Tags the next RC on the release branch if no backports remain.", - ) - create_rc_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - create_rc_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to push the RC tag to (required).", - ) - - # Subcommand: promote-rc - promote_parser = subparsers.add_parser( - "promote-rc", - help="Promote the latest RC to final release.", - ) - promote_parser.add_argument( - "version", - nargs="?", - type=_semver_type, - help="The final version to release (e.g., 0.38.0).", - ) - promote_parser.add_argument( - "--issue", - type=int, - help="The tracking issue number (optional).", - ) - promote_parser.add_argument( - "--dry-run", - action=argparse.BooleanOptionalAction, - default=True, - help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", - ) + for cmd in cmds: + cmd.add_parser(subparsers) return parser @@ -348,24 +50,10 @@ def main(): exit_code = 1 try: - if args.command == "determine-next-version": - exit_code = cmd_determine_next_version(args) - elif args.command == "create-release-issue": - exit_code = cmd_create_release_issue(args) - elif args.command == "prepare": - exit_code = cmd_prepare(args) - elif args.command == "complete-prepare": - exit_code = cmd_complete_prepare(args) - elif args.command == "create-release-branch": - exit_code = cmd_create_release_branch(args) - elif args.command == "process-backports": - exit_code = cmd_process_backports(args) - elif args.command == "create-rc": - exit_code = cmd_create_rc(args) - elif args.command == "promote-rc": - exit_code = cmd_promote_rc(args) + # args.command is the run_from_args classmethod of the selected command + exit_code = args.command(args) except Exception as e: - print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) + print(f"Fatal error: {e}", file=sys.stderr) if hasattr(e, "__notes__"): for note in e.__notes__: print(note, file=sys.stderr) diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index adc0d88086..276261bef6 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -38,6 +38,49 @@ def __repr__(self): ) +class ReleaseTask: + """Represents a release task from the tracking issue checklist.""" + + def __init__( + self, + name: str, + checked: bool, + status: str | None = None, + pr: str | None = None, + commit: str | None = None, + branch: str | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + ): + """Initializes a ReleaseTask. + + Args: + name: The name of the task (e.g. 'Prepare Release'). + checked: Whether the checklist item is checked. + status: The status of the task (e.g. 'pending', 'done'). + pr: The associated PR reference (e.g. '#123'). + commit: The associated commit SHA. + branch: The associated branch name. + tag: The associated tag name. + metadata: Raw metadata parsed from the checklist line. + """ + self.name = name + self.checked = checked + self.status = status + self.pr = pr + self.commit = commit + self.branch = branch + self.tag = tag + self.metadata = metadata or {} + + def __repr__(self): + return ( + f"ReleaseTask(name={self.name!r}, checked={self.checked!r}, " + f"status={self.status!r}, pr={self.pr!r}, commit={self.commit!r}, " + f"branch={self.branch!r}, tag={self.tag!r})" + ) + + RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) @@ -101,22 +144,17 @@ def update_task_in_body(body, task_name, checked, metadata): def parse_checklist_state(body): - """Parses the main checklist tasks and their metadata.""" + """Parses the main checklist tasks and their metadata. + + Returns: + A dict containing ReleaseTask objects for 'prepare_release', + 'create_branch', 'tag_final', and a dict of RC tags. + """ state = { - "prepare_release": { - "checked": False, - "status": None, - "pr": None, - "commit": None, - }, - "create_branch": { - "checked": False, - "status": None, - "branch": None, - "commit": None, - }, - "tag_final": {"checked": False, "status": None, "tag": None, "commit": None}, - "rc_tags": {}, # Dynamically mapped: int -> metadata dict + "prepare_release": ReleaseTask("Prepare Release", False), + "create_branch": ReleaseTask("Create Release branch", False), + "tag_final": ReleaseTask("Tag Final", False), + "rc_tags": {}, # Dynamically mapped: int -> ReleaseTask } lines = body.splitlines() @@ -131,37 +169,45 @@ def parse_checklist_state(body): name_lower = name.lower() if "prepare release" in name_lower: - state["prepare_release"] = { - "checked": checked, - "status": meta.get("status"), - "pr": meta.get("pr"), - "commit": meta.get("commit"), - } + state["prepare_release"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + pr=meta.get("pr"), + commit=meta.get("commit"), + metadata=meta, + ) elif "create release branch" in name_lower: - state["create_branch"] = { - "checked": checked, - "status": meta.get("status"), - "branch": meta.get("branch"), - "commit": meta.get("commit"), - } + state["create_branch"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + branch=meta.get("branch"), + commit=meta.get("commit"), + metadata=meta, + ) elif "tag final" in name_lower: - state["tag_final"] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } + state["tag_final"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + tag=meta.get("tag"), + commit=meta.get("commit"), + metadata=meta, + ) else: # Match Tag RC rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE) if rc_match: rc_num = int(rc_match.group(1)) - state["rc_tags"][rc_num] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } + state["rc_tags"][rc_num] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + tag=meta.get("tag"), + commit=meta.get("commit"), + metadata=meta, + ) return state diff --git a/tools/private/release/shell.py b/tools/private/release/shell.py index cfff53f4e4..0093a28574 100644 --- a/tools/private/release/shell.py +++ b/tools/private/release/shell.py @@ -4,14 +4,15 @@ import subprocess -def run_cmd(*args, check=True, capture_output=True): +def run_cmd(*args, check=True, capture_output=True, cwd=None): """Runs a command as a subprocess with separate arguments (prints command). If the command fails, it raises the CalledProcessError after attaching a detailed note explaining the failure to preserve the stack trace. """ cmd = [str(arg) for arg in args] - print(f"Running: {shlex.join(cmd)}") + cwd_suffix = f" (cwd: {cwd})" if cwd else "" + print(f"> {shlex.join(cmd)}{cwd_suffix}") try: result = subprocess.run( cmd, @@ -19,10 +20,11 @@ def run_cmd(*args, check=True, capture_output=True): stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.PIPE if capture_output else None, universal_newlines=True, + cwd=cwd, ) return result.stdout.strip() if capture_output else None except subprocess.CalledProcessError as e: - note = f"Error running command: {shlex.join(cmd)}" + note = f"Error running command: {shlex.join(cmd)}{cwd_suffix}" if capture_output: note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" e.add_note(note) diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 2d050022b1..e774c80134 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -1,15 +1,26 @@ """Utility functions for the release tool.""" +import argparse import fnmatch import os import re from packaging.version import parse as parse_version -from tools.private.release import git +from tools.private.release.git import Git REPO_URL = "https://github.com/bazel-contrib/rules_python" + +def semver_type(value): + """Argparse type validator for semantic versions.""" + if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): + raise argparse.ArgumentTypeError( + f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)" + ) + return value + + _EXCLUDE_PATTERNS = [ "./.git/*", "./.github/*", @@ -45,6 +56,7 @@ def _iter_version_placeholder_files(): def get_latest_version(): """Gets the latest version from git tags.""" + git = Git(".") tags = git.get_tags() versions = [ (tag, parse_version(tag)) @@ -69,6 +81,7 @@ def get_latest_version(): def get_latest_rc_tag(version, remote=None): """Queries git tags and returns the highest RC tag for the version.""" + git = Git(".") if remote: tags = git.get_remote_tags(remote) else: @@ -97,6 +110,7 @@ def should_increment_minor(): def determine_next_version(branch_name=None): """Determines the next version based on git tags and the current branch.""" + git = Git(".") if branch_name is None: branch_name = git.get_current_branch() From 6570a9ec1db2bd61be1b4d3e92fdc85d24da0d1d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 00:51:46 -0700 Subject: [PATCH 051/179] chore(release): pass missing --remote argument to release workflows (#3883) Workflows using `process-backports` and `promote-rc` subcommands were failing due to missing or hardcoded remote arguments. - Added `--remote` parameter to `promote-rc` subcommand. - Updated `release_process_backports.yaml` and `release_promote_rc.yaml` to pass `--remote origin`. - Updated tests in `release_test.py` to support and verify the new argument. --- .../workflows/release_process_backports.yaml | 2 +- .github/workflows/release_promote_rc.yaml | 2 +- tests/tools/private/release/release_test.py | 34 +++++++++++-------- tools/private/release/promote_rc.py | 16 ++++++--- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml index ac6ea99d80..a2ea7b2067 100644 --- a/.github/workflows/release_process_backports.yaml +++ b/.github/workflows/release_process_backports.yaml @@ -35,6 +35,6 @@ jobs: - name: Process Pending Backports run: | bazel run //tools/private/release -- \ - process-backports --issue ${{ inputs.issue }} + process-backports --issue ${{ inputs.issue }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote_rc.yaml index b668a15338..5a0ad72a80 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote_rc.yaml @@ -34,6 +34,6 @@ jobs: - name: Run Promote RC run: | bazel run //tools/private/release -- \ - promote-rc ${{ inputs.version }} + promote-rc ${{ inputs.version }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 96bb65280f..2a752ef1cc 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -514,7 +514,9 @@ def test_replace_version_next_excludes_bazel_dirs(self): def test_valid_version(self): # These should not raise an exception releaser.create_parser().parse_args(["prepare", "0.28.0"]) - releaser.create_parser().parse_args(["promote-rc", "1.0.0"]) + releaser.create_parser().parse_args( + ["promote-rc", "1.0.0", "--remote", "origin"] + ) releaser.create_parser().parse_args( ["create-release-issue", "--version", "1.2.3rc4"] ) @@ -1112,7 +1114,7 @@ def setUp(self): def test_promote_rc_success(self): # Arrange - args = MagicMock(version="2.0.0", issue=123, dry_run=False) + args = MagicMock(version="2.0.0", issue=123, dry_run=False, remote="my-remote") self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False @@ -1124,12 +1126,12 @@ def test_promote_rc_success(self): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("upstream", tags=True, force=True) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") self.mock_git.checkout.assert_not_called() self.mock_git.tag_exists.assert_called_once_with("2.0.0") self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") - self.mock_git.push.assert_called_once_with("upstream", "2.0.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") # Verify issue update self.mock_gh.get_issue_body.assert_called_once_with(123) @@ -1148,7 +1150,7 @@ def test_promote_rc_success(self): def test_promote_rc_resolve_issue_success(self): # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) + args = MagicMock(version="2.0.0", issue=None, dry_run=False, remote="my-remote") self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = None @@ -1162,11 +1164,12 @@ def test_promote_rc_resolve_issue_success(self): # Assert self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") self.mock_git.checkout.assert_not_called() self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") - self.mock_git.push.assert_called_once_with("upstream", "2.0.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") self.mock_gh.get_issue_body.assert_called_once_with(123) expected_updated_body = ( "- [x] Tag Final | status=done tag=2.0.0 commit=abcdef12" @@ -1183,7 +1186,7 @@ def test_promote_rc_resolve_issue_success(self): def test_promote_rc_defaults_to_determine_next_version(self): # Arrange - args = MagicMock(version=None, issue=123, dry_run=False) + args = MagicMock(version=None, issue=123, dry_run=False, remote="my-remote") self.mock_git.get_current_branch.return_value = "release/2.0" self.mock_git.get_tags.return_value = ["2.0.0"] self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"] @@ -1197,14 +1200,15 @@ def test_promote_rc_defaults_to_determine_next_version(self): # Assert self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) self.mock_git.get_current_branch.assert_called_once() self.mock_git.get_tags.assert_called_once() - self.mock_git.get_remote_tags.assert_called_once_with("upstream") + self.mock_git.get_remote_tags.assert_called_once_with("my-remote") self.mock_git.checkout.assert_not_called() self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0") self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") - self.mock_git.push.assert_called_once_with("upstream", "2.0.1") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.1") expected_updated_body = ( "- [x] Tag Final | status=done tag=2.0.1 commit=12345678" @@ -1221,7 +1225,7 @@ def test_promote_rc_defaults_to_determine_next_version(self): def test_promote_rc_dry_run_success(self): # Arrange - args = MagicMock(version="2.0.0", issue=123, dry_run=True) + args = MagicMock(version="2.0.0", issue=123, dry_run=True, remote="my-remote") self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False @@ -1233,7 +1237,7 @@ def test_promote_rc_dry_run_success(self): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("upstream", tags=True, force=True) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") self.mock_git.tag_exists.assert_called_once_with("2.0.0") @@ -1245,7 +1249,7 @@ def test_promote_rc_dry_run_success(self): def test_promote_rc_tag_already_exists(self): # Arrange - args = MagicMock(version="2.0.0", issue=123) + args = MagicMock(version="2.0.0", issue=123, remote="my-remote") self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = True @@ -1262,7 +1266,7 @@ def test_promote_rc_tag_already_exists(self): def test_promote_rc_issue_not_found(self): # Arrange - args = MagicMock(version="2.0.0", issue=None) + args = MagicMock(version="2.0.0", issue=None, remote="my-remote") self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( @@ -1282,7 +1286,7 @@ def test_promote_rc_issue_not_found(self): def test_promote_rc_issue_malformed(self): # Arrange - args = MagicMock(version="2.0.0", issue=123) + args = MagicMock(version="2.0.0", issue=123, remote="my-remote") self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_git.get_commit_sha.return_value = "abcdef123456" @@ -1302,7 +1306,7 @@ def test_promote_rc_issue_malformed(self): def test_promote_rc_no_rc_found(self): # Arrange - args = MagicMock(version="2.0.0", issue=123) + args = MagicMock(version="2.0.0", issue=123, remote="my-remote") self.mock_git.get_remote_tags.return_value = [] # Act diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py index 877a65b485..74671daa71 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote_rc.py @@ -25,14 +25,14 @@ def __init__(self, args, git: Git, gh: GitHub): def run(self) -> int: """Executes the promote-rc subcommand (Phase 3).""" args = self.args - # Fetch from upstream to ensure we have the latest tags - self.git.fetch("upstream", tags=True, force=True) + # Fetch from remote to ensure we have the latest tags + self.git.fetch(args.remote, tags=True, force=True) version = args.version if version is None: version = determine_next_version() - latest_rc = get_latest_rc_tag(version, remote="upstream") + latest_rc = get_latest_rc_tag(version, remote=args.remote) if not latest_rc: print(f"Error: No release candidate tags found matching {version}-rc*") return 1 @@ -86,9 +86,9 @@ def run(self) -> int: f" {commit_sha[:8]}) using tracking issue #{issue_num}..." ) - # Tag the specific commit without checkout, and push to upstream + # Tag the specific commit without checkout, and push to remote self.git.tag(version, commit_sha) - self.git.push("upstream", version) + self.git.push(args.remote, version) print(f"Updating tracking issue #{issue_num} checklist...") self.gh.update_issue_body(issue_num, updated_body) @@ -127,6 +127,12 @@ def add_parser(cls, subparsers): type=int, help="The tracking issue number (optional).", ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push the final tag to (required).", + ) parser.add_argument( "--dry-run", action=argparse.BooleanOptionalAction, From e433644f7b67f9088a899594a42105cc790ba26c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 00:54:53 -0700 Subject: [PATCH 052/179] chore(release): enable execution in process-backports workflow (#3884) Passes `--no-dry-run` to the `process-backports` subcommand in the workflow, enabling it to actually perform the cherry-picks and push changes instead of only simulating them. --- .github/workflows/release_process_backports.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml index a2ea7b2067..822a337323 100644 --- a/.github/workflows/release_process_backports.yaml +++ b/.github/workflows/release_process_backports.yaml @@ -35,6 +35,6 @@ jobs: - name: Process Pending Backports run: | bazel run //tools/private/release -- \ - process-backports --issue ${{ inputs.issue }} --remote origin + process-backports --issue ${{ inputs.issue }} --remote origin --no-dry-run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e51d82222ff769f1e94b22c40f9384c1d8d6e72b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 10:47:10 -0700 Subject: [PATCH 053/179] chore(release): release automation fixes and refactoring (#3885) - Fix promote_rc dry run message to use correct remote. - Support spaces in metadata parsing and format commit SHA with space for GitHub autolinking. - Trigger release publish workflow from create_rc workflow using workflow_call. - Replace version markers during backport processing. - Split release_test.py into separate files for each command. - Refactor release tool BUILD targets to use py_library and glob. --- .github/workflows/release_create_rc.yaml | 12 +- .../{release.yml => release_publish.yaml} | 17 +- RELEASING.md | 4 +- tests/tools/private/release/BUILD.bazel | 91 +- .../private/release/changelog_news_test.py | 413 ++++ tests/tools/private/release/create_rc_test.py | 181 ++ .../release/create_release_branch_test.py | 149 ++ tests/tools/private/release/git_test.py | 48 + tests/tools/private/release/prepare_test.py | 238 +++ .../private/release/process_backports_test.py | 239 +++ .../tools/private/release/promote_rc_test.py | 236 +++ .../private/release/release_issue_test.py | 70 + tests/tools/private/release/release_test.py | 1705 +---------------- .../private/release/release_test_helper.py | 46 + tests/tools/private/release/utils_test.py | 266 +++ tools/private/release/BUILD.bazel | 33 +- tools/private/release/create_rc.py | 8 +- tools/private/release/process_backports.py | 13 +- tools/private/release/promote_rc.py | 2 +- tools/private/release/release_issue.py | 18 +- 20 files changed, 2048 insertions(+), 1741 deletions(-) rename .github/workflows/{release.yml => release_publish.yaml} (87%) create mode 100644 tests/tools/private/release/changelog_news_test.py create mode 100644 tests/tools/private/release/create_rc_test.py create mode 100644 tests/tools/private/release/create_release_branch_test.py create mode 100644 tests/tools/private/release/git_test.py create mode 100644 tests/tools/private/release/prepare_test.py create mode 100644 tests/tools/private/release/process_backports_test.py create mode 100644 tests/tools/private/release/promote_rc_test.py create mode 100644 tests/tools/private/release/release_issue_test.py create mode 100644 tests/tools/private/release/release_test_helper.py create mode 100644 tests/tools/private/release/utils_test.py diff --git a/.github/workflows/release_create_rc.yaml b/.github/workflows/release_create_rc.yaml index 2e22ed1413..9a58e20c09 100644 --- a/.github/workflows/release_create_rc.yaml +++ b/.github/workflows/release_create_rc.yaml @@ -13,8 +13,10 @@ permissions: issues: write jobs: - generate_rc: + tag_rc: runs-on: ubuntu-latest + outputs: + tag_name: ${{ steps.tagger.outputs.tag_name }} steps: - name: Checkout repository uses: actions/checkout@v7 @@ -32,8 +34,16 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Attempt RC Tagging + id: tagger run: | bazel run //tools/private/release -- \ create-rc --issue ${{ inputs.issue }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + call_release: + needs: tag_rc + uses: ./.github/workflows/release_publish.yaml + with: + tag_name: ${{ needs.tag_rc.outputs.tag_name }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release_publish.yaml similarity index 87% rename from .github/workflows/release.yml rename to .github/workflows/release_publish.yaml index 039e56a2b0..8379c3d46d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release_publish.yaml @@ -13,7 +13,7 @@ # limitations under the License. # Cut a release whenever a new tag is pushed to the repo. -name: Release +name: "Release: Publish" on: push: @@ -33,6 +33,17 @@ on: secrets: publish_token: required: false + workflow_call: + inputs: + tag_name: + description: "release tag: tag that will be released" + required: true + type: string + publish_to_pypi: + description: 'Publish to PyPI' + required: false + type: boolean + default: true jobs: release: @@ -42,7 +53,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ github.ref_name }} + ref: ${{ inputs.tag_name || github.ref_name }} - name: Create release archive and notes run: .github/workflows/create_archive_and_notes.sh ${{ inputs.tag_name || github.ref_name }} - name: Release @@ -73,7 +84,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ github.tag_name || github.ref_name }} + ref: ${{ inputs.tag_name || github.ref_name }} - if: github.event_name == 'push' || github.event.inputs.publish_to_pypi env: # This special value tells pypi that the user identity is supplied within the token diff --git a/RELEASING.md b/RELEASING.md index a259c782c6..13eb4ddc0a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -66,14 +66,14 @@ specific commit. To trigger the workflow, use the `gh workflow run` command: ```shell -gh workflow run release.yml --ref +gh workflow run release_publish.yaml --ref ``` By default, the workflow will publish the wheel to PyPI. To skip this step, you can set the `publish_to_pypi` input to `false`: ```shell -gh workflow run release.yml --ref -f publish_to_pypi=false +gh workflow run release_publish.yaml --ref -f publish_to_pypi=false ``` ### Determining Semantic Version diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 9f3bc0542a..e68bbef7d5 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -1,10 +1,97 @@ -load("@rules_python//python:defs.bzl", "py_test") +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +py_library( + name = "release_test_helper", + srcs = ["release_test_helper.py"], + deps = [ + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "changelog_news_test", + srcs = ["changelog_news_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "create_rc_test", + srcs = ["create_rc_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "create_release_branch_test", + srcs = ["create_release_branch_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "git_test", + srcs = ["git_test.py"], + deps = [ + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "prepare_test", + srcs = ["prepare_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "process_backports_test", + srcs = ["process_backports_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "promote_rc_test", + srcs = ["promote_rc_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "release_issue_test", + srcs = ["release_issue_test.py"], + deps = [ + "//tools/private/release:release_lib", + ], +) py_test( name = "release_test", srcs = ["release_test.py"], deps = [ - "//tools/private/release", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "utils_test", + srcs = ["utils_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", "@dev_pip//packaging", ], ) diff --git a/tests/tools/private/release/changelog_news_test.py b/tests/tools/private/release/changelog_news_test.py new file mode 100644 index 0000000000..f8bd141a55 --- /dev/null +++ b/tests/tools/private/release/changelog_news_test.py @@ -0,0 +1,413 @@ +import pathlib +import unittest +from unittest.mock import patch + +from tests.tools.private.release.release_test_helper import TempDirTestCase +from tools.private.release import changelog_news + + +class ChangelogNewsTest(TempDirTestCase): + def test_update_changelog_with_news(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +{#unreleased-removed} +### Removed +* Nothing removed. + +{#unreleased-changed} +### Changed +* Nothing changed. + +{#unreleased-fixed} +### Fixed +* Nothing fixed. + +{#unreleased-added} +### Added +* Nothing added. + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{#v2-0-2-added} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create news files + (news_dir / "123.fixed.md").write_text("Fixed a bug in the compiler") + # Test that it handles prefixing "* " if not present + (news_dir / "456.added.md").write_text("* Added a new feature for Python 3.13") + # Empty file should be ignored + (news_dir / "789.changed.md").write_text("") + # Invalid name should be ignored + (news_dir / "invalid_name.md").write_text("Should be ignored") + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + # 1. News files matching the pattern should be deleted (even empty ones) + self.assertFalse((news_dir / "123.fixed.md").exists()) + self.assertFalse((news_dir / "456.added.md").exists()) + self.assertFalse((news_dir / "789.changed.md").exists()) + # Invalid name does not match pattern -> NOT deleted + self.assertTrue((news_dir / "invalid_name.md").exists()) + + new_content = changelog_path.read_text() + + # 2. A fresh active Unreleased section should be present + self.assertIn("{#unreleased}", new_content) + self.assertIn("## Unreleased", new_content) + self.assertIn( + "Unreleased changes are tracked as individual files in the [news/](./news)\n" + "directory, or view the [latest generated\n" + "changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).", + new_content, + ) + + # 3. The new release section should be present + self.assertIn("{#v3-0-0}", new_content) + self.assertIn("## [3.0.0] - 2026-06-16", new_content) + self.assertIn( + "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", + new_content, + ) + + # 4. Correct categories and content + self.assertIn( + "{#v3-0-0-fixed}\n### Fixed\n* Fixed a bug in the compiler", + new_content, + ) + self.assertIn( + "{#v3-0-0-added}\n### Added\n* Added a new feature for Python 3.13", + new_content, + ) + + # 5. Omitted categories should NOT be present in the new release + self.assertNotIn("{#v3-0-0-removed}", new_content) + self.assertNotIn("{#v3-0-0-changed}", new_content) + + # 6. Old release should still be there + self.assertIn("{#v2-0-2}", new_content) + self.assertIn("## [2.0.2] - 2026-05-14", new_content) + + def test_update_changelog_sorting(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{#v2-0-2-added} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create news files with different sub-categories and some without + (news_dir / "1.fixed.md").write_text("* (zebra) Zebra fix") + (news_dir / "2.fixed.md").write_text("* (apple) Apple fix") + (news_dir / "3.fixed.md").write_text("No subcategory B") + (news_dir / "4.fixed.md").write_text("* (apple) Another apple fix") + (news_dir / "5.fixed.md").write_text("No subcategory A") + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # Expected order in Fixed section: + # 1. No subcategory A + # 2. No subcategory B + # 3. (apple) Another apple fix + # 4. (apple) Apple fix + # 5. (zebra) Zebra fix + + expected_fixed_section = ( + "### Fixed\n" + "* No subcategory A\n" + "* No subcategory B\n" + "* (apple) Another apple fix\n" + "* (apple) Apple fix\n" + "* (zebra) Zebra fix\n" + ) + + self.assertIn(expected_fixed_section, new_content) + + def test_update_changelog_read_failure(self): + # Arrange + original_read_text = pathlib.Path.read_text + + with patch("pathlib.Path.read_text", autospec=True) as mock_read_text: + + def side_effect(path_self, *args, **kwargs): + if "bad_file.fixed.md" in str(path_self): + raise IOError("Simulated read error") + return original_read_text(path_self, *args, **kwargs) + + mock_read_text.side_effect = side_effect + + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{#v2-0-2-added} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create the bad file (must exist so it is found by iterdir) + bad_file = news_dir / "bad_file.fixed.md" + bad_file.write_text("some content that won't be read") + + # Create a good file too + good_file = news_dir / "good_file.fixed.md" + good_file.write_text("* (sub) Good fix") + + # Act & Assert + # It should raise IOError + with self.assertRaises(IOError): + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Both files should still exist (no deletion on failure!) + self.assertTrue(bad_file.exists()) + self.assertTrue(good_file.exists()) + + # Changelog should not be modified + new_content = changelog_path.read_text() + self.assertEqual(changelog, new_content) + + def test_update_changelog_merge_existing(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-3} +## [2.0.3] - 2026-06-15 + +[2.0.3]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.3 + +{#v2-0-3-fixed} +### Fixed +* (pypi) Old fix + multi-line detail + * nested bullet item +* (pypi) Z old fix +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create news files to merge + # 1. New fix in same category (should merge and sort) + (news_dir / "1.fixed.md").write_text("(pypi) New fix") + # 2. New entry in new category (should create category) + (news_dir / "2.added.md").write_text("(toolchains) New feature") + + # Act + changelog_news.update_changelog( + "2.0.3", + "2026-06-15", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + # News files should be deleted + self.assertFalse((news_dir / "1.fixed.md").exists()) + self.assertFalse((news_dir / "2.added.md").exists()) + + new_content = changelog_path.read_text() + + # Expected merged and sorted Fixed section: + # 1. (pypi) New fix (New < Old) + # 2. (pypi) Old fix (with its multi-line detail!) + # 3. (pypi) Z old fix + expected_fixed_section = ( + "### Fixed\n" + "* (pypi) New fix\n" + "* (pypi) Old fix\n" + " multi-line detail\n" + " * nested bullet item\n" + "* (pypi) Z old fix\n" + ) + self.assertIn(expected_fixed_section, new_content) + + # Expected created Added section: + expected_added_section = "### Added\n* (toolchains) New feature\n" + self.assertIn(expected_added_section, new_content) + + # Active Unreleased section should NOT be touched (should still be empty/pointing to news) + self.assertIn("Unreleased changes are tracked as individual files", new_content) + + def test_update_changelog_does_not_leak(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +This release body mentions the word unreleased and {#unreleased} anchor to test leaks. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + (news_dir / "1.fixed.md").write_text("Some fix") + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # The 2.0.2 body should NOT be modified + self.assertIn( + "This release body mentions the word unreleased and {#unreleased} anchor to test leaks.", + new_content, + ) + + def test_update_changelog_empty_news(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{#v2-0-2-added} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # The new release section should be present and contain "No notable changes." + self.assertIn("{#v3-0-0}", new_content) + self.assertIn("## [3.0.0] - 2026-06-16", new_content) + self.assertIn( + "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", + new_content, + ) + self.assertIn("No notable changes.", new_content) + + # Verify that we didn't accidentally create any categories + self.assertNotIn("{#v3-0-0-fixed}", new_content) + self.assertNotIn("{#v3-0-0-added}", new_content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py new file mode 100644 index 0000000000..2b4e40cc4e --- /dev/null +++ b/tests/tools/private/release/create_rc_test.py @@ -0,0 +1,181 @@ +import os +import pathlib +import tempfile +import unittest +from unittest.mock import MagicMock, call, patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.create_rc import CreateRc + + +class CmdCreateRcTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + + def test_create_rc_success_first_rc(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + with tempfile.TemporaryDirectory() as tmpdir: + github_output_file = pathlib.Path(tmpdir) / "github_output" + with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output_file)}): + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertTrue(github_output_file.exists()) + self.assertEqual(github_output_file.read_text(), "tag_name=2.0.0-rc0\n") + self.mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("tag=2.0.0-rc0", call_args[1]) + self.assertIn("commit= 12345678", call_args[1]) + + self.mock_gh.post_issue_comment.assert_called_once() + comment_call_args = self.mock_gh.post_issue_comment.call_args[0] + self.assertEqual(comment_call_args[0], 123) + self.assertIn( + "**New Release Candidate Tagged!** 🐍🌿", + comment_call_args[1], + ) + self.assertIn( + "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)", + comment_call_args[1], + ) + self.assertIn( + "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_publish.yaml)", + comment_call_args[1], + ) + self.assertNotIn("🚀", comment_call_args[1]) + + def test_create_rc_success_next_rc(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag RC1 | status=pending +""" + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") + self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("tag=2.0.0-rc1", call_args[1]) + + self.mock_gh.post_issue_comment.assert_called_once() + comment_call_args = self.mock_gh.post_issue_comment.call_args[0] + self.assertEqual(comment_call_args[0], 123) + self.assertIn( + "**New Release Candidate Tagged!** 🐍🌿", + comment_call_args[1], + ) + self.assertIn( + "- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)", + comment_call_args[1], + ) + self.assertIn( + "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_publish.yaml)", + comment_call_args[1], + ) + self.assertNotIn("🚀", comment_call_args[1]) + + def test_create_rc_gating_on_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [ ] #124 | status=pending +""" + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + + def test_create_rc_with_finished_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [x] #124 | status=done rc=rc0 commit=abcdef12 +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/create_release_branch_test.py b/tests/tools/private/release/create_release_branch_test.py new file mode 100644 index 0000000000..07e3652c85 --- /dev/null +++ b/tests/tools/private/release/create_release_branch_test.py @@ -0,0 +1,149 @@ +import unittest +from unittest.mock import MagicMock + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.create_release_branch import CreateReleaseBranch + + +class CmdCreateReleaseBranchTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + + def test_create_release_branch_success(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.branch_exists.return_value = False + self.mock_git.remote_branch_exists.return_value = False + + # Act + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.checkout.assert_not_called() + self.mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn( + "branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0", + call_args[1], + ) + self.assertIn("commit= abcdef12", call_args[1]) + + def test_create_release_branch_prepare_not_done(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release | status=pending +- [ ] Create Release branch | status=pending +""" + # Act + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_git.fetch.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_create_release_branch_already_checked(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +""" + # Act + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_create_release_branch_already_exists_same_commit(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "abcdef12" + + # Act + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist + + def test_create_release_branch_already_exists_fast_forward(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "oldcommit" + self.mock_git.is_ancestor.return_value = True + + # Act + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) + self.mock_gh.update_issue_body.assert_called_once() + + def test_create_release_branch_already_exists_non_ff(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "othercommit" + self.mock_git.is_ancestor.return_value = False + + # Act + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/git_test.py b/tests/tools/private/release/git_test.py new file mode 100644 index 0000000000..59b5c31989 --- /dev/null +++ b/tests/tools/private/release/git_test.py @@ -0,0 +1,48 @@ +import unittest +from unittest.mock import patch + +from tools.private.release.git import Git + + +class GitCheckoutTest(unittest.TestCase): + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_checkout_simple(self): + self.git.checkout("my-branch") + self.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False + ) + + @patch("tools.private.release.git.Git.branch_exists") + def test_checkout_track_remote_new_branch(self, mock_branch_exists): + mock_branch_exists.return_value = False + + self.git.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + self.mock_run_git.assert_called_once_with( + "checkout", "--track", "origin/my-branch", capture_output=False + ) + + @patch("tools.private.release.git.Git.reset_hard") + @patch("tools.private.release.git.Git.branch_exists") + def test_checkout_track_remote_existing_branch( + self, mock_branch_exists, mock_reset_hard + ): + mock_branch_exists.return_value = True + + self.git.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + self.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False + ) + mock_reset_hard.assert_called_once_with("origin/my-branch") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/prepare_test.py b/tests/tools/private/release/prepare_test.py new file mode 100644 index 0000000000..3d92b131e2 --- /dev/null +++ b/tests/tools/private/release/prepare_test.py @@ -0,0 +1,238 @@ +import unittest +from unittest.mock import MagicMock, patch + +from tests.tools.private.release.release_test_helper import ( + TempDirTestCase, + _mock_git_and_gh, +) +from tools.private.release.gh import ( + MultipleTrackingIssuesError, + NoTrackingIssueError, +) +from tools.private.release.prepare import Prepare + + +class CmdPrepareTest(TempDirTestCase): + def setUp(self): + super().setUp() + _mock_git_and_gh(self) + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", "M foo"] + self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_gh.create_tracking_issue.assert_not_called() + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_git.add_modified_and_deleted.assert_called_once() + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_success_create_issue(self, mock_replace, mock_changelog): + # Arrange + template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + template_file = template_dir / "release_tracking_template.md" + template_file.write_text("dummy template content") + + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", "M foo"] + self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) + self.mock_gh.create_tracking_issue.return_value = 123 + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_gh.create_tracking_issue.assert_called_once_with( + "2.0.0", "dummy template content" + ) + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_git.add_modified_and_deleted.assert_called_once() + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", "M foo"] + self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = ( + MultipleTrackingIssuesError("Multiple open tracking issues") + ) + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_gh.create_tracking_issue.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + self.mock_git.add_modified_and_deleted.assert_not_called() + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_dry_run(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=True) + self.mock_git.status.side_effect = [""] + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_not_called() + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + self.mock_git.fetch.assert_called_once() + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_git.add_modified_and_deleted.assert_not_called() + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_use_associated_pr_from_tracking_issue( + self, mock_replace, mock_changelog + ): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", ""] + self.mock_git.branch_exists.return_value = True + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.get_open_pr.return_value = None + # PR #456 is already associated in the tracking issue + self.mock_gh.get_issue_body.return_value = ( + "- [ ] Prepare Release | status=pending pr=#456" + ) + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True, force=True + ) + self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") + self.mock_gh.create_pr.assert_not_called() # Should NOT create a new PR + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertIn("pr=#456", call_args[1]) + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", ""] + self.mock_git.branch_exists.return_value = True + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.get_open_pr.return_value = None + # No PR associated in the tracking issue + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/789" + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True, force=True + ) + self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertIn("pr=#789", call_args[1]) + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False) + self.mock_git.status.side_effect = ["", ""] + self.mock_git.branch_exists.return_value = True + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_gh.get_open_pr.return_value = { + "number": 456, + "url": "https://github.com/foo/bar/pull/456", + } + self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True, force=True + ) + self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") + self.mock_gh.create_pr.assert_not_called() + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertIn("pr=#456", call_args[1]) + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): + # Arrange + template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + template_file = template_dir / "release_tracking_template.md" + template_file.write_text("dummy template content") + + args = MagicMock(version="2.0.0", issue=None, dry_run=True) + self.mock_git.status.side_effect = [""] + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) + + # Act + result = Prepare(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_not_called() + self.mock_gh.create_tracking_issue.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + self.mock_git.add_modified_and_deleted.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py new file mode 100644 index 0000000000..0ceb7b3210 --- /dev/null +++ b/tests/tools/private/release/process_backports_test.py @@ -0,0 +1,239 @@ +import datetime +import unittest +from unittest.mock import MagicMock, call, patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.process_backports import ProcessBackports + + +class CmdProcessBackportsTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.mock_changelog_news = patch( + "tools.private.release.process_backports.changelog_news" + ).start() + self.mock_replace_version_next = patch( + "tools.private.release.process_backports.replace_version_next" + ).start() + self.addCleanup(patch.stopall) + + def test_process_backports_no_pending(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_body.return_value = "No backports here" + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_git.fetch.assert_not_called() + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_success(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("origin", tags=True, force=True), call("origin")] + ) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_changelog_news.update_changelog.assert_called_once_with( + "2.0.0", "2026-07-01" + ) + self.mock_git.add_modified_and_deleted.assert_called_once() + self.mock_replace_version_next.assert_called_once_with("2.0.0") + self.mock_git.commit.assert_called_once_with( + 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True + ) + self.mock_git.push.assert_called_once_with("origin", "release/2.0") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [x] #124 | status=done rc=rc0 commit= 12345678", call_args[1]) + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_dry_run(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=True) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("origin", tags=True, force=True), call("origin")] + ) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_changelog_news.update_changelog.assert_called_once_with( + "2.0.0", "2026-07-01" + ) + self.mock_git.add_modified_and_deleted.assert_called_once() + self.mock_replace_version_next.assert_called_once_with("2.0.0") + self.mock_git.commit.assert_called_once_with( + 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True + ) + self.mock_git.reset_hard.assert_called_once_with("12345678") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_process_backports_ignored_and_failed_states(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +- [ ] #125 | status=pending +- [ ] #126 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.status = "open-pr" + elif item.pr_ref == "#125": + item.status = "draft-pr" + elif item.pr_ref == "#126": + item.status = "error-closed-pr" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #126 | status=error-closed-pr", call_args[1]) + self.assertNotIn("status=open-pr", call_args[1]) + self.assertNotIn("status=draft-pr", call_args[1]) + self.mock_git.checkout.assert_not_called() + self.mock_git.cherry_pick.assert_not_called() + + def test_process_backports_ignored_error_status(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=error-merge-conflict +- [ ] #125 | status=error-some-other-error +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_gh.get_merge_commits_for_prs.return_value = [] + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_merge_commits_for_prs.assert_not_called() + self.mock_git.checkout.assert_not_called() + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_cherry_pick_failed(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_git.cherry_pick_abort.assert_called_once() + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #124 | status=error-merge-conflict", call_args[1]) + + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_rc_test.py new file mode 100644 index 0000000000..71980dc1fd --- /dev/null +++ b/tests/tools/private/release/promote_rc_test.py @@ -0,0 +1,236 @@ +import unittest +from unittest.mock import MagicMock, call, patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.gh import NoTrackingIssueError +from tools.private.release.promote_rc import PromoteRc + + +class CmdPromoteRcTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + + def test_promote_rc_success(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123, dry_run=False, remote="my-remote") + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_commit_sha.return_value = "abcdef123456" + self.mock_git.tag_exists.return_value = False + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag_exists.assert_called_once_with("2.0.0") + self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") + + # Verify issue update + self.mock_gh.get_issue_body.assert_called_once_with(123) + expected_updated_body = ( + "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + ) + self.mock_gh.update_issue_body.assert_called_once_with( + 123, expected_updated_body + ) + expected_comment = ( + "Version 2.0.0 has been tagged.\n\n" + "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" + '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' + ) + self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) + + def test_promote_rc_resolve_issue_success(self): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=False, remote="my-remote") + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + self.mock_git.get_commit_sha.return_value = "abcdef123456" + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") + self.mock_gh.get_issue_body.assert_called_once_with(123) + expected_updated_body = ( + "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + ) + self.mock_gh.update_issue_body.assert_called_once_with( + 123, expected_updated_body + ) + expected_comment = ( + "Version 2.0.0 has been tagged.\n\n" + "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" + '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' + ) + self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) + + def test_promote_rc_defaults_to_determine_next_version(self): + # Arrange + args = MagicMock(version=None, issue=123, dry_run=False, remote="my-remote") + self.mock_git.get_current_branch.return_value = "release/2.0" + self.mock_git.get_tags.return_value = ["2.0.0"] + self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.tag_exists.return_value = False + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + self.mock_git.get_current_branch.assert_called_once() + self.mock_git.get_tags.assert_called_once() + self.mock_git.get_remote_tags.assert_called_once_with("my-remote") + + self.mock_git.checkout.assert_not_called() + self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0") + self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.1") + + expected_updated_body = ( + "- [x] Tag Final | status=done tag=2.0.1 commit= 12345678" + ) + self.mock_gh.update_issue_body.assert_called_once_with( + 123, expected_updated_body + ) + expected_comment = ( + "Version 2.0.1 has been tagged.\n\n" + "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1\n" + '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.1" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)' + ) + self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) + + @patch("builtins.print") + def test_promote_rc_dry_run_success(self, mock_print): + # Arrange + args = MagicMock(version="2.0.0", issue=123, dry_run=True, remote="my-remote") + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_commit_sha.return_value = "abcdef123456" + self.mock_git.tag_exists.return_value = False + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.tag_exists.assert_called_once_with("2.0.0") + + # Core dry-run assertions: NO modifications + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + self.mock_gh.post_issue_comment.assert_not_called() + + mock_print.assert_has_calls( + [ + call("Verifying tracking issue #123 format..."), + call( + "[DRY RUN] Pre-conditions passed successfully for promoting" + " 2.0.0-rc1 to 2.0.0." + ), + call("[DRY RUN] Would tag commit abcdef12 as 2.0.0"), + call("[DRY RUN] Would push tag 2.0.0 to my-remote"), + call("[DRY RUN] Would update tracking issue #123 checklist"), + call("[DRY RUN] Would post comment to tracking issue #123"), + ] + ) + + def test_promote_rc_tag_already_exists(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123, remote="my-remote") + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = True + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.get_issue_body.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_promote_rc_issue_not_found(self): + # Arrange + args = MagicMock(version="2.0.0", issue=None, remote="my-remote") + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.get_issue_body.assert_not_called() + + def test_promote_rc_issue_malformed(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123, remote="my-remote") + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + self.mock_git.tag_exists.return_value = False + self.mock_git.get_commit_sha.return_value = "abcdef123456" + initial_body = "malformed body" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_promote_rc_no_rc_found(self): + # Arrange + args = MagicMock(version="2.0.0", issue=123, remote="my-remote") + self.mock_git.get_remote_tags.return_value = [] + + # Act + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_not_called() + self.mock_gh.get_issue_body.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py new file mode 100644 index 0000000000..edf5f603f0 --- /dev/null +++ b/tests/tools/private/release/release_issue_test.py @@ -0,0 +1,70 @@ +import unittest + +from tools.private.release.release_issue import ( + format_metadata_line, + parse_metadata_line, +) + + +class ReleaseIssueTest(unittest.TestCase): + def test_parse_metadata_line_spaces(self): + # Test with spaces around '=' + line = "- [ ] Tag Final | tag = 2.0.0 commit = abcdef12" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, + "original_line": line, + } + self.assertEqual(parse_metadata_line(line), expected) + + # Test with spaces after '=' + line = "- [ ] Tag Final | tag= 2.0.0 commit= abcdef12" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, + "original_line": line, + } + self.assertEqual(parse_metadata_line(line), expected) + + # Test with standard format (no spaces) + line = "- [ ] Tag Final | tag=2.0.0 commit=abcdef12" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, + "original_line": line, + } + self.assertEqual(parse_metadata_line(line), expected) + + # Test with no metadata + line = "- [ ] Tag Final" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {}, + "original_line": line, + } + self.assertEqual(parse_metadata_line(line), expected) + + def test_format_metadata_line(self): + # Test with commit metadata (should have space) + metadata = {"status": "done", "tag": "2.0.0", "commit": "abcdef12"} + expected = "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + self.assertEqual(format_metadata_line(True, "Tag Final", metadata), expected) + + # Test with other metadata (should not have space) + metadata = {"status": "done", "pr": "#122"} + expected = "- [x] Prepare Release | status=done pr=#122" + self.assertEqual( + format_metadata_line(True, "Prepare Release", metadata), expected + ) + + # Test with no metadata + expected = "- [ ] Tag Final" + self.assertEqual(format_metadata_line(False, "Tag Final", {}), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 2a752ef1cc..c7ba1b17b7 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -1,516 +1,9 @@ -import datetime -import os -import pathlib -import shutil -import tempfile import unittest -from unittest.mock import MagicMock, call, patch -from tools.private.release import changelog_news, release as releaser, utils -from tools.private.release.create_rc import CreateRc -from tools.private.release.create_release_branch import CreateReleaseBranch -from tools.private.release.gh import ( - MultipleTrackingIssuesError, - NoTrackingIssueError, -) -from tools.private.release.git import Git -from tools.private.release.prepare import Prepare -from tools.private.release.process_backports import ProcessBackports -from tools.private.release.promote_rc import PromoteRc +from tools.private.release import release as releaser -def _mock_git_and_gh(test_case): - mock_git = MagicMock() - mock_gh = MagicMock() - test_case.mock_git = mock_git - test_case.mock_gh = mock_gh - - # Mock Git inside utils.py since it instantiates it locally - patch("tools.private.release.utils.Git", return_value=mock_git).start() - - mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError - mock_gh.NoTrackingIssueError = NoTrackingIssueError - - test_case.addCleanup(patch.stopall) - - # Apply safe defaults - mock_git.get_current_branch.return_value = None - mock_git.get_tags.return_value = [] - mock_git.get_remote_tags.return_value = [] - - mock_git.status.return_value = "" - mock_git.branch_exists.return_value = False - mock_git.tag_exists.return_value = False - mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") - mock_gh.get_open_pr.return_value = None - - -class TempDirTestCase(unittest.TestCase): - def setUp(self): - self.tmpdir = pathlib.Path(tempfile.mkdtemp()) - self.original_cwd = os.getcwd() - self.addCleanup(shutil.rmtree, self.tmpdir) - os.chdir(self.tmpdir) - self.addCleanup(os.chdir, self.original_cwd) - - -class ReleaserTest(TempDirTestCase): - def test_update_changelog_with_news(self): - # Arrange - changelog = """# Changelog - -{#unreleased} -## Unreleased - -[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased - -{#unreleased-removed} -### Removed -* Nothing removed. - -{#unreleased-changed} -### Changed -* Nothing changed. - -{#unreleased-fixed} -### Fixed -* Nothing fixed. - -{#unreleased-added} -### Added -* Nothing added. - -{#v2-0-2} -## [2.0.2] - 2026-05-14 - -[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 - -{#v2-0-2-added} -### Added -* (toolchains) Some older change. -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Create news files - (news_dir / "123.fixed.md").write_text("Fixed a bug in the compiler") - # Test that it handles prefixing "* " if not present - (news_dir / "456.added.md").write_text("* Added a new feature for Python 3.13") - # Empty file should be ignored - (news_dir / "789.changed.md").write_text("") - # Invalid name should be ignored - (news_dir / "invalid_name.md").write_text("Should be ignored") - - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Assert - # 1. News files matching the pattern should be deleted (even empty ones) - self.assertFalse((news_dir / "123.fixed.md").exists()) - self.assertFalse((news_dir / "456.added.md").exists()) - self.assertFalse((news_dir / "789.changed.md").exists()) - # Invalid name does not match pattern -> NOT deleted - self.assertTrue((news_dir / "invalid_name.md").exists()) - - new_content = changelog_path.read_text() - - # 2. A fresh active Unreleased section should be present - self.assertIn("{#unreleased}", new_content) - self.assertIn("## Unreleased", new_content) - self.assertIn( - "Unreleased changes are tracked as individual files in the [news/](./news)\n" - "directory, or view the [latest generated\n" - "changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).", - new_content, - ) - - # 3. The new release section should be present - self.assertIn("{#v3-0-0}", new_content) - self.assertIn("## [3.0.0] - 2026-06-16", new_content) - self.assertIn( - "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", - new_content, - ) - - # 4. Correct categories and content - self.assertIn( - "{#v3-0-0-fixed}\n### Fixed\n* Fixed a bug in the compiler", new_content - ) - self.assertIn( - "{#v3-0-0-added}\n### Added\n* Added a new feature for Python 3.13", - new_content, - ) - - # 5. Omitted categories should NOT be present in the new release - self.assertNotIn("{#v3-0-0-removed}", new_content) - self.assertNotIn("{#v3-0-0-changed}", new_content) - - # 6. Old release should still be there - self.assertIn("{#v2-0-2}", new_content) - self.assertIn("## [2.0.2] - 2026-05-14", new_content) - - def test_update_changelog_sorting(self): - # Arrange - changelog = """# Changelog - -{#unreleased} -## Unreleased - -[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased - -Unreleased changes are tracked as individual files in the [news/](./news) -directory, or view the [latest generated -changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). - -{#v2-0-2} -## [2.0.2] - 2026-05-14 - -[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 - -{#v2-0-2-added} -### Added -* (toolchains) Some older change. -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Create news files with different sub-categories and some without - (news_dir / "1.fixed.md").write_text("* (zebra) Zebra fix") - (news_dir / "2.fixed.md").write_text("* (apple) Apple fix") - (news_dir / "3.fixed.md").write_text("No subcategory B") - (news_dir / "4.fixed.md").write_text("* (apple) Another apple fix") - (news_dir / "5.fixed.md").write_text("No subcategory A") - - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Assert - new_content = changelog_path.read_text() - - # Expected order in Fixed section: - # 1. No subcategory A - # 2. No subcategory B - # 3. (apple) Another apple fix - # 4. (apple) Apple fix - # 5. (zebra) Zebra fix - - expected_fixed_section = ( - "### Fixed\n" - "* No subcategory A\n" - "* No subcategory B\n" - "* (apple) Another apple fix\n" - "* (apple) Apple fix\n" - "* (zebra) Zebra fix\n" - ) - - self.assertIn(expected_fixed_section, new_content) - - def test_update_changelog_read_failure(self): - # Arrange - original_read_text = pathlib.Path.read_text - - with patch("pathlib.Path.read_text", autospec=True) as mock_read_text: - - def side_effect(path_self, *args, **kwargs): - if "bad_file.fixed.md" in str(path_self): - raise IOError("Simulated read error") - return original_read_text(path_self, *args, **kwargs) - - mock_read_text.side_effect = side_effect - - changelog = """# Changelog - -{#unreleased} -## Unreleased - -[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased - -Unreleased changes are tracked as individual files in the [news/](./news) -directory, or view the [latest generated -changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). - -{#v2-0-2} -## [2.0.2] - 2026-05-14 - -[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 - -{#v2-0-2-added} -### Added -* (toolchains) Some older change. -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Create the bad file (must exist so it is found by iterdir) - bad_file = news_dir / "bad_file.fixed.md" - bad_file.write_text("some content that won't be read") - - # Create a good file too - good_file = news_dir / "good_file.fixed.md" - good_file.write_text("* (sub) Good fix") - - # Act & Assert - # It should raise IOError - with self.assertRaises(IOError): - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Both files should still exist (no deletion on failure!) - self.assertTrue(bad_file.exists()) - self.assertTrue(good_file.exists()) - - # Changelog should not be modified - new_content = changelog_path.read_text() - self.assertEqual(changelog, new_content) - - def test_update_changelog_merge_existing(self): - # Arrange - changelog = """# Changelog - -{#unreleased} -## Unreleased - -[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased - -Unreleased changes are tracked as individual files in the [news/](./news) -directory, or view the [latest generated -changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). - -{#v2-0-3} -## [2.0.3] - 2026-06-15 - -[2.0.3]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.3 - -{#v2-0-3-fixed} -### Fixed -* (pypi) Old fix - multi-line detail - * nested bullet item -* (pypi) Z old fix -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Create news files to merge - # 1. New fix in same category (should merge and sort) - (news_dir / "1.fixed.md").write_text("(pypi) New fix") - # 2. New entry in new category (should create category) - (news_dir / "2.added.md").write_text("(toolchains) New feature") - - # Act - changelog_news.update_changelog( - "2.0.3", - "2026-06-15", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Assert - # News files should be deleted - self.assertFalse((news_dir / "1.fixed.md").exists()) - self.assertFalse((news_dir / "2.added.md").exists()) - - new_content = changelog_path.read_text() - - # Expected merged and sorted Fixed section: - # 1. (pypi) New fix (New < Old) - # 2. (pypi) Old fix (with its multi-line detail!) - # 3. (pypi) Z old fix - expected_fixed_section = ( - "### Fixed\n" - "* (pypi) New fix\n" - "* (pypi) Old fix\n" - " multi-line detail\n" - " * nested bullet item\n" - "* (pypi) Z old fix\n" - ) - self.assertIn(expected_fixed_section, new_content) - - # Expected created Added section: - expected_added_section = "### Added\n* (toolchains) New feature\n" - self.assertIn(expected_added_section, new_content) - - # Active Unreleased section should NOT be touched (should still be empty/pointing to news) - self.assertIn("Unreleased changes are tracked as individual files", new_content) - - def test_update_changelog_does_not_leak(self): - # Arrange - changelog = """# Changelog - -{#unreleased} -## Unreleased - -[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased - -Unreleased changes are tracked as individual files in the [news/](./news) -directory, or view the [latest generated -changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). - -{#v2-0-2} -## [2.0.2] - 2026-05-14 - -[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 - -This release body mentions the word unreleased and {#unreleased} anchor to test leaks. -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - (news_dir / "1.fixed.md").write_text("Some fix") - - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Assert - new_content = changelog_path.read_text() - - # The 2.0.2 body should NOT be modified - self.assertIn( - "This release body mentions the word unreleased and {#unreleased} anchor to test leaks.", - new_content, - ) - - def test_update_changelog_empty_news(self): - # Arrange - changelog = """# Changelog - -{#unreleased} -## Unreleased - -[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased - -Unreleased changes are tracked as individual files in the [news/](./news) -directory, or view the [latest generated -changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). - -{#v2-0-2} -## [2.0.2] - 2026-05-14 - -[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 - -{#v2-0-2-added} -### Added -* (toolchains) Some older change. -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Assert - new_content = changelog_path.read_text() - - # The new release section should be present and contain "No notable changes." - self.assertIn("{#v3-0-0}", new_content) - self.assertIn("## [3.0.0] - 2026-06-16", new_content) - self.assertIn( - "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", - new_content, - ) - self.assertIn("No notable changes.", new_content) - - # Verify that we didn't accidentally create any categories - self.assertNotIn("{#v3-0-0-fixed}", new_content) - self.assertNotIn("{#v3-0-0-added}", new_content) - - def test_replace_version_next(self): - # Arrange - mock_file_content = """ -:::{versionadded} VERSION_NEXT_FEATURE -blabla -::: - -:::{versionchanged} VERSION_NEXT_PATCH -blabla -::: -""" - (self.tmpdir / "mock_file.bzl").write_text(mock_file_content) - - utils.replace_version_next("0.28.0") - - new_content = (self.tmpdir / "mock_file.bzl").read_text() - - self.assertIn(":::{versionadded} 0.28.0", new_content) - self.assertIn(":::{versionadded} 0.28.0", new_content) - self.assertNotIn("VERSION_NEXT_FEATURE", new_content) - self.assertNotIn("VERSION_NEXT_PATCH", new_content) - - def test_replace_version_next_excludes_bazel_dirs(self): - # Arrange - mock_file_content = """ -:::{versionadded} VERSION_NEXT_FEATURE -blabla -::: -""" - bazel_dir = self.tmpdir / "bazel-rules_python" - bazel_dir.mkdir() - (bazel_dir / "mock_file.bzl").write_text(mock_file_content) - - tools_dir = self.tmpdir / "tools" / "private" / "release" - tools_dir.mkdir(parents=True) - (tools_dir / "mock_file.bzl").write_text(mock_file_content) - - tests_dir = self.tmpdir / "tests" / "tools" / "private" / "release" - tests_dir.mkdir(parents=True) - (tests_dir / "mock_file.bzl").write_text(mock_file_content) - - version = "0.28.0" - - # Act - utils.replace_version_next(version) - - # Assert - new_content = (bazel_dir / "mock_file.bzl").read_text() - self.assertIn("VERSION_NEXT_FEATURE", new_content) - - new_content = (tools_dir / "mock_file.bzl").read_text() - self.assertIn("VERSION_NEXT_FEATURE", new_content) - - new_content = (tests_dir / "mock_file.bzl").read_text() - self.assertIn("VERSION_NEXT_FEATURE", new_content) - +class ReleaseCLITest(unittest.TestCase): def test_valid_version(self): # These should not raise an exception releaser.create_parser().parse_args(["prepare", "0.28.0"]) @@ -528,1199 +21,5 @@ def test_invalid_version(self): releaser.create_parser().parse_args(["prepare", "a.b.c"]) -class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_success(self, mock_get_tags): - mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] - self.assertEqual(utils.get_latest_version(), "1.0.0") - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_rc_is_latest(self, mock_get_tags): - mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] - with self.assertRaisesRegex( - ValueError, "The latest version is a pre-release version: 1.1.0rc0" - ): - utils.get_latest_version() - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_no_tags(self, mock_get_tags): - mock_get_tags.return_value = [] - with self.assertRaisesRegex( - RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." - ): - utils.get_latest_version() - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_no_matching_tags(self, mock_get_tags): - mock_get_tags.return_value = ["v1.0", "latest"] - with self.assertRaisesRegex( - RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." - ): - utils.get_latest_version() - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_only_rc_tags(self, mock_get_tags): - mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] - with self.assertRaisesRegex( - ValueError, "The latest version is a pre-release version: 1.1.0rc0" - ): - utils.get_latest_version() - - -class GetLatestRcTagTest(unittest.TestCase): - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_no_tags(self, mock_get_tags): - mock_get_tags.return_value = [] - self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): - mock_get_tags.return_value = ["1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0"] - self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_success(self, mock_get_tags): - mock_get_tags.return_value = [ - "2.0.0-rc0", - "2.0.0-rc2", - "2.0.0-rc1", - "2.1.0-rc0", - ] - self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): - mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] - self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") - - @patch("tools.private.release.git.Git.get_remote_tags") - def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): - mock_get_remote_tags.return_value = [ - "2.0.0-rc0", - "2.0.0-rc2", - "2.0.0-rc1", - "2.1.0-rc0", - ] - self.assertEqual(utils.get_latest_rc_tag("2.0.0", remote="origin"), "2.0.0-rc2") - mock_get_remote_tags.assert_called_once_with("origin") - - -class DetermineNextVersionTest(TempDirTestCase): - def setUp(self): - super().setUp() - self.mock_get_latest_version = patch( - "tools.private.release.utils.get_latest_version" - ).start() - self.mock_get_current_branch = patch( - "tools.private.release.git.Git.get_current_branch" - ).start() - self.mock_get_current_branch.return_value = "main" - self.addCleanup(patch.stopall) - - def test_no_markers(self): - (self.tmpdir / "mock_file.bzl").write_text("no markers here") - self.mock_get_latest_version.return_value = "1.2.3" - - next_version = utils.determine_next_version() - - self.assertEqual(next_version, "1.2.4") - - def test_only_patch(self): - (self.tmpdir / "mock_file.bzl").write_text( - ":::{versionchanged} VERSION_NEXT_PATCH" - ) - self.mock_get_latest_version.return_value = "1.2.3" - - next_version = utils.determine_next_version() - - self.assertEqual(next_version, "1.2.4") - - def test_only_feature(self): - (self.tmpdir / "mock_file.bzl").write_text( - ":::{versionadded} VERSION_NEXT_FEATURE" - ) - self.mock_get_latest_version.return_value = "1.2.3" - - next_version = utils.determine_next_version() - - self.assertEqual(next_version, "1.3.0") - - def test_both_markers(self): - (self.tmpdir / "mock_file_patch.bzl").write_text( - ":::{versionchanged} VERSION_NEXT_PATCH" - ) - (self.tmpdir / "mock_file_feature.bzl").write_text( - ":::{versionadded} VERSION_NEXT_FEATURE" - ) - self.mock_get_latest_version.return_value = "1.2.3" - - next_version = utils.determine_next_version() - - self.assertEqual(next_version, "1.3.0") - - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_with_existing_tags( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.37" - mock_get_tags.return_value = ["0.37.0", "0.37.1", "0.36.0"] - - next_version = utils.determine_next_version() - - self.assertEqual(next_version, "0.37.2") - - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_no_tags( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.38" - mock_get_tags.return_value = ["0.37.0"] # No 0.38.x tags - - next_version = utils.determine_next_version() - - self.assertEqual(next_version, "0.38.0") - - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_with_active_rc( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.37" - # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet - mock_get_tags.return_value = ["0.37.0-rc0", "0.37.0-rc1", "0.36.0"] - - next_version = utils.determine_next_version() - - # Should target 0.37.0, not 0.37.1 - self.assertEqual(next_version, "0.37.0") - - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.37" - # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet) - mock_get_tags.return_value = ["0.37.0", "0.37.1-rc0", "0.36.0"] - - next_version = utils.determine_next_version() - - # Should target 0.37.1, not 0.37.2 - self.assertEqual(next_version, "0.37.1") - - @patch("tools.private.release.git.Git.get_current_branch") - def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): - mock_get_branch.return_value = "main" - # Should fallback to default behavior (which uses mock_get_latest_version from setUp) - self.mock_get_latest_version.return_value = "1.2.3" - (self.tmpdir / "mock_file.bzl").write_text("no markers here") - - next_version = utils.determine_next_version() - - self.assertEqual(next_version, "1.2.4") - - -class CmdPrepareTest(TempDirTestCase): - def setUp(self): - super().setUp() - _mock_git_and_gh(self) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", "M foo"] - self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) - self.mock_git.add_modified_and_deleted.assert_called_once() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_success_create_issue(self, mock_replace, mock_changelog): - # Arrange - template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" - template_dir.mkdir(parents=True, exist_ok=True) - template_file = template_dir / "release_tracking_template.md" - template_file.write_text("dummy template content") - - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", "M foo"] - self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( - "Not found" - ) - self.mock_gh.create_tracking_issue.return_value = 123 - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_gh.create_tracking_issue.assert_called_once_with( - "2.0.0", "dummy template content" - ) - self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) - self.mock_git.add_modified_and_deleted.assert_called_once() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", "M foo"] - self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ( - MultipleTrackingIssuesError("Multiple open tracking issues") - ) - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_not_called() - self.mock_git.add_modified_and_deleted.assert_not_called() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_dry_run(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=True) - self.mock_git.status.side_effect = [""] - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_not_called() - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.create_pr.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - self.mock_git.fetch.assert_called_once() - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_git.add_modified_and_deleted.assert_not_called() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_use_associated_pr_from_tracking_issue( - self, mock_replace, mock_changelog - ): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", ""] - self.mock_git.branch_exists.return_value = True - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.get_open_pr.return_value = None - # PR #456 is already associated in the tracking issue - self.mock_gh.get_issue_body.return_value = ( - "- [ ] Prepare Release | status=pending pr=#456" - ) - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True, force=True - ) - self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") - self.mock_gh.create_pr.assert_not_called() # Should NOT create a new PR - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertIn("pr=#456", call_args[1]) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", ""] - self.mock_git.branch_exists.return_value = True - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.get_open_pr.return_value = None - # No PR associated in the tracking issue - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/789" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True, force=True - ) - self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") - self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertIn("pr=#789", call_args[1]) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", ""] - self.mock_git.branch_exists.return_value = True - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.get_open_pr.return_value = { - "number": 456, - "url": "https://github.com/foo/bar/pull/456", - } - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True, force=True - ) - self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") - self.mock_gh.create_pr.assert_not_called() - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertIn("pr=#456", call_args[1]) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): - # Arrange - template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" - template_dir.mkdir(parents=True, exist_ok=True) - template_file = template_dir / "release_tracking_template.md" - template_file.write_text("dummy template content") - - args = MagicMock(version="2.0.0", issue=None, dry_run=True) - self.mock_git.status.side_effect = [""] - self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( - "Not found" - ) - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_not_called() - self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_not_called() - self.mock_git.add_modified_and_deleted.assert_not_called() - - -class CmdCreateRcTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - - def test_create_rc_success_first_rc(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -- [ ] Tag RC0 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [call("my-remote"), call("my-remote", tags=True, force=True)] - ) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") - self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("tag=2.0.0-rc0", call_args[1]) - self.assertIn("commit=12345678", call_args[1]) - - self.mock_gh.post_issue_comment.assert_called_once() - comment_call_args = self.mock_gh.post_issue_comment.call_args[0] - self.assertEqual(comment_call_args[0], 123) - self.assertIn( - "**New Release Candidate Tagged!** 🐍🌿", - comment_call_args[1], - ) - self.assertIn( - "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)", - comment_call_args[1], - ) - self.assertIn( - "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", - comment_call_args[1], - ) - self.assertIn( - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", - comment_call_args[1], - ) - self.assertIn( - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", - comment_call_args[1], - ) - self.assertNotIn("🚀", comment_call_args[1]) - - def test_create_rc_success_next_rc(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 -- [ ] Tag RC1 | status=pending -""" - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [call("my-remote"), call("my-remote", tags=True, force=True)] - ) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") - self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("tag=2.0.0-rc1", call_args[1]) - - self.mock_gh.post_issue_comment.assert_called_once() - comment_call_args = self.mock_gh.post_issue_comment.call_args[0] - self.assertEqual(comment_call_args[0], 123) - self.assertIn( - "**New Release Candidate Tagged!** 🐍🌿", - comment_call_args[1], - ) - self.assertIn( - "- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)", - comment_call_args[1], - ) - self.assertIn( - "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", - comment_call_args[1], - ) - self.assertIn( - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", - comment_call_args[1], - ) - self.assertIn( - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", - comment_call_args[1], - ) - self.assertNotIn("🚀", comment_call_args[1]) - - def test_create_rc_gating_on_backports(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -- [ ] Tag RC0 | status=pending - -## Backports -- [ ] #124 | status=pending -""" - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - - def test_create_rc_with_finished_backports(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -- [ ] Tag RC0 | status=pending - -## Backports -- [x] #124 | status=done rc=rc0 commit=abcdef12 -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") - - -class CmdPromoteRcTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - - def test_promote_rc_success(self): - # Arrange - args = MagicMock(version="2.0.0", issue=123, dry_run=False, remote="my-remote") - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] - self.mock_git.get_commit_sha.return_value = "abcdef123456" - self.mock_git.tag_exists.return_value = False - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") - self.mock_git.checkout.assert_not_called() - self.mock_git.tag_exists.assert_called_once_with("2.0.0") - self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") - - # Verify issue update - self.mock_gh.get_issue_body.assert_called_once_with(123) - expected_updated_body = ( - "- [x] Tag Final | status=done tag=2.0.0 commit=abcdef12" - ) - self.mock_gh.update_issue_body.assert_called_once_with( - 123, expected_updated_body - ) - expected_comment = ( - "Version 2.0.0 has been tagged.\n\n" - "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" - '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' - ) - self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - - def test_promote_rc_resolve_issue_success(self): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False, remote="my-remote") - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_git.get_commit_sha.return_value = "abcdef123456" - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") - self.mock_gh.get_issue_body.assert_called_once_with(123) - expected_updated_body = ( - "- [x] Tag Final | status=done tag=2.0.0 commit=abcdef12" - ) - self.mock_gh.update_issue_body.assert_called_once_with( - 123, expected_updated_body - ) - expected_comment = ( - "Version 2.0.0 has been tagged.\n\n" - "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" - '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' - ) - self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - - def test_promote_rc_defaults_to_determine_next_version(self): - # Arrange - args = MagicMock(version=None, issue=123, dry_run=False, remote="my-remote") - self.mock_git.get_current_branch.return_value = "release/2.0" - self.mock_git.get_tags.return_value = ["2.0.0"] - self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"] - self.mock_git.get_commit_sha.return_value = "12345678" - self.mock_git.tag_exists.return_value = False - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - self.mock_git.get_current_branch.assert_called_once() - self.mock_git.get_tags.assert_called_once() - self.mock_git.get_remote_tags.assert_called_once_with("my-remote") - - self.mock_git.checkout.assert_not_called() - self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0") - self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.1") - - expected_updated_body = ( - "- [x] Tag Final | status=done tag=2.0.1 commit=12345678" - ) - self.mock_gh.update_issue_body.assert_called_once_with( - 123, expected_updated_body - ) - expected_comment = ( - "Version 2.0.1 has been tagged.\n\n" - "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1\n" - '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.1" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)' - ) - self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - - def test_promote_rc_dry_run_success(self): - # Arrange - args = MagicMock(version="2.0.0", issue=123, dry_run=True, remote="my-remote") - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] - self.mock_git.get_commit_sha.return_value = "abcdef123456" - self.mock_git.tag_exists.return_value = False - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") - self.mock_git.tag_exists.assert_called_once_with("2.0.0") - - # Core dry-run assertions: NO modifications - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - self.mock_gh.post_issue_comment.assert_not_called() - - def test_promote_rc_tag_already_exists(self): - # Arrange - args = MagicMock(version="2.0.0", issue=123, remote="my-remote") - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = True - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.get_issue_body.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_promote_rc_issue_not_found(self): - # Arrange - args = MagicMock(version="2.0.0", issue=None, remote="my-remote") - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( - "Not found" - ) - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.get_issue_body.assert_not_called() - - def test_promote_rc_issue_malformed(self): - # Arrange - args = MagicMock(version="2.0.0", issue=123, remote="my-remote") - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = False - self.mock_git.get_commit_sha.return_value = "abcdef123456" - initial_body = "malformed body" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.get_issue_body.assert_called_once_with(123) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_promote_rc_no_rc_found(self): - # Arrange - args = MagicMock(version="2.0.0", issue=123, remote="my-remote") - self.mock_git.get_remote_tags.return_value = [] - - # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_gh.get_issue_body.assert_not_called() - - -class CmdCreateReleaseBranchTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - - def test_create_release_branch_success(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [ ] Create Release branch | status=pending -""" - self.mock_git.branch_exists.return_value = False - self.mock_git.remote_branch_exists.return_value = False - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.checkout.assert_not_called() - self.mock_git.push.assert_called_once_with( - "my-remote", "abcdef12:refs/heads/release/2.0" - ) - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn( - "branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0", - call_args[1], - ) - self.assertIn("commit=abcdef12", call_args[1]) - - def test_create_release_branch_prepare_not_done(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release | status=pending -- [ ] Create Release branch | status=pending -""" - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.fetch.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_create_release_branch_already_checked(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -""" - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_create_release_branch_already_exists_same_commit(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [ ] Create Release branch | status=pending -""" - self.mock_git.remote_branch_exists.return_value = True - self.mock_git.get_commit_sha.return_value = "abcdef12" - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist - - def test_create_release_branch_already_exists_fast_forward(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [ ] Create Release branch | status=pending -""" - self.mock_git.remote_branch_exists.return_value = True - self.mock_git.get_commit_sha.return_value = "oldcommit" - self.mock_git.is_ancestor.return_value = True - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.push.assert_called_once_with( - "my-remote", "abcdef12:refs/heads/release/2.0" - ) - self.mock_gh.update_issue_body.assert_called_once() - - def test_create_release_branch_already_exists_non_ff(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [ ] Create Release branch | status=pending -""" - self.mock_git.remote_branch_exists.return_value = True - self.mock_git.get_commit_sha.return_value = "othercommit" - self.mock_git.is_ancestor.return_value = False - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - -class CmdProcessBackportsTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - self.mock_changelog_news = patch( - "tools.private.release.process_backports.changelog_news" - ).start() - self.addCleanup(patch.stopall) - - def test_process_backports_no_pending(self): - args = MagicMock(issue=123, remote="origin", dry_run=False) - self.mock_gh.get_issue_body.return_value = "No backports here" - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.get_issue_body.assert_called_once_with(123) - self.mock_git.fetch.assert_not_called() - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_success(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = MagicMock(issue=123, remote="origin", dry_run=False) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release -- [ ] Create Release branch - -## Backports -- [ ] #124 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.get_commit_sha.return_value = "12345678" - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [call("origin", tags=True, force=True), call("origin")] - ) - self.mock_git.checkout.assert_called_once_with( - "release/2.0", track_remote="origin" - ) - self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_changelog_news.update_changelog.assert_called_once_with( - "2.0.0", "2026-07-01" - ) - self.mock_git.add.assert_called_once_with("CHANGELOG.md", "news/") - self.mock_git.commit.assert_called_once_with( - 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True - ) - self.mock_git.push.assert_called_once_with("origin", "release/2.0") - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("- [x] #124 | status=done rc=rc0 commit=12345678", call_args[1]) - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_dry_run(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = MagicMock(issue=123, remote="origin", dry_run=True) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release -- [ ] Create Release branch - -## Backports -- [ ] #124 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.get_commit_sha.return_value = "12345678" - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [call("origin", tags=True, force=True), call("origin")] - ) - self.mock_git.checkout.assert_called_once_with( - "release/2.0", track_remote="origin" - ) - self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_changelog_news.update_changelog.assert_called_once_with( - "2.0.0", "2026-07-01" - ) - self.mock_git.commit.assert_called_once_with( - 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True - ) - self.mock_git.reset_hard.assert_called_once_with("12345678") - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_process_backports_ignored_and_failed_states(self): - args = MagicMock(issue=123, remote="origin", dry_run=False) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release -- [ ] Create Release branch - -## Backports -- [ ] #124 | status=pending -- [ ] #125 | status=pending -- [ ] #126 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.status = "open-pr" - elif item.pr_ref == "#125": - item.status = "draft-pr" - elif item.pr_ref == "#126": - item.status = "error-closed-pr" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("- [ ] #126 | status=error-closed-pr", call_args[1]) - self.assertNotIn("status=open-pr", call_args[1]) - self.assertNotIn("status=draft-pr", call_args[1]) - self.mock_git.checkout.assert_not_called() - self.mock_git.cherry_pick.assert_not_called() - - def test_process_backports_ignored_error_status(self): - args = MagicMock(issue=123, remote="origin", dry_run=False) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release -- [ ] Create Release branch - -## Backports -- [ ] #124 | status=error-merge-conflict -- [ ] #125 | status=error-some-other-error -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_gh.get_merge_commits_for_prs.return_value = [] - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.get_merge_commits_for_prs.assert_not_called() - self.mock_git.checkout.assert_not_called() - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_cherry_pick_failed(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = MagicMock(issue=123, remote="origin", dry_run=False) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release -- [ ] Create Release branch - -## Backports -- [ ] #124 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_git.checkout.assert_called_once_with( - "release/2.0", track_remote="origin" - ) - self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_git.cherry_pick_abort.assert_called_once() - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("- [ ] #124 | status=error-merge-conflict", call_args[1]) - - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_not_called() - - -class GitCheckoutTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_checkout_simple(self): - self.git.checkout("my-branch") - self.mock_run_git.assert_called_once_with( - "checkout", "my-branch", capture_output=False - ) - - @patch("tools.private.release.git.Git.branch_exists") - def test_checkout_track_remote_new_branch(self, mock_branch_exists): - mock_branch_exists.return_value = False - - self.git.checkout("my-branch", track_remote="origin") - - mock_branch_exists.assert_called_once_with("my-branch") - self.mock_run_git.assert_called_once_with( - "checkout", "--track", "origin/my-branch", capture_output=False - ) - - @patch("tools.private.release.git.Git.reset_hard") - @patch("tools.private.release.git.Git.branch_exists") - def test_checkout_track_remote_existing_branch( - self, mock_branch_exists, mock_reset_hard - ): - mock_branch_exists.return_value = True - - self.git.checkout("my-branch", track_remote="origin") - - mock_branch_exists.assert_called_once_with("my-branch") - self.mock_run_git.assert_called_once_with( - "checkout", "my-branch", capture_output=False - ) - mock_reset_hard.assert_called_once_with("origin/my-branch") - - if __name__ == "__main__": unittest.main() diff --git a/tests/tools/private/release/release_test_helper.py b/tests/tools/private/release/release_test_helper.py new file mode 100644 index 0000000000..c35cd0fa40 --- /dev/null +++ b/tests/tools/private/release/release_test_helper.py @@ -0,0 +1,46 @@ +import os +import pathlib +import shutil +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from tools.private.release.gh import ( + MultipleTrackingIssuesError, + NoTrackingIssueError, +) + + +def _mock_git_and_gh(test_case): + mock_git = MagicMock() + mock_gh = MagicMock() + test_case.mock_git = mock_git + test_case.mock_gh = mock_gh + + # Mock Git inside utils.py since it instantiates it locally + patch("tools.private.release.utils.Git", return_value=mock_git).start() + + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError + mock_gh.NoTrackingIssueError = NoTrackingIssueError + + test_case.addCleanup(patch.stopall) + + # Apply safe defaults + mock_git.get_current_branch.return_value = None + mock_git.get_tags.return_value = [] + mock_git.get_remote_tags.return_value = [] + + mock_git.status.return_value = "" + mock_git.branch_exists.return_value = False + mock_git.tag_exists.return_value = False + mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") + mock_gh.get_open_pr.return_value = None + + +class TempDirTestCase(unittest.TestCase): + def setUp(self): + self.tmpdir = pathlib.Path(tempfile.mkdtemp()) + self.original_cwd = os.getcwd() + self.addCleanup(shutil.rmtree, self.tmpdir) + os.chdir(self.tmpdir) + self.addCleanup(os.chdir, self.original_cwd) diff --git a/tests/tools/private/release/utils_test.py b/tests/tools/private/release/utils_test.py new file mode 100644 index 0000000000..796bab1619 --- /dev/null +++ b/tests/tools/private/release/utils_test.py @@ -0,0 +1,266 @@ +import unittest +from unittest.mock import patch + +from tests.tools.private.release.release_test_helper import TempDirTestCase +from tools.private.release import utils + + +class GetLatestVersionTest(unittest.TestCase): + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_version_success(self, mock_get_tags): + mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] + self.assertEqual(utils.get_latest_version(), "1.0.0") + + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_version_rc_is_latest(self, mock_get_tags): + mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] + with self.assertRaisesRegex( + ValueError, "The latest version is a pre-release version: 1.1.0rc0" + ): + utils.get_latest_version() + + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_version_no_tags(self, mock_get_tags): + mock_get_tags.return_value = [] + with self.assertRaisesRegex( + RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." + ): + utils.get_latest_version() + + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_version_no_matching_tags(self, mock_get_tags): + mock_get_tags.return_value = ["v1.0", "latest"] + with self.assertRaisesRegex( + RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." + ): + utils.get_latest_version() + + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_version_only_rc_tags(self, mock_get_tags): + mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] + with self.assertRaisesRegex( + ValueError, "The latest version is a pre-release version: 1.1.0rc0" + ): + utils.get_latest_version() + + +class GetLatestRcTagTest(unittest.TestCase): + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_rc_tag_no_tags(self, mock_get_tags): + mock_get_tags.return_value = [] + self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) + + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): + mock_get_tags.return_value = [ + "1.0.0", + "2.0.0", + "v2.0.0-rc0", + "2.1.0-rc0", + ] + self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) + + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_rc_tag_success(self, mock_get_tags): + mock_get_tags.return_value = [ + "2.0.0-rc0", + "2.0.0-rc2", + "2.0.0-rc1", + "2.1.0-rc0", + ] + self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") + + @patch("tools.private.release.git.Git.get_tags") + def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): + mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] + self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") + + @patch("tools.private.release.git.Git.get_remote_tags") + def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): + mock_get_remote_tags.return_value = [ + "2.0.0-rc0", + "2.0.0-rc2", + "2.0.0-rc1", + "2.1.0-rc0", + ] + self.assertEqual(utils.get_latest_rc_tag("2.0.0", remote="origin"), "2.0.0-rc2") + mock_get_remote_tags.assert_called_once_with("origin") + + +class DetermineNextVersionTest(TempDirTestCase): + def setUp(self): + super().setUp() + self.mock_get_latest_version = patch( + "tools.private.release.utils.get_latest_version" + ).start() + self.mock_get_current_branch = patch( + "tools.private.release.git.Git.get_current_branch" + ).start() + self.mock_get_current_branch.return_value = "main" + self.addCleanup(patch.stopall) + + def test_no_markers(self): + (self.tmpdir / "mock_file.bzl").write_text("no markers here") + self.mock_get_latest_version.return_value = "1.2.3" + + next_version = utils.determine_next_version() + + self.assertEqual(next_version, "1.2.4") + + def test_only_patch(self): + (self.tmpdir / "mock_file.bzl").write_text( + ":::{versionchanged} VERSION_NEXT_PATCH" + ) + self.mock_get_latest_version.return_value = "1.2.3" + + next_version = utils.determine_next_version() + + self.assertEqual(next_version, "1.2.4") + + def test_only_feature(self): + (self.tmpdir / "mock_file.bzl").write_text( + ":::{versionadded} VERSION_NEXT_FEATURE" + ) + self.mock_get_latest_version.return_value = "1.2.3" + + next_version = utils.determine_next_version() + + self.assertEqual(next_version, "1.3.0") + + def test_both_markers(self): + (self.tmpdir / "mock_file_patch.bzl").write_text( + ":::{versionchanged} VERSION_NEXT_PATCH" + ) + (self.tmpdir / "mock_file_feature.bzl").write_text( + ":::{versionadded} VERSION_NEXT_FEATURE" + ) + self.mock_get_latest_version.return_value = "1.2.3" + + next_version = utils.determine_next_version() + + self.assertEqual(next_version, "1.3.0") + + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") + def test_determine_next_version_on_release_branch_with_existing_tags( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.37" + mock_get_tags.return_value = ["0.37.0", "0.37.1", "0.36.0"] + + next_version = utils.determine_next_version() + + self.assertEqual(next_version, "0.37.2") + + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") + def test_determine_next_version_on_release_branch_no_tags( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.38" + mock_get_tags.return_value = ["0.37.0"] # No 0.38.x tags + + next_version = utils.determine_next_version() + + self.assertEqual(next_version, "0.38.0") + + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") + def test_determine_next_version_on_release_branch_with_active_rc( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.37" + # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet + mock_get_tags.return_value = ["0.37.0-rc0", "0.37.0-rc1", "0.36.0"] + + next_version = utils.determine_next_version() + + # Should target 0.37.0, not 0.37.1 + self.assertEqual(next_version, "0.37.0") + + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") + def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( + self, mock_get_tags, mock_get_branch + ): + mock_get_branch.return_value = "release/0.37" + # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet) + mock_get_tags.return_value = ["0.37.0", "0.37.1-rc0", "0.36.0"] + + next_version = utils.determine_next_version() + + # Should target 0.37.1, not 0.37.2 + self.assertEqual(next_version, "0.37.1") + + @patch("tools.private.release.git.Git.get_current_branch") + def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): + mock_get_branch.return_value = "main" + # Should fallback to default behavior (which uses mock_get_latest_version from setUp) + self.mock_get_latest_version.return_value = "1.2.3" + (self.tmpdir / "mock_file.bzl").write_text("no markers here") + + next_version = utils.determine_next_version() + + self.assertEqual(next_version, "1.2.4") + + +class ReplaceVersionNextTest(TempDirTestCase): + def test_replace_version_next(self): + # Arrange + mock_file_content = """ +:::{versionadded} VERSION_NEXT_FEATURE +blabla +::: + +:::{versionchanged} VERSION_NEXT_PATCH +blabla +::: +""" + (self.tmpdir / "mock_file.bzl").write_text(mock_file_content) + + utils.replace_version_next("0.28.0") + + new_content = (self.tmpdir / "mock_file.bzl").read_text() + + self.assertIn(":::{versionadded} 0.28.0", new_content) + self.assertIn(":::{versionadded} 0.28.0", new_content) + self.assertNotIn("VERSION_NEXT_FEATURE", new_content) + self.assertNotIn("VERSION_NEXT_PATCH", new_content) + + def test_replace_version_next_excludes_bazel_dirs(self): + # Arrange + mock_file_content = """ +:::{versionadded} VERSION_NEXT_FEATURE +blabla +::: +""" + bazel_dir = self.tmpdir / "bazel-rules_python" + bazel_dir.mkdir() + (bazel_dir / "mock_file.bzl").write_text(mock_file_content) + + tools_dir = self.tmpdir / "tools" / "private" / "release" + tools_dir.mkdir(parents=True) + (tools_dir / "mock_file.bzl").write_text(mock_file_content) + + tests_dir = self.tmpdir / "tests" / "tools" / "private" / "release" + tests_dir.mkdir(parents=True) + (tests_dir / "mock_file.bzl").write_text(mock_file_content) + + version = "0.28.0" + + # Act + utils.replace_version_next(version) + + # Assert + new_content = (bazel_dir / "mock_file.bzl").read_text() + self.assertIn("VERSION_NEXT_FEATURE", new_content) + + new_content = (tools_dir / "mock_file.bzl").read_text() + self.assertIn("VERSION_NEXT_FEATURE", new_content) + + new_content = (tests_dir / "mock_file.bzl").read_text() + self.assertIn("VERSION_NEXT_FEATURE", new_content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 7ea3d2e535..a63d2111f7 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -7,28 +7,23 @@ py_library( srcs = ["changelog_news.py"], ) +py_library( + name = "release_lib", + srcs = glob( + ["*.py"], + exclude = ["changelog_news.py"], + ), + deps = [ + ":changelog_news", + "@dev_pip//packaging", + ], +) + py_binary( name = "release", - srcs = [ - "__init__.py", - "complete_prepare.py", - "create_rc.py", - "create_release_branch.py", - "create_release_issue.py", - "determine_next_version.py", - "gh.py", - "git.py", - "prepare.py", - "process_backports.py", - "promote_rc.py", - "release.py", - "release_issue.py", - "shell.py", - "utils.py", - ], + srcs = ["release.py"], main = "release.py", deps = [ - ":changelog_news", - "@dev_pip//packaging", + ":release_lib", ], ) diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index ea4d8d7450..4e8564a9b8 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -98,6 +98,12 @@ def run(self) -> int: self.git.tag(next_rc, target_ref) self.git.push(args.remote, next_rc) + import os + + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"tag_name={next_rc}\n") + # Check off the appropriate "Tag RC{N}" task in the checklist print(f"Checking off Tag RC{next_rc_num} task...") metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} @@ -110,7 +116,7 @@ def run(self) -> int: tag_url = f"{REPO_URL}/releases/tag/{next_rc}" bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" - release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" + release_workflow_url = f"{REPO_URL}/actions/workflows/release_publish.yaml" comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 21af2d5154..83394bd27e 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -12,7 +12,10 @@ parse_backports, update_task_in_body, ) -from tools.private.release.utils import get_latest_rc_tag +from tools.private.release.utils import ( + get_latest_rc_tag, + replace_version_next, +) class ProcessBackports: @@ -90,8 +93,12 @@ def _cherry_pick_and_update_prs( release_date = datetime.date.today().strftime("%Y-%m-%d") changelog_news.update_changelog(version, release_date) - # Stage changelog changes and news/ deletions - self.git.add("CHANGELOG.md", "news/") + # Replace version markers that might have been introduced by the backport + print(f"Replacing version markers for PR {item.pr_ref}...") + replace_version_next(version) + + # Stage changelog changes, news/ deletions, and version placeholder updates + self.git.add_modified_and_deleted() # Amend cherry-pick commit to include news merging and deletions, # and reference the release tracking issue. diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py index 74671daa71..80b4ac8065 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote_rc.py @@ -76,7 +76,7 @@ def run(self) -> int: f" {latest_rc} to {version}." ) print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") - print(f"[DRY RUN] Would push tag {version} to upstream") + print(f"[DRY RUN] Would push tag {version} to {args.remote}") print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") return 0 diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 276261bef6..32e0a0fee7 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -96,11 +96,8 @@ def parse_metadata_line(line): metadata = {} if metadata_str: - pairs = metadata_str.strip().split() - for pair in pairs: - if "=" in pair: - k, v = pair.split("=", 1) - metadata[k] = v + for k, v in re.findall(r"(\w+)\s*=\s*(\S+)", metadata_str): + metadata[k] = v return { "checked": checked, @@ -116,7 +113,16 @@ def format_metadata_line(checked, name, metadata): if not metadata: return f"- [{check_str}] {name}" - metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items()) + metadata_pairs = [] + for k, v in metadata.items(): + if k == "commit": + # The 'commit' key is special-cased with a space after '=' so that + # GitHub autolinks the commit SHA. Autolinking requires certain + # characters to precede the value. + metadata_pairs.append(f"commit= {v}") + else: + metadata_pairs.append(f"{k}={v}") + metadata_str = " ".join(metadata_pairs) return f"- [{check_str}] {name} | {metadata_str}" From 8629003966d56fff8c919d7c9dc191bae9715c9e Mon Sep 17 00:00:00 2001 From: alex-the-third <139810351+alex-the-third@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:31:51 +0200 Subject: [PATCH 054/179] fix: use locale-neutral Windows ACL for build data (#3887) On localized Windows installations, `build_data_writer.ps1` could fail when creating the output file ACL because it used the English localized `Everyone` principal name. This changes the ACL rule to use the language-neutral `WorldSid` identity instead, preserving the existing Everyone read permission without depending on the display language of Windows. Before: German and other localized Windows installs could fail with `IdentityMappedException` / `IdentityNotMappedException`. After: build data generation succeeds on localized Windows installs. Fixes #3886 Tests: `bazelisk test //tests/build_data:build_data_test --config=fast-tests` --------- Co-authored-by: Richard Levasseur --- news/3886.fixed.md | 1 + python/private/build_data_writer.ps1 | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 news/3886.fixed.md diff --git a/news/3886.fixed.md b/news/3886.fixed.md new file mode 100644 index 0000000000..2285813f8b --- /dev/null +++ b/news/3886.fixed.md @@ -0,0 +1 @@ +(windows) Fixed build data generation on localized Windows installations. diff --git a/python/private/build_data_writer.ps1 b/python/private/build_data_writer.ps1 index 0074e69d38..05c49fef9c 100644 --- a/python/private/build_data_writer.ps1 +++ b/python/private/build_data_writer.ps1 @@ -21,7 +21,16 @@ $Utf8NoBom = New-Object System.Text.UTF8Encoding $False [System.IO.File]::WriteAllLines($OutputPath, $Lines, $Utf8NoBom) $Acl = Get-Acl $OutputPath -$AccessRule = New-Object System.Security.AccessControl.FileSystemAccessRule("Everyone", "Read", "Allow") +# We use WorldSid because the "Everyone" name is locale-dependent. +$EveryoneSid = New-Object System.Security.Principal.SecurityIdentifier( + [System.Security.Principal.WellKnownSidType]::WorldSid, + $null +) +$AccessRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $EveryoneSid, + "Read", + "Allow" +) $Acl.SetAccessRule($AccessRule) Set-Acl $OutputPath $Acl From 08025e1a74310fd3c52afb2f48eb8210a964f7f3 Mon Sep 17 00:00:00 2001 From: Ted Kaplan Date: Thu, 2 Jul 2026 17:04:59 -0700 Subject: [PATCH 055/179] feat(py_test): add opt-in safeguard against accidental no-op tests (#3825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #3824. `py_test` runs the main module directly and does not automatically invoke a test runner (e.g. `unittest`/`pytest`). A common pitfall is to define test classes/functions but forget to run them, so the test silently passes without executing anything. This adds a [validation action](https://bazel.build/extending/rules#validation_actions) to `py_test` that statically analyzes the main module with `ast` and fails the build if the module's top-level body is "inert" — only definitions, imports, assignments, and docstrings, with nothing that actually runs tests (such as an `if __name__ == "__main__":` guard invoking a runner). Because this is technically a breaking change, it is gated behind a new flag, `//python/config_settings:validate_test_main`, with values `auto`/`enabled`/ `disabled`. The default is `auto`, which currently resolves to `disabled`, and can be flipped to `enabled` in rules_python 3.0. Notes/limitations: * Only applies to `py_test` targets with a `main` source file; `main_module` targets are not checked. * Enabling requires the exec-tools toolchain (default for hermetic toolchains); if it's missing, the build fails with a message pointing at the disable flag. ## Test plan * Analysis tests (`tests/base_rules/py_test`) verify the `PyValidateTestMain` action and `_validation` output group are present when enabled and absent when disabled. * Unit test (`tests/validate_test_main`) covers the `ast` classification (inert vs. runs-something). * Bazel-in-bazel integration test (`tests/integration/validate_test_main`) builds real `py_test` targets with the flag and asserts an inert test fails the build with a descriptive error when enabled, while a correct test builds; and that an inert test builds when disabled / by default. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- .bazelignore | 1 + .bazelrc.deleted_packages | 1 + .../python/config_settings/index.md | 38 +++ news/3825.added.md | 6 + python/config_settings/BUILD.bazel | 9 + python/private/BUILD.bazel | 20 ++ python/private/attributes.bzl | 8 + python/private/common_labels.bzl | 1 + python/private/flags.bzl | 21 ++ python/private/py_executable.bzl | 84 +++++- python/private/py_test_main_validator.py | 224 +++++++++++++++ tests/base_rules/py_test/py_test_tests.bzl | 58 +++- tests/integration/BUILD.bazel | 5 + .../bzlmod_lockfile/MODULE.bazel.lock | 2 +- .../validate_test_main/BUILD.bazel | 21 ++ .../validate_test_main/MODULE.bazel | 10 + .../integration/validate_test_main/WORKSPACE | 13 + .../validate_test_main/WORKSPACE.bzlmod | 0 .../validate_test_main/good_test.py | 10 + .../validate_test_main/import_only_test.py | 3 + .../validate_test_main/inert_test.py | 7 + tests/integration/validate_test_main_test.py | 40 +++ tests/validate_test_main/BUILD.bazel | 10 + .../validate_test_main_test.py | 261 ++++++++++++++++++ 24 files changed, 849 insertions(+), 4 deletions(-) create mode 100644 news/3825.added.md create mode 100644 python/private/py_test_main_validator.py create mode 100644 tests/integration/validate_test_main/BUILD.bazel create mode 100644 tests/integration/validate_test_main/MODULE.bazel create mode 100644 tests/integration/validate_test_main/WORKSPACE create mode 100644 tests/integration/validate_test_main/WORKSPACE.bzlmod create mode 100644 tests/integration/validate_test_main/good_test.py create mode 100644 tests/integration/validate_test_main/import_only_test.py create mode 100644 tests/integration/validate_test_main/inert_test.py create mode 100644 tests/integration/validate_test_main_test.py create mode 100644 tests/validate_test_main/BUILD.bazel create mode 100644 tests/validate_test_main/validate_test_main_test.py diff --git a/.bazelignore b/.bazelignore index 5c3bb7caea..88a4c32056 100644 --- a/.bazelignore +++ b/.bazelignore @@ -37,3 +37,4 @@ tests/integration/py_cc_toolchain_registered/bazel-py_cc_toolchain_registered tests/integration/toolchain_target_settings/bazel-module_under_test tests/integration/unified_pypi/bazel-unified_pypi tests/integration/uv_lock/bazel-uv_lock +tests/integration/validate_test_main/bazel-module_under_test diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index ce42333e6f..aca014bb56 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -42,6 +42,7 @@ common --deleted_packages=tests/integration/runtime_manifests common --deleted_packages=tests/integration/toolchain_target_settings common --deleted_packages=tests/integration/unified_pypi common --deleted_packages=tests/integration/uv_lock +common --deleted_packages=tests/integration/validate_test_main common --deleted_packages=tests/modules/another_module common --deleted_packages=tests/modules/other common --deleted_packages=tests/modules/other/nspkg_delta diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md index 6b7a150518..78f8937a4d 100644 --- a/docs/api/rules_python/python/config_settings/index.md +++ b/docs/api/rules_python/python/config_settings/index.md @@ -242,6 +242,44 @@ The `auto` value The `omit_if_generated_source` value was removed :::: +::::{bzl:flag} validate_test_main +Determines if `py_test` runs a build-time validation that its main module +actually runs tests. + +A common `py_test` pitfall is to define test classes or functions but forget +to add code that runs them (for example, assuming `py_test` automatically +invokes `unittest` or `pytest`). When that happens, the test does nothing and +silently passes. + +When enabled, a validation action statically analyzes the main module and +fails the build if it defines test classes or functions but its top-level body +is "inert" -- i.e. it only contains definitions, imports, assignments, and +docstrings, with nothing that actually runs tests (such as an +`if __name__ == "__main__":` guard that invokes a test runner). + +A module that defines no classes or functions at all (for example, one that +only imports other modules) is always allowed, since it isn't the +"defined some tests but forgot to run them" case this check targets. + +This is only applicable to `py_test` targets that have a `main` source file; +targets using `main_module` are not checked. + +Values: + +* `auto`: (default) Automatically decide the effective value; the current + behavior is `disabled`. +* `enabled`: Run the validation action. +* `disabled`: Don't run the validation action. + +:::{note} +Enabling this requires the exec tools toolchain (with an exec interpreter) to +be registered, which is the case for the default hermetic toolchains. +::: + +:::{versionadded} VERSION_NEXT_FEATURE +::: +:::: + ::::{bzl:flag} py_linux_libc Set what libc is used for the target platform. This will affect which whl binaries will be pulled and what toolchain will be auto-detected. Currently `rules_python` only supplies toolchains compatible with `glibc`. diff --git a/news/3825.added.md b/news/3825.added.md new file mode 100644 index 0000000000..71b20aca91 --- /dev/null +++ b/news/3825.added.md @@ -0,0 +1,6 @@ +(py_test) Added an opt-in safeguard against `py_test` targets that silently +pass without running any tests. Set +{obj}`--@rules_python//python/config_settings:validate_test_main=enabled` to +fail the build when a test's main module only contains inert top-level +statements (definitions, imports, assignments) and never invokes a test +runner ([#3824](https://github.com/bazel-contrib/rules_python/issues/3824)). diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel index 97761bea83..c7de1d00da 100644 --- a/python/config_settings/BUILD.bazel +++ b/python/config_settings/BUILD.bazel @@ -10,6 +10,7 @@ load( "LibcFlag", "PrecompileFlag", "PrecompileSourceRetentionFlag", + "ValidateTestMainFlag", "VenvsSitePackages", "VenvsUseDeclareSymlinkFlag", rp_string_flag = "string_flag", @@ -77,6 +78,14 @@ string_flag( visibility = ["//visibility:public"], ) +string_flag( + name = "validate_test_main", + build_setting_default = ValidateTestMainFlag.AUTO, + values = ValidateTestMainFlag.flag_values(), + # NOTE: Only public because it's an implicit dependency of py_test. + visibility = ["//visibility:public"], +) + string_flag( name = "precompile_source_retention", build_setting_default = PrecompileSourceRetentionFlag.AUTO, diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index c3d60ffaf1..9a80b6a6d6 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -18,6 +18,7 @@ load("//python:py_binary.bzl", "py_binary") load("//python:py_library.bzl", "py_library") load(":bazel_config_mode.bzl", "bazel_config_mode") load(":py_exec_tools_toolchain.bzl", "current_interpreter_executable") +load(":py_interpreter_program.bzl", "py_interpreter_program") load(":sentinel_impl.bzl", "sentinel") load(":stamp_impl.bzl", "stamp_build_setting") load(":uncachable_version_file.bzl", "define_uncachable_version_file") @@ -219,6 +220,25 @@ py_binary( visibility = ["//:__subpackages__"], ) +# Tool used by py_test's validation action to statically check that the main +# module actually runs tests. See the validate_test_main config setting. +py_interpreter_program( + name = "py_test_main_validator", + main = "py_test_main_validator.py", + # Not actually public. Only public because it's an implicit dependency of + # the py_test rule. + visibility = ["//visibility:public"], +) + +py_library( + name = "py_test_main_validator_lib", + srcs = ["py_test_main_validator.py"], + imports = ["../.."], + visibility = [ + "//tests/validate_test_main:__pkg__", + ], +) + bzl_library( name = "attr_builders", srcs = ["attr_builders.bzl"], diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl index ad741d078d..61b41d527c 100644 --- a/python/private/attributes.bzl +++ b/python/private/attributes.bzl @@ -535,6 +535,14 @@ environment when the test is executed by bazel test. "@platforms//os:watchos", ], ), + "_validate_test_main": lambda: attrb.Label( + default = "//python/private:py_test_main_validator", + cfg = "exec", + ), + "_validate_test_main_flag": lambda: attrb.Label( + default = labels.VALIDATE_TEST_MAIN, + providers = [BuildSettingInfo], + ), }) # Attributes specific to Python test-equivalent executable rules. Such rules may diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl index 135f8c0a1b..6cc42ecf8d 100644 --- a/python/private/common_labels.bzl +++ b/python/private/common_labels.bzl @@ -29,6 +29,7 @@ labels = struct( PY_FREETHREADED = str(Label("//python/config_settings:py_freethreaded")), PY_LINUX_LIBC = str(Label("//python/config_settings:py_linux_libc")), REPL_DEP = str(Label("//python/bin:repl_dep")), + VALIDATE_TEST_MAIN = str(Label("//python/config_settings:validate_test_main")), VENV = str(Label("//python/config_settings:venv")), VENVS_SITE_PACKAGES = str(Label("//python/config_settings:venvs_site_packages")), VENVS_USE_DECLARE_SYMLINK = str(Label("//python/config_settings:venvs_use_declare_symlink")), diff --git a/python/private/flags.bzl b/python/private/flags.bzl index d9e3aa41c3..32769e4084 100644 --- a/python/private/flags.bzl +++ b/python/private/flags.bzl @@ -87,6 +87,27 @@ AddSrcsToRunfilesFlag = FlagEnum( is_enabled = _AddSrcsToRunfilesFlag_is_enabled, ) +def _ValidateTestMainFlag_is_enabled(ctx): + value = ctx.attr._validate_test_main_flag[BuildSettingInfo].value + if value == ValidateTestMainFlag.AUTO: + # Default off; intended to be flipped to enabled in a future major + # version (e.g. rules_python 3.0). + value = ValidateTestMainFlag.DISABLED + return value == ValidateTestMainFlag.ENABLED + +# Determines if py_test runs a validation action that statically checks the +# main module actually runs tests (instead of silently passing). +# buildifier: disable=name-conventions +ValidateTestMainFlag = FlagEnum( + # Automatically decide the effective value; currently resolves to disabled. + AUTO = "auto", + # Run the validation action. + ENABLED = "enabled", + # Don't run the validation action. + DISABLED = "disabled", + is_enabled = _ValidateTestMainFlag_is_enabled, +) + def _string_flag_impl(ctx): if ctx.attr.override: value = ctx.attr.override diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 11246ec513..c519af8c09 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -57,12 +57,13 @@ load( "runfiles_root_path", ) load(":common_labels.bzl", "labels") -load(":flags.bzl", "BootstrapImplFlag", "VenvsUseDeclareSymlinkFlag", "read_possibly_native_flag") +load(":flags.bzl", "BootstrapImplFlag", "ValidateTestMainFlag", "VenvsUseDeclareSymlinkFlag", "read_possibly_native_flag") load(":precompile.bzl", "maybe_precompile") load(":py_cc_link_params_info.bzl", "PyCcLinkParamsInfo") load(":py_executable_info.bzl", "PyExecutableInfo") load(":py_info.bzl", "PyInfo", "VenvSymlinkKind") load(":py_internal.bzl", "py_internal") +load(":py_interpreter_program.bzl", "PyInterpreterProgramInfo") load(":py_runtime_info.bzl", "DEFAULT_STUB_SHEBANG") load(":reexports.bzl", "BuiltinPyInfo", "BuiltinPyRuntimeInfo") load(":rule_builders.bzl", "ruleb") @@ -1170,6 +1171,11 @@ def py_executable_base_impl(ctx, *, semantics, is_test, inherited_environment = main_py = determine_main(ctx) else: main_py = None + + # Keep a reference to the main source file (before it may be replaced with a + # precompiled pyc below) so the test-main validation can statically analyze + # the original source. + main_py_source = main_py direct_sources = filter_to_py_srcs(ctx.files.srcs) precompile_result = maybe_precompile(ctx, direct_sources) @@ -1288,10 +1294,84 @@ def py_executable_base_impl(ctx, *, semantics, is_test, inherited_environment = implicit_pyc_source_files = implicit_pyc_source_files, imports = imports, ) - _add_provider_output_group_info(providers, py_info, exec_result.output_groups) + output_groups = dict(exec_result.output_groups) + if is_test: + _maybe_add_test_main_validation(ctx, main_py_source, output_groups) + _add_provider_output_group_info(providers, py_info, output_groups) return providers +def _maybe_add_test_main_validation(ctx, main_py, output_groups): + """Adds a validation action that checks the test main actually runs tests. + + This is a safeguard against the common pitfall of defining test classes or + functions but forgetting to invoke a test runner, which causes the test to + silently pass without running anything. See the + `//python/config_settings:validate_test_main` flag. + + Args: + ctx: Rule ctx. + main_py: File or None; the main entry point source file. None when the + target uses `main_module` (which can't be statically analyzed here). + output_groups: dict[str, depset[File]]; mutated in place to add the + `_validation` output group when a validation action is created. + """ + if not ValidateTestMainFlag.is_enabled(ctx): + return + + # `main_module` targets execute a module by name; there's no single source + # file to statically analyze, so the check doesn't apply. + if main_py == None: + return + + exec_tools_toolchain = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE] + if exec_tools_toolchain == None or exec_tools_toolchain.exec_tools.exec_interpreter == None: + fail( + "Validating py_test main modules requires the exec tools toolchain " + + "with an exec interpreter, but none was found. Either register one " + + "or set --@rules_python//python/config_settings:validate_test_main=disabled.", + ) + + exec_tools = exec_tools_toolchain.exec_tools + validator = ctx.attr._validate_test_main + program_info = validator[PyInterpreterProgramInfo] + interpreter = exec_tools.exec_interpreter[DefaultInfo].files_to_run + validator_files_to_run = validator[DefaultInfo].files_to_run + + validation_output = ctx.actions.declare_file(ctx.label.name + "_validate_test_main.txt") + + args = ctx.actions.args() + args.add_all(program_info.interpreter_args) + args.add(validator_files_to_run.executable) + args.add("--src", main_py) + args.add("--src_name", main_py.short_path) + args.add("--label", str(ctx.label)) + args.add("--output", validation_output) + + execution_requirements = {} + if testing.ExecutionInfo in validator: + execution_requirements = validator[testing.ExecutionInfo].requirements + + ctx.actions.run( + executable = interpreter, + arguments = [args], + inputs = [main_py], + outputs = [validation_output], + tools = [validator_files_to_run], + mnemonic = "PyValidateTestMain", + progress_message = "Validating py_test main %{label}", + env = program_info.env | { + "PYTHONNOUSERSITE": "1", + "PYTHONSAFEPATH": "1", + }, + execution_requirements = execution_requirements, + toolchain = EXEC_TOOLS_TOOLCHAIN_TYPE, + ) + if "_validation" in output_groups: + output_groups["_validation"] = depset([validation_output], transitive = [output_groups["_validation"]]) + else: + output_groups["_validation"] = depset([validation_output]) + def _get_build_info(ctx, cc_toolchain): build_info_files = py_internal.cc_toolchain_build_info_files(cc_toolchain) if cc_helper.is_stamping_enabled(ctx): diff --git a/python/private/py_test_main_validator.py b/python/private/py_test_main_validator.py new file mode 100644 index 0000000000..e3849c5f57 --- /dev/null +++ b/python/private/py_test_main_validator.py @@ -0,0 +1,224 @@ +"""Static check that a py_test main module actually runs something. + +A common ``py_test`` pitfall is to define test classes or functions but forget +to add any code that actually executes them (for example, assuming that +``py_test`` automatically invokes ``unittest`` or ``pytest``). When that +happens, running the test does nothing and the target silently passes. + +This validator parses the main module with :mod:`ast` and fails if the +module body is "inert", i.e. every top-level statement is one that does not +run anything (definitions, imports, assignments, docstrings, ``pass``). A +single active statement -- a bare call, a loop, or the conventional +``if __name__ == "__main__":`` guard -- is enough to consider the module able +to run tests. Top-level ``if`` and ``try`` blocks are inspected recursively, so +common guards like ``if TYPE_CHECKING:`` or ``try: import foo except +ImportError:`` (whose branches are themselves inert) don't bypass the check. + +As an exception, a module that defines no classes or functions at all (for +example, one that only imports other modules) is always allowed: it isn't the +"defined some tests but forgot to run them" case this check targets, and it +may legitimately rely on import side effects. +""" + +import argparse +import ast +import sys + +# Statement node types that never run any code on their own, regardless of +# their contents. A module whose top-level body consists solely of these (and +# inert assignments/expressions/guards, see below) is considered inert. +_INERT_NODE_TYPES = [ + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ast.Import, + ast.ImportFrom, + ast.Global, + ast.Pass, +] + +# `ast.TypeAlias` (PEP 695, e.g. `type Alias = int`) only exists on Python +# 3.12+. Add it dynamically so the validator still imports on older versions. +if hasattr(ast, "TypeAlias"): + _INERT_NODE_TYPES.append(ast.TypeAlias) + +_INERT_NODE_TYPES = tuple(_INERT_NODE_TYPES) + +# `ast.TryStar` (PEP 654, `try/except*`) only exists on Python 3.11+. +_TRY_NODE_TYPES = (ast.Try, ast.TryStar) if hasattr(ast, "TryStar") else (ast.Try,) + +# Expression node types whose evaluation runs code (and thus may run tests). +# `await`/`yield` can't appear at the module top level, but are included for +# completeness when walking nested expressions. +_ACTIVE_EXPR_NODE_TYPES = (ast.Call, ast.Await, ast.Yield, ast.YieldFrom) + + +def _expression_runs_code(value) -> bool: + """Returns True if evaluating the expression would invoke/await/yield. + + Note this walks the expression as written; it does not try to model what + actually executes (e.g. a call inside a `lambda` body won't run until the + lambda is called). Erring toward "runs code" keeps the safeguard from + flagging valid tests. + """ + if value is None: + return False + return any(isinstance(child, _ACTIVE_EXPR_NODE_TYPES) for child in ast.walk(value)) + + +def _all_inert(nodes) -> bool: + return all(_is_inert_statement(node) for node in nodes) + + +def _is_inert_statement(node: ast.stmt) -> bool: + """Returns True if the top-level statement does not run any test code.""" + if isinstance(node, _INERT_NODE_TYPES): + return True + + # Assignments are inert unless their value runs code, e.g. + # `exit_code = unittest.main()` actually runs the tests. + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + return not _expression_runs_code(node.value) + + # Bare expression statements: docstrings and other no-op expressions are + # inert; a call/await/yield (e.g. `unittest.main()`) runs code. + # Assert statements are inert unless their condition or message runs code. + if isinstance(node, ast.Assert): + return not (_expression_runs_code(node.test) or _expression_runs_code(node.msg)) + + if isinstance(node, ast.Expr): + return not _expression_runs_code(node.value) + + # An `if` whose condition and branches are entirely inert, e.g. the very + # common `if TYPE_CHECKING:` guard around typing-only imports. A call in the + # condition (e.g. `if feature_enabled():`) or a runner call in a branch body + # makes it active. + if isinstance(node, ast.If): + return ( + not _expression_runs_code(node.test) + and _all_inert(node.body) + and _all_inert(node.orelse) + ) + + # A `try` (or `try/except*`) whose body, handlers, else, and finally are all + # inert, e.g. the common `try: import foo except ImportError: ...` optional + # import pattern. + if isinstance(node, _TRY_NODE_TYPES): + if not ( + _all_inert(node.body) + and _all_inert(node.orelse) + and _all_inert(node.finalbody) + ): + return False + return all(_all_inert(handler.body) for handler in node.handlers) + + return False + + +def module_runs_something(tree: ast.Module) -> bool: + """Returns True if the module body has at least one active statement.""" + return any(not _is_inert_statement(node) for node in tree.body) + + +def _defines_test_code(tree: ast.Module) -> bool: + """Returns True if the module defines any top-level class or function.""" + return any( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + for node in tree.body + ) + + +def module_runs_tests(tree: ast.Module) -> bool: + """Returns True if the module appears able to run tests (or isn't checked). + + The check targets the specific pitfall of defining test classes/functions + but never running them. A module that defines no classes or functions at + all (for example, one that only imports other modules) is not subject to + the check and is always allowed, since it isn't the "defined but never run" + case and may legitimately rely on import side effects. + """ + if module_runs_something(tree): + return True + return not _defines_test_code(tree) + + +def _format_error(label: str, src_name: str) -> str: + target = label or src_name + return ( + "py_test target {target} will not run any tests.\n" + "\n" + "The main module '{src_name}' only contains inert top-level statements " + "(class/function definitions, imports, assignments). Running it does " + "nothing, so the test silently passes without executing any test " + "code.\n" + "\n" + "py_test runs the main module directly; it does not automatically " + "invoke a test runner such as unittest or pytest. Add code that runs " + "your tests, for example:\n" + "\n" + ' if __name__ == "__main__":\n' + " unittest.main()\n" + "\n" + "or use a main module that invokes a runner (e.g. pytest.main()).\n" + "\n" + "This check can be disabled by setting " + "--@rules_python//python/config_settings:validate_test_main=disabled." + ).format(target=target, src_name=src_name) + + +def main(args) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--src", + required=True, + help="Path to the main .py source file to analyze.", + ) + parser.add_argument( + "--src_name", + default="", + help="Human-friendly name of the source file, used in error messages.", + ) + parser.add_argument( + "--label", + default="", + help="The py_test target label, used in error messages.", + ) + parser.add_argument( + "--output", + required=True, + help="Path to the validation marker file to write on success.", + ) + options = parser.parse_args(args) + + src_name = options.src_name or options.src + + with open(options.src, "rb") as f: + source = f.read() + + try: + tree = ast.parse(source, filename=src_name) + except SyntaxError as e: + # A syntax error is surfaced by other actions (compilation/execution). + # The validator can't analyze the file, so don't fail here; treat it as + # passing to avoid duplicate or confusing errors. + sys.stderr.write( + "WARNING: py_test main validator could not parse {}: {}\n".format( + src_name, e + ) + ) + with open(options.output, "w") as out: + out.write("") + return 0 + + if not module_runs_tests(tree): + sys.stderr.write(_format_error(options.label, src_name) + "\n") + return 1 + + # Validation actions must produce their declared outputs on success. + with open(options.output, "w") as out: + out.write("") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/base_rules/py_test/py_test_tests.bzl b/tests/base_rules/py_test/py_test_tests.bzl index fd284beffd..e16204e9f2 100644 --- a/tests/base_rules/py_test/py_test_tests.bzl +++ b/tests/base_rules/py_test/py_test_tests.bzl @@ -16,12 +16,13 @@ load("@rules_testing//lib:analysis_test.bzl", "analysis_test") load("@rules_testing//lib:util.bzl", rt_util = "util") load("//python:py_test.bzl", "py_test") +load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility load( "//tests/base_rules:py_executable_base_tests.bzl", "create_executable_tests", ) load("//tests/base_rules:util.bzl", pt_util = "util") -load("//tests/support:support.bzl", "CC_TOOLCHAIN", "CROSSTOOL_TOP") +load("//tests/support:support.bzl", "CC_TOOLCHAIN", "CROSSTOOL_TOP", "PY_TOOLCHAINS") load("//tests/support/platforms:platforms.bzl", "platform_targets") # The Windows CI currently runs as root, which breaks when @@ -96,6 +97,61 @@ def _test_non_mac_doesnt_require_darwin_for_execution_impl(env, target): _tests.append(_test_non_mac_doesnt_require_darwin_for_execution) +_VALIDATE_TEST_MAIN_CONFIG_SETTINGS = { + "//command_line_option:extra_toolchains": [PY_TOOLCHAINS, CC_TOOLCHAIN], + labels.EXEC_TOOLS_TOOLCHAIN: "enabled", +} + +def _test_validate_test_main_enabled(name, config): + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_subject.py"], + ) + analysis_test( + name = name, + impl = _test_validate_test_main_enabled_impl, + target = name + "_subject", + config_settings = _VALIDATE_TEST_MAIN_CONFIG_SETTINGS | { + labels.VALIDATE_TEST_MAIN: "enabled", + }, + attr_values = _SKIP_WINDOWS, + ) + +def _test_validate_test_main_enabled_impl(env, target): + mnemonics = [a.mnemonic for a in target.actions] + env.expect.that_collection(mnemonics).contains("PyValidateTestMain") + env.expect.that_bool( + hasattr(target[OutputGroupInfo], "_validation"), + ).equals(True) + +_tests.append(_test_validate_test_main_enabled) + +def _test_validate_test_main_disabled(name, config): + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_subject.py"], + ) + analysis_test( + name = name, + impl = _test_validate_test_main_disabled_impl, + target = name + "_subject", + config_settings = _VALIDATE_TEST_MAIN_CONFIG_SETTINGS | { + labels.VALIDATE_TEST_MAIN: "disabled", + }, + attr_values = _SKIP_WINDOWS, + ) + +def _test_validate_test_main_disabled_impl(env, target): + mnemonics = [a.mnemonic for a in target.actions] + env.expect.that_collection(mnemonics).not_contains("PyValidateTestMain") + env.expect.that_bool( + hasattr(target[OutputGroupInfo], "_validation"), + ).equals(False) + +_tests.append(_test_validate_test_main_disabled) + def py_test_test_suite(name): config = struct(rule = py_test) native.test_suite( diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel index 9301e19590..abdb37be57 100644 --- a/tests/integration/BUILD.bazel +++ b/tests/integration/BUILD.bazel @@ -118,6 +118,11 @@ rules_python_integration_test( py_main = "toolchain_target_settings_test.py", ) +rules_python_integration_test( + name = "validate_test_main_test", + py_main = "validate_test_main_test.py", +) + rules_python_integration_test( name = "unified_pypi_test", py_main = "unified_pypi_test.py", diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index c7468217e9..fd161a3965 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -252,7 +252,7 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "ELjwPp2kLku5M3S/gpjjVjy3TwT760/zVEQ70nJreHU=", + "bzlTransitiveDigest": "OC4ZhWl8jX9wvFicn83AioC9J7Dx6Us2/+EzoGDlPzU=", "usagesDigest": "6yXGw7XDyXjOfqBL0SBu1YBEMMYPQzCE3jTzUCkxPgg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", diff --git a/tests/integration/validate_test_main/BUILD.bazel b/tests/integration/validate_test_main/BUILD.bazel new file mode 100644 index 0000000000..cf931d3303 --- /dev/null +++ b/tests/integration/validate_test_main/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_python//python:py_test.bzl", "py_test") + +# A test whose main module only defines a test case but never runs it. With +# --validate_test_main=enabled this should fail to build. +py_test( + name = "inert_test", + srcs = ["inert_test.py"], +) + +# A test whose main module invokes a runner. This should always build. +py_test( + name = "good_test", + srcs = ["good_test.py"], +) + +# A test whose main module defines nothing and only imports. This is allowed +# even with validation enabled, since it isn't the "defined but never run" case. +py_test( + name = "import_only_test", + srcs = ["import_only_test.py"], +) diff --git a/tests/integration/validate_test_main/MODULE.bazel b/tests/integration/validate_test_main/MODULE.bazel new file mode 100644 index 0000000000..c154ac169e --- /dev/null +++ b/tests/integration/validate_test_main/MODULE.bazel @@ -0,0 +1,10 @@ +module(name = "module_under_test") + +bazel_dep(name = "rules_python", version = "0.0.0") +local_path_override( + module_name = "rules_python", + path = "../../..", +) + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain(python_version = "3.11") diff --git a/tests/integration/validate_test_main/WORKSPACE b/tests/integration/validate_test_main/WORKSPACE new file mode 100644 index 0000000000..de908549c0 --- /dev/null +++ b/tests/integration/validate_test_main/WORKSPACE @@ -0,0 +1,13 @@ +local_repository( + name = "rules_python", + path = "../../..", +) + +load("@rules_python//python:repositories.bzl", "py_repositories", "python_register_toolchains") + +py_repositories() + +python_register_toolchains( + name = "python_3_11", + python_version = "3.11", +) diff --git a/tests/integration/validate_test_main/WORKSPACE.bzlmod b/tests/integration/validate_test_main/WORKSPACE.bzlmod new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/validate_test_main/good_test.py b/tests/integration/validate_test_main/good_test.py new file mode 100644 index 0000000000..46107c96ec --- /dev/null +++ b/tests/integration/validate_test_main/good_test.py @@ -0,0 +1,10 @@ +import unittest + + +class GoodTest(unittest.TestCase): + def test_something(self): + self.assertTrue(True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/validate_test_main/import_only_test.py b/tests/integration/validate_test_main/import_only_test.py new file mode 100644 index 0000000000..1bd46dead7 --- /dev/null +++ b/tests/integration/validate_test_main/import_only_test.py @@ -0,0 +1,3 @@ +# This module defines no classes or functions; it only imports. The validation +# action allows it even when enabled. +import os # noqa: F401 diff --git a/tests/integration/validate_test_main/inert_test.py b/tests/integration/validate_test_main/inert_test.py new file mode 100644 index 0000000000..8decf676d4 --- /dev/null +++ b/tests/integration/validate_test_main/inert_test.py @@ -0,0 +1,7 @@ +import unittest + + +class InertTest(unittest.TestCase): + def test_nothing_runs(self): + # This test case is never executed because nothing invokes a runner. + self.assertTrue(True) diff --git a/tests/integration/validate_test_main_test.py b/tests/integration/validate_test_main_test.py new file mode 100644 index 0000000000..4d108411d5 --- /dev/null +++ b/tests/integration/validate_test_main_test.py @@ -0,0 +1,40 @@ +import unittest + +from tests.integration import runner + +_FLAG = "--@rules_python//python/config_settings:validate_test_main" + + +class ValidateTestMainTest(runner.TestCase): + def test_inert_test_fails_when_enabled(self): + """An inert test main should fail the build when validation is on.""" + result = self.run_bazel( + "build", + f"{_FLAG}=enabled", + "//:inert_test", + check=False, + ) + self.assertNotEqual(result.exit_code, 0, "Expected build to fail") + self.assert_result_matches(result, r"will not run any tests") + + def test_good_test_builds_when_enabled(self): + """A main that invokes a runner should build when validation is on.""" + self.run_bazel("build", f"{_FLAG}=enabled", "//:good_test") + + def test_import_only_test_builds_when_enabled(self): + """A main that only imports (defines nothing) is allowed when on.""" + self.run_bazel("build", f"{_FLAG}=enabled", "//:import_only_test") + + def test_inert_test_builds_when_disabled(self): + """Validation is off by default, so even an inert test builds.""" + self.run_bazel("build", f"{_FLAG}=disabled", "//:inert_test") + + def test_inert_test_builds_by_default(self): + """The default (auto) resolves to disabled, so an inert test builds.""" + self.run_bazel("build", "//:inert_test") + + +if __name__ == "__main__": + # Enabling this makes the runner log subprocesses as the test goes along. + # logging.basicConfig(level = "INFO") + unittest.main() diff --git a/tests/validate_test_main/BUILD.bazel b/tests/validate_test_main/BUILD.bazel new file mode 100644 index 0000000000..94163a2fde --- /dev/null +++ b/tests/validate_test_main/BUILD.bazel @@ -0,0 +1,10 @@ +load("//python:py_test.bzl", "py_test") + +py_test( + name = "validate_test_main_test", + srcs = ["validate_test_main_test.py"], + main = "validate_test_main_test.py", + deps = [ + "//python/private:py_test_main_validator_lib", + ], +) diff --git a/tests/validate_test_main/validate_test_main_test.py b/tests/validate_test_main/validate_test_main_test.py new file mode 100644 index 0000000000..4cbc55c937 --- /dev/null +++ b/tests/validate_test_main/validate_test_main_test.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +import ast +import textwrap +import unittest + +from python.private.py_test_main_validator import module_runs_tests + + +def _runs_tests(source: str) -> bool: + tree = ast.parse(textwrap.dedent(source)) + return module_runs_tests(tree) + + +class ModuleRunsTestsTest(unittest.TestCase): + def test_only_definitions_is_rejected(self): + self.assertFalse( + _runs_tests( + """ + import unittest + + class MyTest(unittest.TestCase): + def test_foo(self): + self.assertTrue(True) + """ + ) + ) + + def test_definitions_with_assignments_is_rejected(self): + self.assertFalse( + _runs_tests( + """ + import unittest + + CONSTANT = 5 + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + + def helper(): pass + """ + ) + ) + + def test_global_statement_with_definition_is_rejected(self): + self.assertFalse( + _runs_tests( + """ + global x + + class MyTest: + def test_foo(self): + pass + """ + ) + ) + + def test_assert_statement_with_definition_is_rejected(self): + # A bare `assert` with an inert condition runs no tests. + self.assertFalse( + _runs_tests( + """ + assert True + + class MyTest: + def test_foo(self): + pass + """ + ) + ) + + def test_assert_statement_with_active_expression_runs_tests(self): + # An assert whose condition runs a call actually runs the tests. + self.assertTrue( + _runs_tests( + """ + import unittest + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + + assert unittest.main() + """ + ) + ) + + @unittest.skipUnless( + hasattr(ast, "TypeAlias"), "PEP 695 type aliases require Python 3.12+" + ) + def test_type_alias_with_definition_is_rejected(self): + self.assertFalse( + _runs_tests( + """ + type Alias = int + + class MyTest: + def test_foo(self): + pass + """ + ) + ) + + def test_type_checking_block_with_definition_is_rejected(self): + # `if TYPE_CHECKING:` guarding typing-only imports is inert; with a test + # definition and no runner, the module is still rejected. + self.assertFalse( + _runs_tests( + """ + from typing import TYPE_CHECKING + import unittest + + if TYPE_CHECKING: + from typing import Any + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + """ + ) + ) + + def test_try_except_import_error_with_definition_is_rejected(self): + # `try: import foo except ImportError:` optional imports are inert. + self.assertFalse( + _runs_tests( + """ + try: + import foo + except ImportError: + foo = None + + import unittest + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + """ + ) + ) + + def test_definitions_with_active_assignment_runs_tests(self): + # An assignment whose value runs a call actually runs the tests. + self.assertTrue( + _runs_tests( + """ + import unittest + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + + exit_code = unittest.main() + """ + ) + ) + + def test_definitions_with_active_expression_runs_tests(self): + self.assertTrue( + _runs_tests( + """ + import sys + import unittest + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + + sys.exit(unittest.main()) + """ + ) + ) + + def test_if_block_invoking_runner_runs_tests(self): + # A runner call inside an `if` body must still count as active. + self.assertTrue( + _runs_tests( + """ + import sys + import unittest + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + + if "--run" in sys.argv: + unittest.main() + """ + ) + ) + + def test_import_only_module_is_allowed(self): + # A module that defines nothing and only imports other modules is not + # the "defined but never run" case, so it is allowed. + self.assertTrue( + _runs_tests( + """ + import my_tests + from my_pkg.tests import suite + """ + ) + ) + + def test_imports_and_assignments_without_definitions_is_allowed(self): + self.assertTrue( + _runs_tests( + """ + import my_tests + + CONSTANT = 5 + """ + ) + ) + + def test_empty_module_is_allowed(self): + self.assertTrue(_runs_tests("")) + + def test_docstring_only_is_allowed(self): + self.assertTrue(_runs_tests('"""A module docstring."""')) + + def test_if_name_main_guard_runs_tests(self): + self.assertTrue( + _runs_tests( + """ + import unittest + + class MyTest(unittest.TestCase): + def test_foo(self): + pass + + if __name__ == "__main__": + unittest.main() + """ + ) + ) + + def test_bare_call_runs_tests(self): + self.assertTrue( + _runs_tests( + """ + import pytest + pytest.main() + """ + ) + ) + + def test_top_level_loop_runs_tests(self): + self.assertTrue( + _runs_tests( + """ + def f(): pass + + for _ in range(1): + f() + """ + ) + ) + + +if __name__ == "__main__": + unittest.main() From 7968ac919487a14b355c621d746ce93c02080a3b Mon Sep 17 00:00:00 2001 From: Jan Winkler <45763961+golithe@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:08:04 +0200 Subject: [PATCH 056/179] feat: enable pyproject.toml as single source of truth for Python version (#3514) - Enables using pyproject.toml as single source of truth for Python version via `python.defaults(pyproject_toml=...)`, `pip.default(pyproject_toml=...)`, and `pip.parse(pyproject_toml=...)`. - The version is read from the requires-python field and used as the default toolchain version, so anything relying on the default (f.e. `py_binary`/`py_test`, `pip.parse`, `compile_pip_requirements`) picks it up with no explicit `python_version` in BUILD files: ```starlark # MODULE.bazel python.defaults(pyproject_toml = "//:pyproject.toml") pip.default(pyproject_toml = "//:pyproject.toml") # BUILD.bazel (version comes from default, no need to restate it) compile_pip_requirements( name="requirements", requirements_txt = "requirements.txt" ) ``` - Relates to #1708 (lifts the mandatory `python_version` restriction on `pip.parse`) Changes: - Added `pyproject_toml` attribute to `python.defaults()` and `pip.default()` and `pip.parse()`, reading the version from `requires-python` - Parses `pyproject.toml` with the pure-Starlark `@toml.bzl (toml.decode)` (no host Python interpreter required) - Auto-registers a toolchain for the default version if no explicit `python.toolchain()` call exists - Made `python_version` optional in `pip.parse()`: the version can come from `pip.parse(pyproject_toml=...)` directly (which also works for non-root modules) or fall back to `pip.default(pyproject_toml=...)` - Precedence order: `ENV > pyproject_toml > python_version_file > python_version` Requirements: - `requires-python = "==X.Y.Z"` format in pyproject.toml Thanks to @aignas and @rickeylev for the review and design guidance --------- Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com> --- news/3514.added.md | 1 + python/private/BUILD.bazel | 10 ++++ python/private/pypi/BUILD.bazel | 1 + python/private/pypi/extension.bzl | 61 +++++++++++++++++++++++- python/private/pypi/hub_builder.bzl | 27 ++++++----- python/private/pyproject_utils.bzl | 51 ++++++++++++++++++++ python/private/python.bzl | 46 +++++++++++++++++- tests/pypi/extension/extension_tests.bzl | 58 +++++++++++++++++++++- tests/pypi/extension/pip_parse.bzl | 4 +- 9 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 news/3514.added.md create mode 100644 python/private/pyproject_utils.bzl diff --git a/news/3514.added.md b/news/3514.added.md new file mode 100644 index 0000000000..bf2a119380 --- /dev/null +++ b/news/3514.added.md @@ -0,0 +1 @@ +(pip,python) Added `pyproject_toml` attribute to {obj}`pip.default`, {obj}`pip.parse` and {obj}`python.defaults` to read the default Python version from the `requires-python` field of `pyproject.toml`. diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index 9a80b6a6d6..e4d06d4a89 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -729,6 +729,7 @@ bzl_library( ":full_version", ":pbs_manifest", ":platform_info", + ":pyproject_utils", ":python_register_toolchains", ":pythons_hub", ":repo_utils", @@ -896,6 +897,15 @@ bzl_library( ], ) +bzl_library( + name = "pyproject_utils", + srcs = ["pyproject_utils.bzl"], + deps = [ + ":version", + "@toml.bzl//:toml", + ], +) + bzl_library( name = "bzlmod_enabled", srcs = ["bzlmod_enabled.bzl"], diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 677154ee46..8dd32afb9c 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -125,6 +125,7 @@ bzl_library( ":whl_library", "//python/private:auth", "//python/private:normalize_name", + "//python/private:pyproject_utils", "//python/private:repo_utils", "@pythons_hub//:interpreters", "@pythons_hub//:versions", diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index 094cac7f3f..b79d04c075 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -20,6 +20,7 @@ load("@rules_python_internal//:rules_python_config.bzl", rp_config = "config") load("@toml.bzl", "toml") load("//python/private:auth.bzl", "AUTH_ATTRS") load("//python/private:normalize_name.bzl", "normalize_name") +load("//python/private:pyproject_utils.bzl", "read_pyproject", "version_from_requires_python") load("//python/private:repo_utils.bzl", "repo_utils") load(":hub_builder.bzl", "hub_builder") load(":hub_repository.bzl", "hub_repository", "whl_config_settings_to_json") @@ -208,6 +209,7 @@ def build_config( default_hub = None defaults = { "platforms": default_platforms(), + "python_version": None, } for mod in module_ctx.modules: if not (mod.is_root or mod.name == "rules_python"): @@ -219,6 +221,11 @@ def build_config( if default_hub: fail("Duplicate pip.default tag: only one explicit default PyPI hub is allowed.") default_hub = tag.default_hub + pyproject_toml = tag.pyproject_toml + if pyproject_toml: + pyproject = read_pyproject(module_ctx, pyproject_toml) + if pyproject.requires_python: + defaults["python_version"] = version_from_requires_python(pyproject.requires_python) platform = tag.platform if platform: @@ -256,6 +263,7 @@ def build_config( default_hub = default_hub, index_url = defaults.get("index_url", "https://pypi.org/simple").rstrip("/"), netrc = defaults.get("netrc", None), + python_version = defaults.get("python_version", None), platforms = { name: _plat(**values) for name, values in defaults["platforms"].items() @@ -359,6 +367,15 @@ You cannot use both the additive_build_content and additive_build_content_file a for mod in module_ctx.modules: for pip_attr in mod.tags.parse: + python_version = pip_attr.python_version + if not python_version and pip_attr.pyproject_toml: + pyproject = read_pyproject(module_ctx, pip_attr.pyproject_toml) + if pyproject.requires_python: + python_version = version_from_requires_python(pyproject.requires_python) + python_version = python_version or config.python_version + if not python_version: + _fail("pip.parse() requires one of `python_version`, `pyproject_toml`, or `pip.default(pyproject_toml=...)` to be set") + hub_name = pip_attr.hub_name if hub_name == "pypi": if is_pypi_hub_reserved: @@ -422,6 +439,7 @@ You cannot use both the additive_build_content and additive_build_content_file a builder.pip_parse( module_ctx, pip_attr = pip_attr, + python_version = python_version, ) # dict[str package, dict[str, None] extra_targets] @@ -718,6 +736,23 @@ If you are defining custom platforms in your project and don't want things to cl [isolation] feature. [isolation]: https://bazel.build/rules/lib/globals/module#use_extension.isolate +""", + ), + "pyproject_toml": attr.label( + mandatory = False, + doc = """\ +Label pointing to pyproject.toml file to read the default Python version from. +When specified, reads the `requires-python` field from pyproject.toml and uses +it as the default python_version for all `pip.parse()` calls that don't +explicitly specify one. + +:::{note} +The version must be specified as `==X.Y.Z` (exact version with full semver). +This is designed to work with dependency management tools like Renovate. +::: + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, ), "whl_abi_tags": attr.string_list( @@ -888,8 +923,23 @@ find in case extra indexes are specified. """, default = True, ), + "pyproject_toml": attr.label( + mandatory = False, + doc = """\ +Label pointing to a pyproject.toml file to read the Python version from. +When specified, the `requires-python` field is used as the `python_version` +for this `pip.parse()` call, unless `python_version` is set explicitly. + +:::{note} +The version must be specified as `==X.Y.Z` (exact version with full semver). +::: + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + ), "python_version": attr.string( - mandatory = True, + mandatory = False, doc = """ The Python version the dependencies are targetting, in Major.Minor format (e.g., "3.11") or patch level granularity (e.g. "3.11.1"). @@ -897,6 +947,15 @@ The Python version the dependencies are targetting, in Major.Minor format If an interpreter isn't explicitly provided (using `python_interpreter` or `python_interpreter_target`), then the version specified here must have a corresponding `python.toolchain()` configured. + +:::{seealso} +The {obj}`pyproject_toml` attribute for getting the version from a project file. +::: + +:::{versionchanged} VERSION_NEXT_FEATURE +No longer mandatory if the {obj}`pyproject_toml` attribute or +{obj}`pip.default.pyproject_toml` is specified. +::: """, ), "simpleapi_skip": attr.string_list( diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl index 7717f36731..01dc8494d6 100644 --- a/python/private/pypi/hub_builder.bzl +++ b/python/private/pypi/hub_builder.bzl @@ -144,8 +144,8 @@ def _build(self): whl_libraries = self._whl_libraries, ) -def _pip_parse(self, module_ctx, pip_attr): - python_version = pip_attr.python_version +def _pip_parse(self, module_ctx, pip_attr, python_version = None): + python_version = python_version or pip_attr.python_version if python_version in self._platforms: fail(( "Duplicate pip python version '{version}' for hub " + @@ -191,7 +191,8 @@ def _pip_parse(self, module_ctx, pip_attr): self, module_ctx, pip_attr = pip_attr, - enable_pipstar_extract = bool(self._config.enable_pipstar_extract or self._get_index_urls.get(pip_attr.python_version)), + python_version = python_version, + enable_pipstar_extract = bool(self._config.enable_pipstar_extract or self._get_index_urls.get(python_version)), ) ### end of PUBLIC methods @@ -393,11 +394,11 @@ def _set_get_index_urls(self, mctx, pip_attr): ) return True -def _detect_interpreter(self, pip_attr): +def _detect_interpreter(self, pip_attr, python_version): 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(".", "_"), + python_version.replace(".", "_"), ) if python_name not in self._available_interpreters: fail(( @@ -407,7 +408,7 @@ def _detect_interpreter(self, pip_attr): "Expected to find {python_name} among registered versions:\n {labels}" ).format( hub_name = self.name, - version = pip_attr.python_version, + version = python_version, python_name = python_name, labels = " \n".join(self._available_interpreters), )) @@ -476,6 +477,7 @@ def _create_whl_repos( module_ctx, *, pip_attr, + python_version, enable_pipstar_extract = False): """create all of the whl repositories @@ -483,10 +485,11 @@ def _create_whl_repos( self: the builder. module_ctx: {type}`module_ctx`. pip_attr: {type}`struct` - the struct that comes from the tag class iteration. + python_version: {type}`str` - the resolved python version for this pip.parse call. enable_pipstar_extract: {type}`bool` - enable the pipstar extraction or not. """ logger = self._logger - platforms = self._platforms[pip_attr.python_version] + platforms = self._platforms[python_version] requirements_by_platform = parse_requirements( module_ctx, requirements_by_platform = requirements_files_by_platform( @@ -498,7 +501,7 @@ def _create_whl_repos( 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, + version = python_version, minor_mapping = self._minor_mapping, ), logger = logger, @@ -528,7 +531,7 @@ def _create_whl_repos( pip_attr = pip_attr, ) - interpreter = _detect_interpreter(self, pip_attr) + interpreter = _detect_interpreter(self, pip_attr, python_version) for whl in requirements_by_platform: whl_library_args = common_args | _whl_library_args( @@ -543,16 +546,16 @@ def _create_whl_repos( whl_library_args = whl_library_args, download_only = pip_attr.download_only, netrc = self._config.netrc or pip_attr.netrc, - use_downloader = src.url and _use_downloader(self, pip_attr.python_version, whl.name), + use_downloader = src.url and _use_downloader(self, python_version, whl.name), auth_patterns = self._config.auth_patterns or pip_attr.auth_patterns, - python_version = _major_minor_version(pip_attr.python_version), + python_version = _major_minor_version(python_version), is_multiple_versions = whl.is_multiple_versions, interpreter = interpreter, enable_pipstar_extract = enable_pipstar_extract, ) _add_whl_library( self, - python_version = pip_attr.python_version, + python_version = python_version, whl = whl, repo = repo, ) diff --git a/python/private/pyproject_utils.bzl b/python/private/pyproject_utils.bzl new file mode 100644 index 0000000000..e96ffd2023 --- /dev/null +++ b/python/private/pyproject_utils.bzl @@ -0,0 +1,51 @@ +"""Utilities for reading values from pyproject.toml.""" + +load("@toml.bzl", "toml") +load(":version.bzl", "version") + +def read_pyproject(module_ctx, pyproject): + """Read a pyproject.toml file and return the relevant fields. + + The file is parsed with a pure-Starlark TOML decoder; no Python + interpreter is required. The raw `requires-python` value is returned + as-is so that callers can decide how to interpret it. + + Args: + module_ctx: the module extension context (needs `path` and `read`). + pyproject: {type}`Label` pointing at the pyproject.toml file. + + Returns: + {type}`struct` with the attributes: + * `requires_python`: {type}`str | None` the raw `requires-python` + value (e.g. `"==3.13.9"`), or `None` if it is not set. + """ + data = toml.decode(module_ctx.read(module_ctx.path(pyproject), watch = "yes")) + return struct( + requires_python = data.get("project", {}).get("requires-python"), + ) + +def version_from_requires_python(requires_python): + """Derive a concrete Python version from a `requires-python` value. + + Currently only an exact `==X.Y.Z` specifier is supported. The value is + validated and normalized via {obj}`//python/private:version.bzl` so that + malformed input fails in a consistent way. Broader specifier support + (e.g. `>=`, `X.Y`) can be layered on here in the future. + + Args: + requires_python: {type}`str` the raw `requires-python` value. + + Returns: + {type}`str` the normalized version string (e.g. `"3.13.9"`). + """ + if not requires_python: + fail("`requires-python` must be specified") + + if not requires_python.startswith("=="): + fail("`requires-python` must pin an exact version with `==`, got: {}".format(requires_python)) + + bare_version = requires_python[len("=="):].strip() + + # Parse strictly so malformed versions fail cleanly, then normalize. + version.parse(bare_version, strict = True) + return version.normalize(bare_version) diff --git a/python/private/python.bzl b/python/private/python.bzl index 23bae5d341..f098a626fb 100644 --- a/python/private/python.bzl +++ b/python/private/python.bzl @@ -20,6 +20,7 @@ load(":auth.bzl", "AUTH_ATTRS") load(":full_version.bzl", "full_version") load(":pbs_manifest.bzl", "parse_runtime_manifest") load(":platform_info.bzl", "platform_info") +load(":pyproject_utils.bzl", "read_pyproject", "version_from_requires_python") load(":python_register_toolchains.bzl", "python_register_toolchains") load(":pythons_hub.bzl", "hub_repo") load(":repo_utils.bzl", "repo_utils") @@ -88,6 +89,7 @@ def parse_modules(*, module_ctx, logger = None, _fail = fail): mod = mod, seen_versions = seen_versions, config = config, + default_python_version = default_python_version, ) for toolchain_attr in toolchain_attr_structs: @@ -971,8 +973,15 @@ def _compute_default_python_version(mctx): defaults_attr_structs = _create_defaults_attr_structs(mod = mod) default_python_version_env = None default_python_version_file = None + pyproject_toml_label = None for defaults_attr in defaults_attr_structs: + pyproject_toml_label = _one_or_the_same( + pyproject_toml_label, + defaults_attr.pyproject_toml, + onerror = lambda: fail("Multiple pyproject.toml files specified in defaults"), + ) + default_python_version = _one_or_the_same( default_python_version, defaults_attr.python_version, @@ -988,11 +997,17 @@ def _compute_default_python_version(mctx): defaults_attr.python_version_file, onerror = _fail_multiple_defaults_python_version_file, ) + + # Priority order: ENV > pyproject_toml > python_version_file > python_version if default_python_version_file: default_python_version = _one_or_the_same( default_python_version, mctx.read(default_python_version_file, watch = "yes").strip(), ) + if pyproject_toml_label: + pyproject = read_pyproject(mctx, pyproject_toml_label) + if pyproject.requires_python: + default_python_version = version_from_requires_python(pyproject.requires_python) if default_python_version_env: default_python_version = mctx.getenv( default_python_version_env, @@ -1034,11 +1049,29 @@ def _create_defaults_attr_struct(*, tag): python_version = getattr(tag, "python_version", None), python_version_env = getattr(tag, "python_version_env", None), python_version_file = getattr(tag, "python_version_file", None), + pyproject_toml = getattr(tag, "pyproject_toml", None), ) -def _create_toolchain_attr_structs(*, mod, config, seen_versions): +def _create_toolchain_attr_structs(*, mod, config, seen_versions, default_python_version): arg_structs = [] + # Auto-register a toolchain for the default version if not already + # registered via an explicit python.toolchain() call. + # This works for any default source: pyproject_toml, python_version_file, + # python_version_env, or python_version. + has_explicit_toolchain = default_python_version and any([ + tag.python_version == default_python_version + for tag in mod.tags.toolchain + ]) + if (default_python_version and + default_python_version not in seen_versions and + mod.is_root and not has_explicit_toolchain): + arg_structs.append(_create_toolchain_attrs_struct( + python_version = default_python_version, + toolchain_tag_count = 1, + )) + seen_versions[default_python_version] = True + for tag in mod.tags.toolchain: arg_structs.append(_create_toolchain_attrs_struct( tag = tag, @@ -1078,6 +1111,17 @@ def _create_toolchain_attrs_struct( _defaults = tag_class( doc = """Tag class to specify the default Python version.""", attrs = { + "pyproject_toml": attr.label( + mandatory = False, + doc = """\ +Label pointing to pyproject.toml file to read the default Python version from. +When specified, reads the `requires-python` field from pyproject.toml. +The version must be specified as `==X.Y.Z` (exact version with full semver). + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + ), "python_version": attr.string( mandatory = False, doc = """\ diff --git a/tests/pypi/extension/extension_tests.bzl b/tests/pypi/extension/extension_tests.bzl index c2e0c8b60f..97849e882d 100644 --- a/tests/pypi/extension/extension_tests.bzl +++ b/tests/pypi/extension/extension_tests.bzl @@ -24,7 +24,7 @@ load(":pip_parse.bzl", _parse = "pip_parse") _tests = [] -def _pypi_mock_mctx(*modules, os_name = "unittest", arch_name = "exotic", environ = {}, read = None): +def _pypi_mock_mctx(*modules, os_name = "unittest", arch_name = "exotic", environ = {}, read = None, mock_files = {}): _ = read # @unused return mocks.mctx( modules = list(modules), @@ -36,7 +36,7 @@ def _pypi_mock_mctx(*modules, os_name = "unittest", arch_name = "exotic", enviro simple==0.0.1 \ --hash=sha256:deadbeef \ --hash=sha256:deadbaaf""", - }, + } | mock_files, ) def _default( @@ -51,6 +51,7 @@ def _default( netrc = None, os_name = None, platform = None, + pyproject_toml = None, whl_platform_tags = None, whl_abi_tags = None): return struct( @@ -64,6 +65,7 @@ def _default( netrc = netrc, os_name = os_name, platform = platform, + pyproject_toml = pyproject_toml, whl_abi_tags = whl_abi_tags or [], whl_platform_tags = whl_platform_tags or [], ) @@ -183,6 +185,58 @@ def _test_simple(env): _tests.append(_test_simple) +def _test_pip_parse_pyproject_toml(env): + # pip.parse() reads the version from pyproject.toml's requires-python when + # python_version is not set explicitly. + pypi = _parse_modules( + env, + module_ctx = _pypi_mock_mctx( + _mod( + name = "rules_python", + parse = [ + _parse( + hub_name = "pypi", + pyproject_toml = "pyproject.toml", + simpleapi_skip = ["simple"], + requirements_lock = "requirements.txt", + ), + ], + ), + os_name = "linux", + arch_name = "x86_64", + mock_files = { + "pyproject.toml": "[project]\nrequires-python = \"==3.15.19\"\n", + }, + ), + available_interpreters = { + "python_3_15_19_host": "unit_test_interpreter_target", + }, + minor_mapping = {"3.15": "3.15.19"}, + ) + + # Resolves identically to passing python_version = "3.15.19" explicitly: + # the full version drives interpreter selection, hub naming uses major.minor. + pypi.exposed_packages().contains_exactly({"pypi": ["simple"]}) + pypi.hub_whl_map().contains_exactly({"pypi": { + "simple": { + "pypi_315_simple": [ + whl_config_setting( + version = "3.15", + ), + ], + }, + }}) + pypi.whl_libraries().contains_exactly({ + "pypi_315_simple": { + "config_load": "@pypi//:config.bzl", + "dep_template": "@pypi//{name}:{target}", + "python_interpreter_target": "unit_test_interpreter_target", + "requirement": "simple==0.0.1 --hash=sha256:deadbeef --hash=sha256:deadbaaf", + }, + }) + +_tests.append(_test_pip_parse_pyproject_toml) + def _test_simple_isolated(env): """Simulate `isolate = True` with parse_modules. diff --git a/tests/pypi/extension/pip_parse.bzl b/tests/pypi/extension/pip_parse.bzl index 939639f2c5..7b5bdfdfd6 100644 --- a/tests/pypi/extension/pip_parse.bzl +++ b/tests/pypi/extension/pip_parse.bzl @@ -3,7 +3,7 @@ def pip_parse( *, hub_name, - python_version, + python_version = None, add_libdir_to_library_search_path = False, auth_patterns = {}, download_only = False, @@ -19,6 +19,7 @@ def pip_parse( netrc = None, parse_all_requirements_files = True, pip_data_exclude = None, + pyproject_toml = None, python_interpreter = None, python_interpreter_target = None, quiet = True, @@ -52,6 +53,7 @@ def pip_parse( netrc = netrc, parse_all_requirements_files = parse_all_requirements_files, pip_data_exclude = pip_data_exclude, + pyproject_toml = pyproject_toml, python_interpreter = python_interpreter, python_interpreter_target = python_interpreter_target, python_version = python_version, From 8396c281295e1a051a6e0ea2f57934d93515e5bc Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 20:00:06 -0700 Subject: [PATCH 057/179] Fix release tool links and improve workflow robustness (#3888) Fixes release tool bugs and improves workflow robustness. - Fix BCR entry link in create-rc to use RC version instead of final version. - Format BCR entry link as 'BCR Entry '. - Fix release workflow status link to use release_create_rc.yaml. - Use GITHUB_RUN_ID to link directly to the workflow run if available. - Link the branch name in the create-rc comment. - Add a always-running job to release_create_release_branch.yaml to suppress 'no jobs ran' errors. - Remove obsolete rocket assertions in create_rc tests. --- .../release_create_release_branch.yaml | 9 ++++ tests/tools/private/release/create_rc_test.py | 52 ++++++++++++++++--- tools/private/release/create_rc.py | 16 ++++-- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release_create_release_branch.yaml b/.github/workflows/release_create_release_branch.yaml index ea030a7085..4c021814d0 100644 --- a/.github/workflows/release_create_release_branch.yaml +++ b/.github/workflows/release_create_release_branch.yaml @@ -35,3 +35,12 @@ jobs: create-release-branch --issue ${{ github.event.issue.number }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # A no-op job that always runs to prevent "no jobs ran" failures + # when the main job is skipped. + suppress-no-jobs-ran-error: + runs-on: ubuntu-latest + steps: + - name: Echo Success + run: echo "Success" + diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 2b4e40cc4e..193f11561d 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -56,23 +56,56 @@ def test_create_rc_success_first_rc(self): "**New Release Candidate Tagged!** 🐍🌿", comment_call_args[1], ) + self.assertIn( + "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)", + comment_call_args[1], + ) self.assertIn( "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)", comment_call_args[1], ) self.assertIn( - "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + "- [BCR Entry 2.0.0-rc0](https://registry.bazel.build/modules/rules_python/2.0.0-rc0)", comment_call_args[1], ) self.assertIn( - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0-rc0)", comment_call_args[1], ) self.assertIn( - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_publish.yaml)", + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml)", + comment_call_args[1], + ) + + def test_create_rc_success_with_run_id(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + with patch.dict(os.environ, {"GITHUB_RUN_ID": "987654321"}): + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_gh.post_issue_comment.assert_called_once() + comment_call_args = self.mock_gh.post_issue_comment.call_args[0] + self.assertIn( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/runs/987654321)", + comment_call_args[1], + ) + self.assertIn( + "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)", comment_call_args[1], ) - self.assertNotIn("🚀", comment_call_args[1]) def test_create_rc_success_next_rc(self): # Arrange @@ -113,23 +146,26 @@ def test_create_rc_success_next_rc(self): "**New Release Candidate Tagged!** 🐍🌿", comment_call_args[1], ) + self.assertIn( + "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)", + comment_call_args[1], + ) self.assertIn( "- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)", comment_call_args[1], ) self.assertIn( - "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + "- [BCR Entry 2.0.0-rc1](https://registry.bazel.build/modules/rules_python/2.0.0-rc1)", comment_call_args[1], ) self.assertIn( - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0-rc1)", comment_call_args[1], ) self.assertIn( - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_publish.yaml)", + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml)", comment_call_args[1], ) - self.assertNotIn("🚀", comment_call_args[1]) def test_create_rc_gating_on_backports(self): # Arrange diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 4e8564a9b8..260cbb8940 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -114,15 +114,21 @@ def run(self) -> int: self.gh.update_issue_body(args.issue, updated_body) tag_url = f"{REPO_URL}/releases/tag/{next_rc}" - bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" - bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" - release_workflow_url = f"{REPO_URL}/actions/workflows/release_publish.yaml" + bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{next_rc}" + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{next_rc}" + if run_id := os.environ.get("GITHUB_RUN_ID"): + release_workflow_url = f"{REPO_URL}/actions/runs/{run_id}" + else: + release_workflow_url = ( + f"{REPO_URL}/actions/workflows/release_create_rc.yaml" + ) + branch_url = f"{REPO_URL}/tree/{branch_name}" comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 -Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. +Release Candidate **{next_rc}** has been successfully generated and tagged on branch [`{branch_name}`]({branch_url}). - [Github Release {next_rc}]({tag_url}) -- BCR Entry: [rules_python@{version}]({bcr_entry_url}) +- [BCR Entry {next_rc}]({bcr_entry_url}) - [BCR PRs]({bcr_search_url}) - [Release workflow status]({release_workflow_url})""" self.gh.post_issue_comment(args.issue, comment_body) From f3fa8dab54e676560c1a886abafc8114a015f67f Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 20:41:45 -0700 Subject: [PATCH 058/179] build(release): support triggering release workflows via comments (#3889) Allows maintainers to trigger release workflows (prepare, create-rc, process-backports) by commenting on the release tracking issue. This automates the release process further and reduces the need to manually trigger workflows from the GitHub Actions UI. - Created a central `on_issue_comment.yaml` workflow to parse comments. - Updated `release_prepare.yaml`, `release_create_rc.yaml`, and `release_process_backports.yaml` to be reusable workflows. - Updated the release tracking issue template to document the new comment commands. --- .../release_tracking_template.md | 24 +++++-- .github/workflows/on_issue_comment.yaml | 68 +++++++++++++++++++ .github/workflows/release_create_rc.yaml | 6 ++ .github/workflows/release_prepare.yaml | 16 ++++- .../workflows/release_process_backports.yaml | 6 ++ 5 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/on_issue_comment.yaml diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md index f573fdfacf..6bae3f3c2c 100644 --- a/.github/ISSUE_TEMPLATE/release_tracking_template.md +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -18,7 +18,7 @@ labels: ['type: release'] To request a backport: 1. Add a new checklist item under the `## Backports` section. 2. The format must be: `- [ ] #` (e.g., `- [ ] #1234`). -3. Trigger the [Process Backports Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/process_backports.yml). +3. Trigger the [Process Backports Workflow][process_backports].
--- @@ -36,8 +36,22 @@ The checklist items use metadata suffix: `| key=value key2=value2`.
Available Commands -Maintainers can trigger automation by running manual workflows: -- [Process Backports Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/process_backports.yml) -- [Generate RC Tag Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/generate_rc.yml) -- [Promote RC to Final Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/promote_rc.yml) +Maintainers can trigger automation by: +- Running manual workflows: + - [Process Backports Workflow][process_backports] + - [Create RC Workflow][create_rc] + - [Promote RC to Final Release Workflow][promote_rc] +- Commenting on this issue (requires the issue to have the `type: release` + label): + - `/prepare` at the beginning of a line to trigger the Release Prepare + workflow. + - `/create-rc` at the beginning of a line to trigger the Create RC + workflow. + - `/process-backports` at the beginning of a line to trigger the Process + Backports workflow. +
+ +[process_backports]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_process_backports.yaml +[create_rc]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml +[promote_rc]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml new file mode 100644 index 0000000000..d37ae1516c --- /dev/null +++ b/.github/workflows/on_issue_comment.yaml @@ -0,0 +1,68 @@ +name: "On Issue Comment" + +on: + issue_comment: + types: [created] + +permissions: + contents: read + issues: read + +jobs: + # This job always runs to prevent GHA from marking the run as failed when + # all other jobs are skipped. + noop: + runs-on: ubuntu-latest + steps: + - run: echo "No-op" + + parse_comment: + runs-on: ubuntu-latest + if: | + github.event.issue.pull_request == null && + contains(github.event.issue.labels.*.name, 'type: release') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + outputs: + command: ${{ steps.parse.outputs.command }} + issue_number: ${{ github.event.issue.number }} + steps: + - name: Parse comment + id: parse + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then + echo "command=create-rc" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then + echo "command=prepare" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then + echo "command=process-backports" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + + call_create_rc: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'create-rc' + uses: ./.github/workflows/release_create_rc.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_prepare: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'prepare' + uses: ./.github/workflows/release_prepare.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_process_backports: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'process-backports' + uses: ./.github/workflows/release_process_backports.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit diff --git a/.github/workflows/release_create_rc.yaml b/.github/workflows/release_create_rc.yaml index 9a58e20c09..eaf6250b85 100644 --- a/.github/workflows/release_create_rc.yaml +++ b/.github/workflows/release_create_rc.yaml @@ -7,6 +7,12 @@ on: description: 'The Release Tracking Issue Number (e.g., 142)' required: true type: string + workflow_call: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string permissions: contents: write diff --git a/.github/workflows/release_prepare.yaml b/.github/workflows/release_prepare.yaml index 5c9a0b8f49..8ca38cb1c2 100644 --- a/.github/workflows/release_prepare.yaml +++ b/.github/workflows/release_prepare.yaml @@ -2,7 +2,17 @@ name: "Release: Prepare" on: workflow_dispatch: - # Allow manual triggering to prepare the release immediately + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: false + type: string + workflow_call: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: false + type: string permissions: contents: write @@ -30,7 +40,7 @@ jobs: - name: Run Release Preparation Pipeline run: | - # Manual trigger: run full preparation - bazel run //tools/private/release -- prepare --no-dry-run + bazel run //tools/private/release -- \ + prepare ${{ inputs.issue && format('--issue={0}', inputs.issue) || '' }} --no-dry-run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml index 822a337323..0812aa5248 100644 --- a/.github/workflows/release_process_backports.yaml +++ b/.github/workflows/release_process_backports.yaml @@ -7,6 +7,12 @@ on: description: 'The Release Tracking Issue Number (e.g., 142)' required: true type: string + workflow_call: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string permissions: contents: write From b2d1bf0b26d3ff736019b48034b0bcee55ce9660 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 00:57:00 -0700 Subject: [PATCH 059/179] chore(release): allow comments to trigger actions, update releasing docs (#3891) Updates the release documentation to reflect the new process using the Release Tracking Issue, comments (/prepare, /create-rc, /process-backports), and automated workflows. --- .../release_tracking_template.md | 43 ++----- .github/workflows/on_issue_comment.yaml | 37 +++++- .../workflows/release_process_backports.yaml | 31 ++++- .github/workflows/release_promote_rc.yaml | 23 +++- RELEASING.md | 113 ++++++++++-------- .../private/release/process_backports_test.py | 27 +++-- .../tools/private/release/promote_rc_test.py | 89 ++++++++++---- tools/private/release/gh.py | 33 +++++ tools/private/release/process_backports.py | 53 +++++++- tools/private/release/promote_rc.py | 72 ++++++++++- tools/private/release/release_issue.py | 49 +++++++- tools/private/release/utils.py | 22 ++++ 12 files changed, 468 insertions(+), 124 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md index 6bae3f3c2c..57a51a16ef 100644 --- a/.github/ISSUE_TEMPLATE/release_tracking_template.md +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -12,46 +12,21 @@ labels: ['type: release'] ## Backports -
-How to add backports - -To request a backport: -1. Add a new checklist item under the `## Backports` section. -2. The format must be: `- [ ] #` (e.g., `- [ ] #1234`). -3. Trigger the [Process Backports Workflow][process_backports]. -
+To request a backport, add it to the checklist below and process it. See [RELEASING.md: How to add backports](https://github.com/bazel-contrib/rules_python/blob/main/RELEASING.md#how-to-add-backports) for details. --- -*Maintainers: Automation will react to changes on this issue.* -
-Manual Editing - -You can manually edit this issue to control the release flow. -The checklist items use metadata suffix: `| key=value key2=value2`. -- **Retry Prepare Release**: Reset to `- [ ] Prepare Release | status=awaiting-preparation`. -- **Force Task Done**: Check the box `- [x]` and add appropriate metadata (e.g. `status=done`). -
+To manually control the release flow, see the [RELEASING.md: Manual Editing](https://github.com/bazel-contrib/rules_python/blob/main/RELEASING.md#manual-editing-of-tracking-issue) section.
Available Commands -Maintainers can trigger automation by: -- Running manual workflows: - - [Process Backports Workflow][process_backports] - - [Create RC Workflow][create_rc] - - [Promote RC to Final Release Workflow][promote_rc] -- Commenting on this issue (requires the issue to have the `type: release` - label): - - `/prepare` at the beginning of a line to trigger the Release Prepare - workflow. - - `/create-rc` at the beginning of a line to trigger the Create RC - workflow. - - `/process-backports` at the beginning of a line to trigger the Process - Backports workflow. +Comment commands: +- `/prepare`: Determines version, creates tracking issue and preparation PR. +- `/create-rc`: Tags and publishes a new release candidate (RC). +- `/process-backports`: Cherry-picks pending backports. +- `/add-backports `: Adds PRs to the backports and processes backports. +- `/promote`: Promotes the latest RC to final release. +See [RELEASING.md](https://github.com/bazel-contrib/rules_python/blob/main/RELEASING.md) for details on how to use them.
- -[process_backports]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_process_backports.yaml -[create_rc]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml -[promote_rc]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml index d37ae1516c..4be5906596 100644 --- a/.github/workflows/on_issue_comment.yaml +++ b/.github/workflows/on_issue_comment.yaml @@ -27,11 +27,13 @@ jobs: outputs: command: ${{ steps.parse.outputs.command }} issue_number: ${{ github.event.issue.number }} + backports: ${{ steps.parse.outputs.backports }} steps: - name: Parse comment id: parse env: COMMENT_BODY: ${{ github.event.comment.body }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then echo "command=create-rc" >> "$GITHUB_OUTPUT" @@ -39,6 +41,27 @@ jobs: echo "command=prepare" >> "$GITHUB_OUTPUT" elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then echo "command=process-backports" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then + args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') + # Strip leading/trailing spaces and commas + args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') + # Replace internal spaces/commas with single comma + csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') + if [ -n "$csv" ]; then + echo "command=add-backports" >> "$GITHUB_OUTPUT" + echo "backports=$csv" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + echo "Error: No PRs specified for add-backports." >&2 + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -f "content=-1" + fi + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then + echo "command=promote" >> "$GITHUB_OUTPUT" else echo "command=none" >> "$GITHUB_OUTPUT" fi @@ -61,8 +84,20 @@ jobs: call_process_backports: needs: parse_comment - if: needs.parse_comment.outputs.command == 'process-backports' + if: | + needs.parse_comment.outputs.command == 'process-backports' || + needs.parse_comment.outputs.command == 'add-backports' uses: ./.github/workflows/release_process_backports.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + add_backports: ${{ needs.parse_comment.outputs.command == 'add-backports' && needs.parse_comment.outputs.backports || '' }} + comment_id: "${{ github.event.comment.id }}" + secrets: inherit + + call_promote: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'promote' + uses: ./.github/workflows/release_promote_rc.yaml with: issue: ${{ needs.parse_comment.outputs.issue_number }} secrets: inherit diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml index 0812aa5248..394a5b0fe9 100644 --- a/.github/workflows/release_process_backports.yaml +++ b/.github/workflows/release_process_backports.yaml @@ -7,12 +7,28 @@ on: description: 'The Release Tracking Issue Number (e.g., 142)' required: true type: string + add_backports: + description: 'CSV list of PR numbers to add and process (optional)' + required: false + type: string + comment_id: + description: 'The ID of the comment that triggered this run (optional)' + required: false + type: string workflow_call: inputs: issue: description: 'The Release Tracking Issue Number (e.g., 142)' required: true type: string + add_backports: + description: 'CSV list of PR numbers to add and process (optional)' + required: false + type: string + comment_id: + description: 'The ID of the comment that triggered this run (optional)' + required: false + type: string permissions: contents: write @@ -40,7 +56,18 @@ jobs: - name: Process Pending Backports run: | - bazel run //tools/private/release -- \ - process-backports --issue ${{ inputs.issue }} --remote origin --no-dry-run + ARGS=() + if [ -n "${{ inputs.add_backports }}" ]; then + ARGS+=("--add=${{ inputs.add_backports }}") + fi + if [ -n "${{ inputs.comment_id }}" ]; then + ARGS+=("--triggering-comment=${{ inputs.comment_id }}") + fi + + bazel run //tools/private/release -- process-backports \ + --issue ${{ inputs.issue }} \ + --remote origin \ + --no-dry-run \ + "${ARGS[@]}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote_rc.yaml index 5a0ad72a80..987583a1fd 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote_rc.yaml @@ -7,6 +7,16 @@ on: description: 'The final version to release (e.g., 0.38.0)' required: true type: string + workflow_call: + inputs: + version: + description: 'The final version to release (e.g., 0.38.0)' + required: false + type: string + issue: + description: 'The tracking issue number' + required: true + type: string permissions: contents: write @@ -33,7 +43,16 @@ jobs: - name: Run Promote RC run: | - bazel run //tools/private/release -- \ - promote-rc ${{ inputs.version }} --remote origin + ARGS=() + if [ -n "${{ inputs.version }}" ]; then + ARGS+=("${{ inputs.version }}") + fi + if [ -n "${{ inputs.issue }}" ]; then + ARGS+=("--issue" "${{ inputs.issue }}") + fi + ARGS+=("--remote" "origin") + ARGS+=("--no-dry-run") + + bazel run //tools/private/release -- promote-rc "${ARGS[@]}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/RELEASING.md b/RELEASING.md index 13eb4ddc0a..528450f611 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,54 +8,40 @@ existing Bazel workspace to sanity check functionality. ## Releasing from HEAD -These are the steps for a regularly scheduled release from HEAD. +Releases are managed using a semi-automated process centered around a GitHub +Release Tracking Issue and automated workflows triggered by comments or issue edits. + +> [!NOTE] +> Comment-based commands must be posted by project maintainers (Owner, +> Member, or Collaborator) and must be on their own line (leading and trailing +> whitespace is ignored). ### Steps -1. Update the changelog and replace the version placeholders by running the - release tool. The next version number will be automatically determined - based on the presence of `VERSION_NEXT_*` placeholders and git tags. The - tool will read all news entry files in the `news/` directory, assemble - them into the changelog, and delete the processed news files. - - ```shell - bazel run //tools/private/release - ``` - - If you want to append news entries to an already existing release section in - the changelog (for example, to update a drafted release or a release - branch), you can specify the version explicitly: - - ```shell - bazel run //tools/private/release -- X.Y.Z - ``` - -1. Send these changes for review and get them merged. -1. Create a branch for the new release, named `release/X.Y` - ``` - git branch --no-track release/X.Y upstream/main && git push upstream release/X.Y - ``` - -The next step is to create tags to trigger release workflow, **however** -we start by using release candidate tags (`X.Y.Z-rcN`) before tagging the -final release (`X.Y.Z`). - -1. Create release candidate tag and push. The first RC uses `N=0`. Increment - `N` for each RC. - ``` - git tag X.Y.0-rcN upstream/release/X.Y && git push upstream tag X.Y.0-rcN - ``` -2. Announce the RC release: see [Announcing Releases] -3. Wait a week for feedback. - * Follow [Patch release with cherry picks] to pull bug fixes into the - release branch. - * Repeat the RC tagging step, incrementing `N`. -4. Finally, tag the final release tag: - ```shell - git tag X.Y.0 upstream/release/X.Y && git push upstream tag X.Y.0 - ``` - -Release automation will create a GitHub release and BCR pull request. +1. **Prepare the Release**: Run the [Release: Prepare](https://github.com/bazel-contrib/rules_python/actions/workflows/release_prepare.yaml) + workflow manually. You can trigger it from the GitHub Actions UI or using + the GitHub CLI: + ```shell + gh workflow run release_prepare.yaml --repo bazel-contrib/rules_python + ``` + This will automatically determine the next version, create a release tracking + issue, and send a preparation PR. + +2. **Approve and Merge**: Approve and merge the PR. Once merged, a release + branch will be created automatically. + +3. **Add Backports (if needed)**: If there are backports, add them following + the [How to add backports](#how-to-add-backports) steps. + +4. **Create an RC**: Comment `/create-rc` on the tracking issue. All pending + backports must be successfully processed before creating the RC. + +5. **Iterate**: Repeat steps 3 and 4 until backports and RCs are no longer + needed. + +6. **Finalize the Release**: Comment `/promote` on the tracking issue to + finalize the release. + ### Manually triggering the release workflow @@ -87,6 +73,31 @@ the `VERSION_NEXT_*` placeholders in the codebase. To see what changes are being accumulated for the next release, review the pending news entries in the `news/` directory. +## How to add backports + +To add backports to an active release, you can use one of the following +methods: + +### Method A: Manual Checklist Update +1. Manually add checklist items under the `## Backports` section of the + Release Tracking Issue. The format must be: `- [ ] #` (e.g., + `- [ ] #1234`). +2. When ready, comment `/process-backports` on the tracking issue to trigger + processing. + +### Method B: Comment Shortcut +1. Comment `/add-backports [ ...]` (space or comma + separated) on the tracking issue. This will automatically add the PRs to the + checklist and trigger processing. + +### Failure Behavior +If a backport fails to process (e.g., due to cherry-pick conflicts): +* The failed backport checklist item will remain unchecked with + `status=error-`. +* You must resolve the conflict manually: checkout the release branch, + cherry-pick the PR, resolve conflicts, push to remote, and manually check + the box on the tracking issue checklist with `status=done` metadata. + ## Patch release with cherry picks If a patch release from head would contain changes that aren't appropriate for @@ -105,8 +116,8 @@ The fix being included is commit `deadbeef`. If multiple commits need to be applied, repeat the `git cherry-pick` step for each. -Once the release branch is in the desired state, use `git tag` to tag it, as -done with a release from head. Release automation will do the rest. +Once the release branch is in the desired state, comment `/create-rc` on the +tracking issue to tag it, as done with a release from head. ### Announcing releases @@ -139,6 +150,14 @@ The two points of no return are: If release steps fail _prior_ to those steps, then its OK to change the tag. You may need to manually delete the GitHub release. +## Manual Editing of Tracking Issue + +You can manually edit the Release Tracking Issue to control the release flow. +The checklist items use metadata suffix: `| key=value key2=value2`. + +* **Retry Prepare Release**: Reset the task to `- [ ] Prepare Release | status=awaiting-preparation`. +* **Force Task Done**: Check the box `- [x]` and add appropriate metadata (e.g. `status=done`). + ## Secrets ### PyPI user rules-python diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py index 0ceb7b3210..a0de7c8566 100644 --- a/tests/tools/private/release/process_backports_test.py +++ b/tests/tools/private/release/process_backports_test.py @@ -1,6 +1,7 @@ +import argparse import datetime import unittest -from unittest.mock import MagicMock, call, patch +from unittest.mock import call, patch from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.process_backports import ProcessBackports @@ -18,7 +19,9 @@ def setUp(self): self.addCleanup(patch.stopall) def test_process_backports_no_pending(self): - args = MagicMock(issue=123, remote="origin", dry_run=False) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) self.mock_gh.get_issue_body.return_value = "No backports here" result = ProcessBackports(args, self.mock_git, self.mock_gh).run() @@ -30,7 +33,9 @@ def test_process_backports_no_pending(self): @patch("tools.private.release.process_backports.datetime") def test_process_backports_success(self, mock_datetime): mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = MagicMock(issue=123, remote="origin", dry_run=False) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -83,7 +88,9 @@ def mock_resolve(items): @patch("tools.private.release.process_backports.datetime") def test_process_backports_dry_run(self, mock_datetime): mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = MagicMock(issue=123, remote="origin", dry_run=True) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=True, add=None, triggering_comment=None + ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -131,7 +138,9 @@ def mock_resolve(items): self.mock_gh.update_issue_body.assert_not_called() def test_process_backports_ignored_and_failed_states(self): - args = MagicMock(issue=123, remote="origin", dry_run=False) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -170,7 +179,9 @@ def mock_resolve(items): self.mock_git.cherry_pick.assert_not_called() def test_process_backports_ignored_error_status(self): - args = MagicMock(issue=123, remote="origin", dry_run=False) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -193,7 +204,9 @@ def test_process_backports_ignored_error_status(self): @patch("tools.private.release.process_backports.datetime") def test_process_backports_cherry_pick_failed(self, mock_datetime): mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = MagicMock(issue=123, remote="origin", dry_run=False) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_rc_test.py index 71980dc1fd..68797e2e1e 100644 --- a/tests/tools/private/release/promote_rc_test.py +++ b/tests/tools/private/release/promote_rc_test.py @@ -1,5 +1,6 @@ +import argparse import unittest -from unittest.mock import MagicMock, call, patch +from unittest.mock import call, patch from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.gh import NoTrackingIssueError @@ -12,7 +13,9 @@ def setUp(self): def test_promote_rc_success(self): # Arrange - args = MagicMock(version="2.0.0", issue=123, dry_run=False, remote="my-remote") + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=False, remote="my-remote" + ) self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False @@ -24,8 +27,15 @@ def test_promote_rc_success(self): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) + self.mock_git.get_commit_sha.assert_has_calls( + [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")] + ) self.mock_git.checkout.assert_not_called() self.mock_git.tag_exists.assert_called_once_with("2.0.0") self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") @@ -48,7 +58,9 @@ def test_promote_rc_success(self): def test_promote_rc_resolve_issue_success(self): # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False, remote="my-remote") + args = argparse.Namespace( + version="2.0.0", issue=None, dry_run=False, remote="my-remote" + ) self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = None @@ -62,9 +74,16 @@ def test_promote_rc_resolve_issue_success(self): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + self.mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.get_commit_sha.assert_has_calls( + [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")] + ) self.mock_git.checkout.assert_not_called() self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") @@ -82,11 +101,12 @@ def test_promote_rc_resolve_issue_success(self): ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - def test_promote_rc_defaults_to_determine_next_version(self): + def test_promote_rc_resolves_version_from_issue(self): # Arrange - args = MagicMock(version=None, issue=123, dry_run=False, remote="my-remote") - self.mock_git.get_current_branch.return_value = "release/2.0" - self.mock_git.get_tags.return_value = ["2.0.0"] + args = argparse.Namespace( + version=None, issue=123, dry_run=False, remote="my-remote" + ) + self.mock_gh.get_issue_title.return_value = "Release 2.0.1" self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"] self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.tag_exists.return_value = False @@ -98,13 +118,20 @@ def test_promote_rc_defaults_to_determine_next_version(self): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - self.mock_git.get_current_branch.assert_called_once() - self.mock_git.get_tags.assert_called_once() + self.mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) + self.mock_git.get_current_branch.assert_not_called() + self.mock_git.get_tags.assert_not_called() self.mock_git.get_remote_tags.assert_called_once_with("my-remote") self.mock_git.checkout.assert_not_called() - self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0") + self.mock_git.get_commit_sha.assert_has_calls( + [call("2.0.1-rc0"), call(remote_ref="my-remote/release/2.0")] + ) self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") self.mock_git.push.assert_called_once_with("my-remote", "2.0.1") @@ -124,7 +151,9 @@ def test_promote_rc_defaults_to_determine_next_version(self): @patch("builtins.print") def test_promote_rc_dry_run_success(self, mock_print): # Arrange - args = MagicMock(version="2.0.0", issue=123, dry_run=True, remote="my-remote") + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=True, remote="my-remote" + ) self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False @@ -136,8 +165,15 @@ def test_promote_rc_dry_run_success(self, mock_print): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1") + self.mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) + self.mock_git.get_commit_sha.assert_has_calls( + [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")] + ) self.mock_git.tag_exists.assert_called_once_with("2.0.0") # Core dry-run assertions: NO modifications @@ -149,6 +185,7 @@ def test_promote_rc_dry_run_success(self, mock_print): mock_print.assert_has_calls( [ call("Verifying tracking issue #123 format..."), + call("Fetching remote branch my-remote/release/2.0..."), call( "[DRY RUN] Pre-conditions passed successfully for promoting" " 2.0.0-rc1 to 2.0.0." @@ -162,7 +199,9 @@ def test_promote_rc_dry_run_success(self, mock_print): def test_promote_rc_tag_already_exists(self): # Arrange - args = MagicMock(version="2.0.0", issue=123, remote="my-remote") + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=False, remote="my-remote" + ) self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = True @@ -179,7 +218,9 @@ def test_promote_rc_tag_already_exists(self): def test_promote_rc_issue_not_found(self): # Arrange - args = MagicMock(version="2.0.0", issue=None, remote="my-remote") + args = argparse.Namespace( + version="2.0.0", issue=None, dry_run=False, remote="my-remote" + ) self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( @@ -199,7 +240,9 @@ def test_promote_rc_issue_not_found(self): def test_promote_rc_issue_malformed(self): # Arrange - args = MagicMock(version="2.0.0", issue=123, remote="my-remote") + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=False, remote="my-remote" + ) self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_git.get_commit_sha.return_value = "abcdef123456" @@ -219,7 +262,9 @@ def test_promote_rc_issue_malformed(self): def test_promote_rc_no_rc_found(self): # Arrange - args = MagicMock(version="2.0.0", issue=123, remote="my-remote") + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=False, remote="my-remote" + ) self.mock_git.get_remote_tags.return_value = [] # Act diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 8b938aecf6..e670fc98df 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -7,6 +7,17 @@ from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd +# GitHub reaction types +# See: https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions +GH_REACTION_THUMBS_UP = "+1" +GH_REACTION_THUMBS_DOWN = "-1" +GH_REACTION_LAUGH = "laugh" +GH_REACTION_CONFUSED = "confused" +GH_REACTION_HEART = "heart" +GH_REACTION_HOORAY = "hooray" +GH_REACTION_ROCKET = "rocket" +GH_REACTION_EYES = "eyes" + class MultipleTrackingIssuesError(ValueError): """Raised when multiple open tracking issues are found for a version.""" @@ -319,6 +330,28 @@ def post_issue_comment(self, issue_num: int, comment_body: str) -> None: capture_output=False, ) + def add_comment_reaction(self, comment_id: int, reaction: str) -> None: + """Adds a reaction to a comment. + + Args: + comment_id: The ID of the comment. + reaction: The reaction type (e.g. '+1', '-1', 'eyes', etc). + """ + path = f"/repos/{self.repo}/issues/comments/{comment_id}/reactions" + self._run_gh( + "api", + "--method", + "POST", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + path, + "-f", + f"content={reaction}", + capture_output=False, + ) + def get_merge_commits_for_prs( self, pending_items: list[BackportTask] ) -> list[BackportTask]: diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 83394bd27e..f0308d7847 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -5,15 +5,17 @@ from typing import Any from tools.private.release import changelog_news -from tools.private.release.gh import GitHub +from tools.private.release.gh import GH_REACTION_THUMBS_DOWN, GitHub from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, + add_backports_to_body, parse_backports, update_task_in_body, ) from tools.private.release.utils import ( get_latest_rc_tag, + parse_pr_list, replace_version_next, ) @@ -178,7 +180,46 @@ def _cherry_pick_and_update_prs( def run(self) -> int: """Executes the process-backports subcommand.""" args = self.args + exit_code = 0 + try: + exit_code = self._run_internal() + except Exception as e: + print(f"Unexpected error: {e}") + exit_code = 1 + + if exit_code != 0 and args.triggering_comment: + print(f"Reacting with thumbs-down to comment {args.triggering_comment}...") + try: + self.gh.add_comment_reaction( + args.triggering_comment, GH_REACTION_THUMBS_DOWN + ) + except Exception as e: + print(f"Failed to add reaction to comment: {e}") + + return exit_code + + def _run_internal(self) -> int: + """Internal implementation of process-backports.""" + args = self.args body = self.gh.get_issue_body(args.issue) + + if args.add: + print(f"Adding backports {args.add} to tracking issue #{args.issue}...") + try: + body = add_backports_to_body(body, args.add) + except ValueError as e: + print(f"Error: {e}") + return 1 + + if not args.dry_run: + self.gh.update_issue_body(args.issue, body) + print("Successfully updated tracking issue checklist.") + else: + print( + "[DRY RUN] Would update tracking issue checklist with new" + " backports." + ) + items = parse_backports(body) pending_items = [ @@ -295,6 +336,16 @@ def add_parser(cls, subparsers): required=True, help="The git remote to push changes to (required).", ) + parser.add_argument( + "--add", + type=parse_pr_list, + help="PR numbers (comma or space separated) to add before processing.", + ) + parser.add_argument( + "--triggering-comment", + type=int, + help="The ID of the comment that triggered this run (optional).", + ) parser.add_argument( "--dry-run", action=argparse.BooleanOptionalAction, diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py index 80b4ac8065..ac3a841526 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote_rc.py @@ -5,7 +5,10 @@ from tools.private.release.gh import GitHub from tools.private.release.git import Git -from tools.private.release.release_issue import update_task_in_body +from tools.private.release.release_issue import ( + RELEASE_TITLE_RE, + update_task_in_body, +) from tools.private.release.utils import ( REPO_URL, determine_next_version, @@ -30,7 +33,23 @@ def run(self) -> int: version = args.version if version is None: - version = determine_next_version() + if args.issue: + issue_title = self.gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if version_match: + version = version_match.group(1) + print( + f"Resolved version {version} from tracking issue" + f" #{args.issue} title." + ) + else: + print( + f"Error: Could not parse version from issue title:" + f" {issue_title}" + ) + return 1 + else: + version = determine_next_version() latest_rc = get_latest_rc_tag(version, remote=args.remote) if not latest_rc: @@ -57,9 +76,56 @@ def run(self) -> int: # Get commit SHA of the RC tag (which will be the same for the final tag) commit_sha = self.git.get_commit_sha(latest_rc) - # Verify issue is in the right format by trying to prepare the update + # Verify issue can be found and read it early print(f"Verifying tracking issue #{issue_num} format...") body = self.gh.get_issue_body(issue_num) + + # Determine release branch name and verify it matches the RC tag commit + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + remote_branch = f"{args.remote}/{branch_name}" + + print(f"Fetching remote branch {remote_branch}...") + self.git.fetch(args.remote, refspec=branch_name) + try: + branch_sha = self.git.get_commit_sha(remote_ref=remote_branch) + except Exception as e: + print( + f"Error: Could not get commit SHA for remote branch" + f" {remote_branch}: {e}" + ) + return 1 + + if commit_sha != branch_sha: + print( + f"Error: The latest RC tag {latest_rc} ({commit_sha[:8]}) is not at" + f" the head of release branch {remote_branch} ({branch_sha[:8]})." + ) + metadata = { + "status": "error-rc-tag-not-branch-head", + "rc": latest_rc, + "branch_commit": branch_sha[:8], + "tag_commit": commit_sha[:8], + } + try: + updated_body = update_task_in_body( + body, "Tag Final", checked=False, metadata=metadata + ) + except ValueError as e: + print(f"Error: Tracking issue #{issue_num} is malformed: {e}") + return 1 + + if not args.dry_run: + self.gh.update_issue_body(issue_num, updated_body) + print(f"Updated tracking issue #{issue_num} with error status.") + else: + print( + f"[DRY RUN] Would update tracking issue #{issue_num} with" + f" error status." + ) + return 1 + + # Verify issue is in the right format by trying to prepare the update (for success case) metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} try: updated_body = update_task_in_body( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 32e0a0fee7..75d00759ec 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -115,11 +115,11 @@ def format_metadata_line(checked, name, metadata): metadata_pairs = [] for k, v in metadata.items(): - if k == "commit": - # The 'commit' key is special-cased with a space after '=' so that - # GitHub autolinks the commit SHA. Autolinking requires certain - # characters to precede the value. - metadata_pairs.append(f"commit= {v}") + if k == "commit" or k.endswith("_commit"): + # The 'commit' key (and keys ending with '_commit') is special-cased with + # a space after '=' so that GitHub autolinks the commit SHA. Autolinking + # requires certain characters to precede the value. + metadata_pairs.append(f"{k}= {v}") else: metadata_pairs.append(f"{k}={v}") metadata_str = " ".join(metadata_pairs) @@ -245,3 +245,42 @@ def parse_backports(body): ) ) return items + + +def add_backports_to_body(body: str, prs: list[int]) -> str: + """Adds new backport checklist items to the ## Backports section.""" + body = body.replace("\r\n", "\n") + # Find the Backports section + pattern = r"(## Backports\n)(.*?)(?=\n##|\n---|\Z)" + match = re.search(pattern, body, re.DOTALL | re.IGNORECASE) + if not match: + raise ValueError("Could not find '## Backports' section in issue body.") + + section_content = match.group(2) + + # Parse existing backports to avoid duplicates + existing_items = parse_backports(body) + existing_prs = { + int(item.pr_ref.lstrip("#")) + for item in existing_items + if item.pr_ref.startswith("#") + } + + new_lines = [] + for pr in prs: + if pr in existing_prs: + print(f"PR #{pr} is already in the backports list. Skipping.") + continue + new_lines.append(f"- [ ] #{pr}") + + if not new_lines: + return body + + # Append new lines to the section content. + section_content_clean = section_content.rstrip("\n") + separator = "\n" if section_content_clean else "" + updated_section = section_content_clean + separator + "\n".join(new_lines) + "\n\n" + + # Replace the old section with the updated one + start, end = match.span(2) + return body[:start] + updated_section + body[end:] diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index e774c80134..db7b96a378 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -172,3 +172,25 @@ def replace_version_next(version): new_content = new_content.replace("VERSION_NEXT_PATCH", version) with open(filepath, "w") as f: f.write(new_content) + + +def parse_pr_list(value: str) -> list[int]: + """Parses a comma or space separated list of PR numbers. + + PR numbers can optionally be prefixed with '#'. + """ + if not value: + return [] + # Split by space and/or comma + pr_strings = [p for p in re.split(r"[\s,]+", value.strip()) if p] + prs = [] + for pr_str in pr_strings: + clean_pr_str = pr_str.lstrip("#") + try: + prs.append(int(clean_pr_str)) + except ValueError as e: + raise argparse.ArgumentTypeError( + f"Invalid PR reference '{pr_str}'. Must be integer optionally" + f" prefixed with '#'." + ) from e + return prs From 1f877845ae26f19e2e214d74990677da167c8b67 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 01:00:36 -0700 Subject: [PATCH 060/179] chore(release): grant write permissions to on_issue_comment workflow (#3892) Update on_issue_comment.yaml to request contents: write and issues: write permissions, which are required for the called reusable workflows. --- .github/workflows/on_issue_comment.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml index 4be5906596..9c6a3eeb6d 100644 --- a/.github/workflows/on_issue_comment.yaml +++ b/.github/workflows/on_issue_comment.yaml @@ -5,8 +5,8 @@ on: types: [created] permissions: - contents: read - issues: read + contents: write + issues: write jobs: # This job always runs to prevent GHA from marking the run as failed when From e3c88670ae4706814b0ef339587074784c2bf07a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 01:04:30 -0700 Subject: [PATCH 061/179] chore(release): add pull-requests: write permission to on_issue_comment workflow (#3893) Update on_issue_comment.yaml to request pull-requests: write permission, which is required for release_prepare.yaml. --- .github/workflows/on_issue_comment.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml index 9c6a3eeb6d..02af5272e6 100644 --- a/.github/workflows/on_issue_comment.yaml +++ b/.github/workflows/on_issue_comment.yaml @@ -7,6 +7,7 @@ on: permissions: contents: write issues: write + pull-requests: write jobs: # This job always runs to prevent GHA from marking the run as failed when From 12ce31bb30e1bb3e6f347c92bbcb1dd237a590b5 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 01:15:20 -0700 Subject: [PATCH 062/179] chore(release): auto-add RC tasks to checklist in create-rc (#3894) Modify create-rc subcommand to automatically insert missing Tag RC tasks into the tracking issue checklist before Tag Final, rather than failing. --- tests/tools/private/release/create_rc_test.py | 41 +++++++++++++ .../private/release/process_backports_test.py | 58 +++++++++++++++++++ tools/private/release/create_rc.py | 25 ++++---- tools/private/release/process_backports.py | 17 ++++++ tools/private/release/release_issue.py | 29 ++++++++++ 5 files changed, 157 insertions(+), 13 deletions(-) diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 193f11561d..0937dfbac6 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -1,3 +1,4 @@ +import argparse import os import pathlib import tempfile @@ -212,6 +213,46 @@ def test_create_rc_with_finished_backports(self): self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + def test_create_rc_auto_add_task(self): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag Final +""" + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") + + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] + call2_args = self.mock_gh.update_issue_body.call_args_list[1][0] + + self.assertEqual(call1_args[0], 123) + self.assertIn("- [ ] Tag RC1", call1_args[1]) + self.assertIn( + "- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]" + " Tag RC1\n- [ ] Tag Final", + call1_args[1].strip(), + ) + + self.assertEqual(call2_args[0], 123) + self.assertIn( + "- [x] Tag RC1 | status=done tag=2.0.0-rc1 commit= 12345678", + call2_args[1], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py index a0de7c8566..639171f59f 100644 --- a/tests/tools/private/release/process_backports_test.py +++ b/tests/tools/private/release/process_backports_test.py @@ -247,6 +247,64 @@ def mock_resolve(items): self.mock_git.commit.assert_not_called() self.mock_git.push.assert_not_called() + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, + remote="origin", + dry_run=False, + add=[124], + triggering_comment=None, + ) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag Final + +## Backports +""" + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + + # update_issue_body should be called twice: + # 1. When adding backports and auto-adding Tag RC1 task. + # 2. When updating the backport status to done. + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + + call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] + call2_args = self.mock_gh.update_issue_body.call_args_list[1][0] + + self.assertEqual(call1_args[0], 123) + self.assertIn("- [ ] #124", call1_args[1]) + self.assertIn("- [ ] Tag RC1", call1_args[1]) + self.assertIn( + "- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]" + " Tag RC1\n- [ ] Tag Final", + call1_args[1].strip(), + ) + + self.assertEqual(call2_args[0], 123) + self.assertIn("- [x] #124 | status=done rc=rc1 commit= 12345678", call2_args[1]) + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 260cbb8940..abc91a8273 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -4,6 +4,7 @@ from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, + add_rc_task_to_body, parse_backports, parse_checklist_state, update_task_in_body, @@ -77,19 +78,17 @@ def run(self) -> int: # Precheck: next RC number must exist and be unchecked in the checklist rc_tags = state.get("rc_tags", {}) if next_rc_num not in rc_tags: - print( - f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" - f" to cut {version}-rc{next_rc_num}." - ) - return 1 - - target_rc_task = rc_tags[next_rc_num] - if target_rc_task.checked or target_rc_task.status == "done": - print( - f"Error: Task 'Tag RC{next_rc_num}' is already marked done in" - " the checklist." - ) - return 1 + print(f"Task 'Tag RC{next_rc_num}' not found in checklist. Adding it...") + body = add_rc_task_to_body(body, next_rc_num) + self.gh.update_issue_body(args.issue, body) + else: + target_rc_task = rc_tags[next_rc_num] + if target_rc_task.checked or target_rc_task.status == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in" + " the checklist." + ) + return 1 target_ref = f"{args.remote}/{branch_name}" commit_sha = self.git.get_commit_sha(target_ref) diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index f0308d7847..81dbe2bd5e 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -10,7 +10,9 @@ from tools.private.release.release_issue import ( RELEASE_TITLE_RE, add_backports_to_body, + add_rc_task_to_body, parse_backports, + parse_checklist_state, update_task_in_body, ) from tools.private.release.utils import ( @@ -207,6 +209,19 @@ def _run_internal(self) -> int: print(f"Adding backports {args.add} to tracking issue #{args.issue}...") try: body = add_backports_to_body(body, args.add) + state = parse_checklist_state(body) + rc_tags = state.get("rc_tags", {}) + has_pending_rc = any( + not task.checked and task.status != "done" + for task in rc_tags.values() + ) + next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0 + if not has_pending_rc: + print( + f"No pending RC task found. Adding 'Tag" + f" RC{next_rc_num}' to checklist..." + ) + body = add_rc_task_to_body(body, next_rc_num) except ValueError as e: print(f"Error: {e}") return 1 @@ -219,6 +234,8 @@ def _run_internal(self) -> int: "[DRY RUN] Would update tracking issue checklist with new" " backports." ) + if not has_pending_rc: + print(f"[DRY RUN] Would add 'Tag RC{next_rc_num}' to checklist.") items = parse_backports(body) diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 75d00759ec..2650b6607c 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -284,3 +284,32 @@ def add_backports_to_body(body: str, prs: list[int]) -> str: # Replace the old section with the updated one start, end = match.span(2) return body[:start] + updated_section + body[end:] + + +def add_rc_task_to_body(body: str, rc_num: int) -> str: + """Adds a new 'Tag RC' task to the checklist in the issue body.""" + body = body.replace("\r\n", "\n") + lines = body.splitlines() + + # Find the index of the last "Tag RC" line + last_rc_idx = -1 + for i, line in enumerate(lines): + parsed = parse_metadata_line(line) + if parsed and re.match(r"Tag RC\d+", parsed["name"], re.IGNORECASE): + last_rc_idx = i + + if last_rc_idx == -1: + # If no RC task found (unexpected, but fallback to before "Tag Final") + for i, line in enumerate(lines): + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == "tag final": + last_rc_idx = i - 1 + break + + if last_rc_idx == -1: + raise ValueError("Could not find a place to insert the new RC task.") + + new_task_line = f"- [ ] Tag RC{rc_num}" + lines.insert(last_rc_idx + 1, new_task_line) + + return "\n".join(lines) From d77d5a401ef474c51e56abb88c3bc03277dd208e Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 01:30:14 -0700 Subject: [PATCH 063/179] fix(release): secure workflows against shell injection (#3895) Pass workflow inputs as environment variables instead of expanding them directly in run scripts to prevent shell injection. The shell expansions were guarded by collaborator checks, but best to prevent any type of injection. --- .github/workflows/release_create_rc.yaml | 7 ++++--- .github/workflows/release_prepare.yaml | 11 ++++++++--- .../workflows/release_process_backports.yaml | 17 ++++++++++------- .github/workflows/release_promote_rc.yaml | 14 ++++++++------ 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release_create_rc.yaml b/.github/workflows/release_create_rc.yaml index eaf6250b85..2c48dd8476 100644 --- a/.github/workflows/release_create_rc.yaml +++ b/.github/workflows/release_create_rc.yaml @@ -41,11 +41,12 @@ jobs: - name: Attempt RC Tagging id: tagger - run: | - bazel run //tools/private/release -- \ - create-rc --issue ${{ inputs.issue }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ inputs.issue }} + run: | + bazel run //tools/private/release -- \ + create-rc --issue "$ISSUE" --remote origin call_release: needs: tag_rc diff --git a/.github/workflows/release_prepare.yaml b/.github/workflows/release_prepare.yaml index 8ca38cb1c2..c83a7b74f9 100644 --- a/.github/workflows/release_prepare.yaml +++ b/.github/workflows/release_prepare.yaml @@ -39,8 +39,13 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Run Release Preparation Pipeline - run: | - bazel run //tools/private/release -- \ - prepare ${{ inputs.issue && format('--issue={0}', inputs.issue) || '' }} --no-dry-run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ inputs.issue }} + run: | + ARGS=() + if [ -n "$ISSUE" ]; then + ARGS+=("--issue=$ISSUE") + fi + bazel run //tools/private/release -- \ + prepare "${ARGS[@]}" --no-dry-run diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml index 394a5b0fe9..ca719778df 100644 --- a/.github/workflows/release_process_backports.yaml +++ b/.github/workflows/release_process_backports.yaml @@ -55,19 +55,22 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Process Pending Backports + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ADD_BACKPORTS: ${{ inputs.add_backports }} + COMMENT_ID: ${{ inputs.comment_id }} + ISSUE: ${{ inputs.issue }} run: | ARGS=() - if [ -n "${{ inputs.add_backports }}" ]; then - ARGS+=("--add=${{ inputs.add_backports }}") + if [ -n "$ADD_BACKPORTS" ]; then + ARGS+=("--add=$ADD_BACKPORTS") fi - if [ -n "${{ inputs.comment_id }}" ]; then - ARGS+=("--triggering-comment=${{ inputs.comment_id }}") + if [ -n "$COMMENT_ID" ]; then + ARGS+=("--triggering-comment=$COMMENT_ID") fi bazel run //tools/private/release -- process-backports \ - --issue ${{ inputs.issue }} \ + --issue "$ISSUE" \ --remote origin \ --no-dry-run \ "${ARGS[@]}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote_rc.yaml index 987583a1fd..7dc1c118b2 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote_rc.yaml @@ -42,17 +42,19 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Run Promote RC + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + ISSUE: ${{ inputs.issue }} run: | ARGS=() - if [ -n "${{ inputs.version }}" ]; then - ARGS+=("${{ inputs.version }}") + if [ -n "$VERSION" ]; then + ARGS+=("$VERSION") fi - if [ -n "${{ inputs.issue }}" ]; then - ARGS+=("--issue" "${{ inputs.issue }}") + if [ -n "$ISSUE" ]; then + ARGS+=("--issue" "$ISSUE") fi ARGS+=("--remote" "origin") ARGS+=("--no-dry-run") bazel run //tools/private/release -- promote-rc "${ARGS[@]}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 1d99f70e5967da59ecd948c0e6775e1e6424d889 Mon Sep 17 00:00:00 2001 From: David Zbarsky Date: Fri, 3 Jul 2026 10:23:47 -0400 Subject: [PATCH 064/179] fix: run executable zip action with execution Python to better support windows building (#3890) The legacy self-executable zip action invokes `cat` through `run_shell`. The action does not declare `cat`, and a Windows-hosted Bazel invocation cannot use that action reliably when the selected execution platform is Linux. Run the existing `exe_zip_maker` through `actions_run` instead. This selects the helper and Python runtime from the execution configuration, preserves the prelude-plus-zip output format, and removes the ambient shell utility dependency. A news entry documents the fix. The Bazel integration that motivated this fix is [bazelbuild/bazel#30121](https://github.com/bazelbuild/bazel/pull/30121). --------- Co-authored-by: Richard Levasseur --- news/3890.fixed.md | 2 ++ python/private/py_executable.bzl | 22 ++++++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 news/3890.fixed.md diff --git a/news/3890.fixed.md b/news/3890.fixed.md new file mode 100644 index 0000000000..59cd5fc5ca --- /dev/null +++ b/news/3890.fixed.md @@ -0,0 +1,2 @@ +(binaries) Fixed building of legacy zipapps on Windows execution platforms by +using a hermetic tool instead of host `cat`. diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index c519af8c09..242b09344e 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -38,6 +38,7 @@ load(":cc_helper.bzl", "cc_helper") load( ":common.bzl", "ExplicitSymlink", + "actions_run", "collect_cc_info", "collect_deps", "collect_imports", @@ -219,6 +220,10 @@ accepting arbitrary Python versions. default = "//python/private:debugger_if_target_config", providers = [PyInfo], ), + "_exe_zip_maker": lambda: attrb.Label( + cfg = "exec", + default = "//tools/private/zipapp:exe_zip_maker", + ), "_launcher": lambda: attrb.Label( cfg = "target", # NOTE: This is an executable, but is only used for Windows. It @@ -1095,15 +1100,16 @@ def _create_executable_zip_file( else: ctx.actions.write(prelude, "#!/usr/bin/env python3\n") - ctx.actions.run_shell( - command = "cat {prelude} {zip} > {output}".format( - prelude = prelude.path, - zip = zip_file.path, - output = output.path, - ), - inputs = [prelude, zip_file], + args = ctx.actions.args() + args.add(prelude) + args.add(zip_file) + args.add(output) + actions_run( + ctx, + executable = ctx.attr._exe_zip_maker, + arguments = [args], + inputs = depset([prelude, zip_file]), outputs = [output], - use_default_shell_env = True, mnemonic = "PyBuildExecutableZip", progress_message = "Build Python zip executable: %{label}", ) From f1db067d634e80a700bc1ce9e5fd3587c170e429 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 08:44:28 -0700 Subject: [PATCH 065/179] chore(release): process backports as part of creating RC (#3896) Automatically process pending backports when `/create-rc` is called. If any backport fails to process (e.g. due to cherry-pick conflicts), the RC creation will abort. Additionally, support `--triggering-comment` option to react with a thumbs-down emoji on the triggering comment if the command fails. - Call `ProcessBackports` in `CreateRc` before proceeding with RC tagging. - Refactor `CreateRc.run` to handle exceptions and reactions. - Update GitHub Actions workflows to pass `comment_id` to `release_create_rc` workflow. - Add unit tests for the new integration and reaction behavior. --- .github/workflows/on_issue_comment.yaml | 1 + .github/workflows/release_create_rc.yaml | 15 ++- RELEASING.md | 5 +- news/process-backports-before-rc.changed.md | 3 + tests/tools/private/release/create_rc_test.py | 124 ++++++++++++++++++ tools/private/release/create_rc.py | 49 ++++++- 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 news/process-backports-before-rc.changed.md diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml index 02af5272e6..14248d0d7a 100644 --- a/.github/workflows/on_issue_comment.yaml +++ b/.github/workflows/on_issue_comment.yaml @@ -73,6 +73,7 @@ jobs: uses: ./.github/workflows/release_create_rc.yaml with: issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" secrets: inherit call_prepare: diff --git a/.github/workflows/release_create_rc.yaml b/.github/workflows/release_create_rc.yaml index 2c48dd8476..a96a04a0ec 100644 --- a/.github/workflows/release_create_rc.yaml +++ b/.github/workflows/release_create_rc.yaml @@ -7,12 +7,20 @@ on: description: 'The Release Tracking Issue Number (e.g., 142)' required: true type: string + comment_id: + description: 'The ID of the comment that triggered this run (optional)' + required: false + type: string workflow_call: inputs: issue: description: 'The Release Tracking Issue Number (e.g., 142)' required: true type: string + comment_id: + description: 'The ID of the comment that triggered this run (optional)' + required: false + type: string permissions: contents: write @@ -44,9 +52,14 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE: ${{ inputs.issue }} + COMMENT_ID: ${{ inputs.comment_id }} run: | + ARGS=() + if [ -n "$COMMENT_ID" ]; then + ARGS+=("--triggering-comment=$COMMENT_ID") + fi bazel run //tools/private/release -- \ - create-rc --issue "$ISSUE" --remote origin + create-rc --issue "$ISSUE" --remote origin "${ARGS[@]}" call_release: needs: tag_rc diff --git a/RELEASING.md b/RELEASING.md index 528450f611..7846cef883 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -33,8 +33,9 @@ Release Tracking Issue and automated workflows triggered by comments or issue ed 3. **Add Backports (if needed)**: If there are backports, add them following the [How to add backports](#how-to-add-backports) steps. -4. **Create an RC**: Comment `/create-rc` on the tracking issue. All pending - backports must be successfully processed before creating the RC. +4. **Create an RC**: Comment `/create-rc` on the tracking issue. This will + automatically process pending backports before creating the RC. If any + backport fails, the RC creation will abort. 5. **Iterate**: Repeat steps 3 and 4 until backports and RCs are no longer needed. diff --git a/news/process-backports-before-rc.changed.md b/news/process-backports-before-rc.changed.md new file mode 100644 index 0000000000..5591b700ce --- /dev/null +++ b/news/process-backports-before-rc.changed.md @@ -0,0 +1,3 @@ +Changed `/create-rc` command to automatically process pending backports before +creating the RC, and react with a thumbs-down emoji on failure if triggered by +a comment. diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 0937dfbac6..06cdb77345 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -253,6 +253,130 @@ def test_create_rc_auto_add_task(self): call2_args[1], ) + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_calls_process_backports(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 + + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + mock_pb_class.assert_called_once() + called_args = mock_pb_class.call_args[0][0] + self.assertEqual(called_args.issue, 123) + self.assertEqual(called_args.remote, "my-remote") + self.assertFalse(called_args.dry_run) + self.assertIsNone(called_args.add) + self.assertIsNone(called_args.triggering_comment) + mock_pb.run.assert_called_once() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_aborts_on_process_backports_failure(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 + + args = MagicMock(issue=123, remote="my-remote") + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + mock_pb_class.assert_called_once() + mock_pb.run.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + self.mock_git.tag.assert_not_called() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_failure_reacts_to_comment(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 # Simulate failure + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.add_comment_reaction.assert_called_once_with(456, "-1") + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_failure_no_comment_no_reaction(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 # Simulate failure + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=None) + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.add_comment_reaction.assert_not_called() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_success_with_comment_no_reaction(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_gh.add_comment_reaction.assert_not_called() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_precondition_failure_reacts_to_comment(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 # Backports succeed + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release | status=pending +- [ ] Create Release branch | status=pending +- [ ] Tag RC0 | status=pending +""" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.add_comment_reaction.assert_called_once_with(456, "-1") + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index abc91a8273..0916377925 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -1,7 +1,11 @@ """Subcommand to tag and push the next release candidate.""" -from tools.private.release.gh import GitHub +import traceback +from argparse import Namespace + +from tools.private.release.gh import GH_REACTION_THUMBS_DOWN, GitHub from tools.private.release.git import Git +from tools.private.release.process_backports import ProcessBackports from tools.private.release.release_issue import ( RELEASE_TITLE_RE, add_rc_task_to_body, @@ -26,6 +30,44 @@ def __init__(self, args, git: Git, gh: GitHub): def run(self) -> int: """Executes the create-rc subcommand.""" args = self.args + exit_code = 0 + try: + exit_code = self._run_internal() + except Exception as e: + print(f"Unexpected error: {e}") + traceback.print_exc() + exit_code = 1 + + if exit_code != 0 and args.triggering_comment: + print(f"Reacting with thumbs-down to comment {args.triggering_comment}...") + try: + self.gh.add_comment_reaction( + args.triggering_comment, GH_REACTION_THUMBS_DOWN + ) + except Exception as e: + print(f"Failed to add reaction to comment: {e}") + + return exit_code + + def _run_internal(self) -> int: + """Internal implementation of create-rc.""" + args = self.args + + # Try to process pending backports first + print("Processing pending backports before creating RC...") + backport_args = Namespace( + issue=args.issue, + remote=args.remote, + add=None, + triggering_comment=None, + dry_run=False, + ) + pb = ProcessBackports(backport_args, self.git, self.gh) + backports_exit_code = pb.run() + if backports_exit_code != 0: + print("Error: Processing backports failed. Aborting RC creation.") + return backports_exit_code + body = self.gh.get_issue_body(args.issue) state = parse_checklist_state(body) @@ -153,6 +195,11 @@ def add_parser(cls, subparsers): required=True, help="The git remote to push the RC tag to (required).", ) + parser.add_argument( + "--triggering-comment", + type=int, + help="The ID of the comment that triggered this run (optional).", + ) parser.set_defaults(command=cls.run_from_args) @classmethod From 5fe64d8694a6c48c33a247a2b90ab37773d2ea84 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 20:28:21 -0700 Subject: [PATCH 066/179] chore(release): automate backport processing on PR merge, and have add-backports accept hashtag and url refs (#3897) Currently, backports must be manually added to the release tracking issue. This change automates the process by allowing maintainers to comment `/backport` on a PR to add it to the active release's backport checklist, and automatically triggering the backport processing when the PR is merged. To support this, the following changes were made: - Added `add-backports` subcommand to the release tool to append PRs to the tracking issue. - Added `on-pr-merged` subcommand to verify `/backport` comment, find the tracking issue, and process backports. - Created `release_add_backports.yaml` reusable workflow to run the `add-backports` subcommand. - Updated `on_comment.yaml` to parse `/backport` comments on PRs and trigger the addition. - Created `on_pr_closed.yaml` to detect merged PRs and run the `on-pr-merged` subcommand. - Added unit tests for the new subcommands and URL resolution. --- .github/workflows/on_comment.yaml | 140 ++++++++++++++++++ .github/workflows/on_issue_comment.yaml | 105 ------------- .github/workflows/on_pr_closed.yaml | 83 +++++++++++ .github/workflows/release_add_backports.yaml | 58 ++++++++ RELEASING.md | 34 ++++- tests/tools/private/release/BUILD.bazel | 26 ++++ .../private/release/add_backports_test.py | 104 +++++++++++++ tests/tools/private/release/create_rc_test.py | 24 +++ tests/tools/private/release/gh_test.py | 61 ++++++++ .../private/release/on_pr_merged_test.py | 112 ++++++++++++++ .../private/release/process_backports_test.py | 50 ++++++- .../private/release/release_issue_test.py | 31 ++++ tools/private/release/add_backports.py | 115 ++++++++++++++ tools/private/release/complete_prepare.py | 2 +- tools/private/release/create_rc.py | 5 +- tools/private/release/gh.py | 48 ++++++ tools/private/release/on_pr_merged.py | 113 ++++++++++++++ tools/private/release/process_backports.py | 20 ++- tools/private/release/release.py | 4 + tools/private/release/release_issue.py | 34 +++-- tools/private/release/utils.py | 19 +-- 21 files changed, 1046 insertions(+), 142 deletions(-) create mode 100644 .github/workflows/on_comment.yaml delete mode 100644 .github/workflows/on_issue_comment.yaml create mode 100644 .github/workflows/on_pr_closed.yaml create mode 100644 .github/workflows/release_add_backports.yaml create mode 100644 tests/tools/private/release/add_backports_test.py create mode 100644 tests/tools/private/release/gh_test.py create mode 100644 tests/tools/private/release/on_pr_merged_test.py create mode 100644 tools/private/release/add_backports.py create mode 100644 tools/private/release/on_pr_merged.py diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml new file mode 100644 index 0000000000..ed7813adac --- /dev/null +++ b/.github/workflows/on_comment.yaml @@ -0,0 +1,140 @@ +name: "On Comment" + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + # This job always runs to prevent GHA from marking the run as failed when + # all other jobs are skipped. + noop: + runs-on: ubuntu-latest + steps: + - run: echo "No-op" + + parse_comment: + runs-on: ubuntu-latest + if: | + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' + outputs: + command: ${{ steps.parse.outputs.command }} + issue_number: ${{ steps.parse.outputs.issue_number }} + pr_number: ${{ steps.parse.outputs.pr_number }} + backports: ${{ steps.parse.outputs.backports }} + steps: + - name: Parse comment + id: parse + env: + COMMENT_BODY: ${{ github.event.comment.body }} + IS_PR: "${{ github.event.issue.pull_request != null }}" + EVENT_NUMBER: "${{ github.event.issue.number }}" + HAS_RELEASE_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: release') }}" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$IS_PR" = "false" ] && [ "$HAS_RELEASE_LABEL" = "true" ]; then + issue_number=$EVENT_NUMBER + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then + echo "command=create-rc" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then + echo "command=prepare" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then + echo "command=process-backports" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then + args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') + args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') + csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') + if [ -n "$csv" ]; then + echo "command=add-backports" >> "$GITHUB_OUTPUT" + echo "backports=$csv" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + echo "Error: No PRs specified for add-backports." >&2 + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -f "content=-1" + fi + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then + echo "command=promote" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + elif [ "$IS_PR" = "true" ]; then + pr_number=$EVENT_NUMBER + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then + echo "command=pr-backport" >> "$GITHUB_OUTPUT" + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + + call_create_rc: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'create-rc' + uses: ./.github/workflows/release_create_rc.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" + secrets: inherit + + call_prepare: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'prepare' + uses: ./.github/workflows/release_prepare.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_add_backports: + needs: parse_comment + if: | + needs.parse_comment.outputs.command == 'add-backports' || + needs.parse_comment.outputs.command == 'pr-backport' + uses: ./.github/workflows/release_add_backports.yaml + with: + prs: ${{ needs.parse_comment.outputs.command == 'pr-backport' && needs.parse_comment.outputs.pr_number || needs.parse_comment.outputs.backports }} + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_process_backports_after_add: + needs: [parse_comment, call_add_backports] + if: needs.parse_comment.outputs.command == 'add-backports' + uses: ./.github/workflows/release_process_backports.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" + secrets: inherit + + call_process_backports_only: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'process-backports' + uses: ./.github/workflows/release_process_backports.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" + secrets: inherit + + call_promote: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'promote' + uses: ./.github/workflows/release_promote_rc.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml deleted file mode 100644 index 14248d0d7a..0000000000 --- a/.github/workflows/on_issue_comment.yaml +++ /dev/null @@ -1,105 +0,0 @@ -name: "On Issue Comment" - -on: - issue_comment: - types: [created] - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - # This job always runs to prevent GHA from marking the run as failed when - # all other jobs are skipped. - noop: - runs-on: ubuntu-latest - steps: - - run: echo "No-op" - - parse_comment: - runs-on: ubuntu-latest - if: | - github.event.issue.pull_request == null && - contains(github.event.issue.labels.*.name, 'type: release') && - (github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR') - outputs: - command: ${{ steps.parse.outputs.command }} - issue_number: ${{ github.event.issue.number }} - backports: ${{ steps.parse.outputs.backports }} - steps: - - name: Parse comment - id: parse - env: - COMMENT_BODY: ${{ github.event.comment.body }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then - echo "command=create-rc" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then - echo "command=prepare" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then - echo "command=process-backports" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then - args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') - # Strip leading/trailing spaces and commas - args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') - # Replace internal spaces/commas with single comma - csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') - if [ -n "$csv" ]; then - echo "command=add-backports" >> "$GITHUB_OUTPUT" - echo "backports=$csv" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - echo "Error: No PRs specified for add-backports." >&2 - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ - -f "content=-1" - fi - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then - echo "command=promote" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - fi - - call_create_rc: - needs: parse_comment - if: needs.parse_comment.outputs.command == 'create-rc' - uses: ./.github/workflows/release_create_rc.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - comment_id: "${{ github.event.comment.id }}" - secrets: inherit - - call_prepare: - needs: parse_comment - if: needs.parse_comment.outputs.command == 'prepare' - uses: ./.github/workflows/release_prepare.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - secrets: inherit - - call_process_backports: - needs: parse_comment - if: | - needs.parse_comment.outputs.command == 'process-backports' || - needs.parse_comment.outputs.command == 'add-backports' - uses: ./.github/workflows/release_process_backports.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - add_backports: ${{ needs.parse_comment.outputs.command == 'add-backports' && needs.parse_comment.outputs.backports || '' }} - comment_id: "${{ github.event.comment.id }}" - secrets: inherit - - call_promote: - needs: parse_comment - if: needs.parse_comment.outputs.command == 'promote' - uses: ./.github/workflows/release_promote_rc.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - secrets: inherit diff --git a/.github/workflows/on_pr_closed.yaml b/.github/workflows/on_pr_closed.yaml new file mode 100644 index 0000000000..5dfcb89748 --- /dev/null +++ b/.github/workflows/on_pr_closed.yaml @@ -0,0 +1,83 @@ +name: "On PR Closed" + +on: + pull_request: + types: [closed] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + # This job always runs to prevent GHA from marking the run as failed when + # all other jobs are skipped. + noop: + runs-on: ubuntu-latest + steps: + - run: echo "No-op" + + check_if_backport: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + outputs: + should_process: ${{ steps.check.outputs.should_process }} + steps: + - name: Check if PR is a backport candidate + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Check if there is any active release issue + ACTIVE_ISSUES=$(gh issue list --repo ${{ github.repository }} --label "type: release" --state open --json number) + if [ "$ACTIVE_ISSUES" = "[]" ] || [ -z "$ACTIVE_ISSUES" ]; then + echo "No active release tracking issue found. Skipping." + echo "should_process=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Check if PR has "/backport" in comments (only comments, not body) + PR_DATA=$(gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json comments) + + if echo "$PR_DATA" | jq -r '.comments[].body' | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then + echo "Found /backport comment. Proceeding." + echo "should_process=true" >> "$GITHUB_OUTPUT" + else + echo "No /backport comment found. Skipping." + echo "should_process=false" >> "$GITHUB_OUTPUT" + fi + + process_backports: + needs: check_if_backport + if: needs.check_if_backport.outputs.should_process == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: read + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Process Backports + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + bazel run //tools/private/release -- on-pr-merged \ + "$PR_NUMBER" \ + --remote origin \ + --no-dry-run diff --git a/.github/workflows/release_add_backports.yaml b/.github/workflows/release_add_backports.yaml new file mode 100644 index 0000000000..d9d6d84e34 --- /dev/null +++ b/.github/workflows/release_add_backports.yaml @@ -0,0 +1,58 @@ +name: "Release: Add Backports" + +on: + workflow_dispatch: + inputs: + prs: + description: 'CSV list of PR numbers to add (e.g., 123,456)' + required: true + type: string + issue: + description: 'The Release Tracking Issue Number (optional)' + required: false + type: string + workflow_call: + inputs: + prs: + description: 'CSV list of PR numbers to add (e.g., 123,456)' + required: true + type: string + issue: + description: 'The Release Tracking Issue Number (optional)' + required: false + type: string + +permissions: + issues: write + +jobs: + add_backports: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Add Backports to Tracking Issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRS: ${{ inputs.prs }} + ISSUE: ${{ inputs.issue }} + run: | + ARGS=() + if [ -n "$ISSUE" ]; then + ARGS+=("--issue=$ISSUE") + fi + + # Convert CSV to array + IFS=',' read -r -a pr_array <<< "$PRS" + + bazel run //tools/private/release -- add-backports \ + "${pr_array[@]}" \ + "${ARGS[@]}" diff --git a/RELEASING.md b/RELEASING.md index 7846cef883..2298b4c8a3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -79,17 +79,41 @@ being accumulated for the next release, review the pending news entries in the To add backports to an active release, you can use one of the following methods: -### Method A: Manual Checklist Update +### Method A: Comment on the PR + +Comment `/backport` on the PR you wish to backport. This will automatically +add the PR to the active release's backports checklist. Once the PR is merged, +the backports will be automatically processed. + +> [!NOTE] +> Commenting `/backport` on an open PR will block further release publishing +> (like creating RCs or promoting) until the PR is merged or manually set to +> status=ignore in the checklist. + +### Method B: Comment on the Tracking Issue + +Comment `/add-backports [ ...]` (space or comma separated) on +the tracking issue. The `` can be a PR number (optionally prefixed with +`#`) or a PR URL (strictly for the configured repository). This will +automatically add the PRs to the checklist and trigger processing. + +### Method C: Manual Checklist Update 1. Manually add checklist items under the `## Backports` section of the Release Tracking Issue. The format must be: `- [ ] #` (e.g., `- [ ] #1234`). 2. When ready, comment `/process-backports` on the tracking issue to trigger processing. -### Method B: Comment Shortcut -1. Comment `/add-backports [ ...]` (space or comma - separated) on the tracking issue. This will automatically add the PRs to the - checklist and trigger processing. +### Method D: Release Tool CLI +You can use the release tool to add backports from your local checkout: +```shell +bazel run //tools/private/release -- add-backports [ ...] +``` +The `` can be: +* A PR number (e.g., `124` or `#124`) +* A PR URL (e.g., `https://github.com/bazel-contrib/rules_python/pull/124` + or `https://github.com/bazel-contrib/rules_python/pull/124/files`) +* Only URLs for the configured repository are accepted. ### Failure Behavior If a backport fails to process (e.g., due to cherry-pick conflicts): diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index e68bbef7d5..bc3b6f7e8b 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -8,6 +8,15 @@ py_library( ], ) +py_test( + name = "add_backports_test", + srcs = ["add_backports_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + py_test( name = "changelog_news_test", srcs = ["changelog_news_test.py"], @@ -35,6 +44,14 @@ py_test( ], ) +py_test( + name = "gh_test", + srcs = ["gh_test.py"], + deps = [ + "//tools/private/release:release_lib", + ], +) + py_test( name = "git_test", srcs = ["git_test.py"], @@ -43,6 +60,15 @@ py_test( ], ) +py_test( + name = "on_pr_merged_test", + srcs = ["on_pr_merged_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + py_test( name = "prepare_test", srcs = ["prepare_test.py"], diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py new file mode 100644 index 0000000000..7a5d96a23b --- /dev/null +++ b/tests/tools/private/release/add_backports_test.py @@ -0,0 +1,104 @@ +import argparse +import unittest +from unittest.mock import patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.add_backports import AddBackports + + +class CmdAddBackportsTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.addCleanup(patch.stopall) + self.mock_gh.resolve_pr_number.side_effect = lambda x: int( + x.lstrip("#").split("/")[-1] + ) + + def test_add_backports_explicit_issue(self): + args = argparse.Namespace(issue=123, prs=["124", "125"]) + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +""" + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #124", call_args[1]) + self.assertIn("- [ ] #125", call_args[1]) + # Should also auto-add Tag RC0 + self.assertIn("- [ ] Tag RC0", call_args[1]) + + def test_add_backports_auto_discover_success(self): + args = argparse.Namespace(issue=None, prs=["124"]) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +""" + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + self.mock_gh.update_issue_body.assert_called_once_with(456, unittest.mock.ANY) + + def test_add_backports_auto_discover_no_issues(self): + args = argparse.Namespace(issue=None, prs=["124"]) + self.mock_gh.get_open_tracking_issues.return_value = [] + + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + + def test_add_backports_auto_discover_multiple_issues(self): + args = argparse.Namespace(issue=None, prs=["124"]) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."}, + {"number": 789, "title": "Release 2.2.0", "url": "http://..."}, + ] + + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + + def test_add_backports_no_auto_add_rc_if_pending(self): + args = argparse.Namespace(issue=123, prs=["124"]) + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag RC0 +- [ ] Tag Final + +## Backports +""" + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertNotIn("Tag RC1", call_args[1]) + # Tag RC0 should still be there + self.assertIn("- [ ] Tag RC0", call_args[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 06cdb77345..8e4ed101e4 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -189,6 +189,30 @@ def test_create_rc_gating_on_backports(self): self.mock_git.tag.assert_not_called() self.mock_git.push.assert_not_called() + def test_create_rc_not_blocked_by_ignored_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [ ] #124 | status=ignore +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + def test_create_rc_with_finished_backports(self): # Arrange args = MagicMock(issue=123, remote="my-remote") diff --git a/tests/tools/private/release/gh_test.py b/tests/tools/private/release/gh_test.py new file mode 100644 index 0000000000..4010d2dca5 --- /dev/null +++ b/tests/tools/private/release/gh_test.py @@ -0,0 +1,61 @@ +import unittest +from unittest.mock import patch + +from tools.private.release.gh import GitHub + + +class GitHubTest(unittest.TestCase): + def setUp(self): + self.gh = GitHub("my-owner/my-repo") + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_digit(self, mock_run_cmd): + # 124 and #125 should resolve immediately without running command + self.assertEqual(self.gh.resolve_pr_number("124"), 124) + self.assertEqual(self.gh.resolve_pr_number("#125"), 125) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_simple(self, mock_run_cmd): + url = "https://github.com/my-owner/my-repo/pull/126" + # Should resolve via regex without calling gh + result = self.gh.resolve_pr_number(url) + self.assertEqual(result, 126) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_with_subpath(self, mock_run_cmd): + url = "https://github.com/my-owner/my-repo/pull/126/files" + # Should resolve via regex without calling gh + result = self.gh.resolve_pr_number(url) + self.assertEqual(result, 126) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_with_query(self, mock_run_cmd): + url = "https://github.com/my-owner/my-repo/pull/126/files?w=1" + # Should resolve via regex without calling gh + result = self.gh.resolve_pr_number(url) + self.assertEqual(result, 126) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_other_repo(self, mock_run_cmd): + # URL for a different repo should fail immediately without calling gh + url = "https://github.com/other-owner/other-repo/pull/126" + with self.assertRaises(ValueError) as ctx: + self.gh.resolve_pr_number(url) + self.assertIn("URL is not for the configured repository", str(ctx.exception)) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_invalid_ref(self, mock_run_cmd): + # Invalid reference (not number, not URL) should fail + with self.assertRaises(ValueError) as ctx: + self.gh.resolve_pr_number("invalid-ref") + self.assertIn("Could not resolve PR reference", str(ctx.exception)) + mock_run_cmd.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/on_pr_merged_test.py b/tests/tools/private/release/on_pr_merged_test.py new file mode 100644 index 0000000000..a5644c5705 --- /dev/null +++ b/tests/tools/private/release/on_pr_merged_test.py @@ -0,0 +1,112 @@ +import argparse +import unittest +from unittest.mock import MagicMock, patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.on_pr_merged import OnPrMerged + + +class CmdOnPrMergedTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.addCleanup(patch.stopall) + + # Mock ProcessBackports + self.mock_process_patcher = patch( + "tools.private.release.on_pr_merged.ProcessBackports" + ) + self.mock_process_class = self.mock_process_patcher.start() + self.mock_process_instance = MagicMock() + self.mock_process_class.return_value = self.mock_process_instance + + def test_on_pr_merged_no_comment(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": "Some comment"}, + {"body": "Another comment /backport_wrong"}, + ] + + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_not_called() + self.mock_process_class.assert_not_called() + + def test_on_pr_merged_has_comment_no_active_release(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": "/backport"}, + ] + self.mock_gh.get_open_tracking_issues.return_value = [] + + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + self.mock_process_class.assert_not_called() + + def test_on_pr_merged_has_comment_not_in_backports(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": " /backport "}, + ] + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release + +## Backports +- [ ] #125 | status=pending +""" + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + self.mock_process_class.assert_not_called() + + def test_on_pr_merged_success(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": "/backport"}, + ] + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release + +## Backports +- [ ] #124 | status=pending +""" + self.mock_process_instance.run.return_value = 0 + + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + + # Verify ProcessBackports was instantiated with correct args + self.mock_process_class.assert_called_once() + called_args = self.mock_process_class.call_args[0][0] + self.assertEqual(called_args.issue, 456) + self.assertEqual(called_args.remote, "origin") + self.assertEqual(called_args.dry_run, True) + self.assertIsNone(called_args.add) + self.assertIsNone(called_args.triggering_comment) + + # Verify ProcessBackports.run() was called + self.mock_process_instance.run.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py index 639171f59f..fe7bae256d 100644 --- a/tests/tools/private/release/process_backports_test.py +++ b/tests/tools/private/release/process_backports_test.py @@ -17,6 +17,9 @@ def setUp(self): "tools.private.release.process_backports.replace_version_next" ).start() self.addCleanup(patch.stopall) + self.mock_gh.resolve_pr_number.side_effect = lambda x: int( + x.lstrip("#").split("/")[-1] + ) def test_process_backports_no_pending(self): args = argparse.Namespace( @@ -254,7 +257,7 @@ def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetim issue=123, remote="origin", dry_run=False, - add=[124], + add=["https://github.com/bazel-contrib/rules_python/pull/124"], triggering_comment=None, ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" @@ -305,6 +308,51 @@ def mock_resolve(items): self.assertEqual(call2_args[0], 123) self.assertIn("- [x] #124 | status=done rc=rc1 commit= 12345678", call2_args[1]) + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_add_backports_marks_invalid(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, + remote="origin", + dry_run=False, + add=["124", "invalid", "125"], + triggering_comment=None, + ) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag Final + +## Backports +""" + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_commit_sha.return_value = "1234567890" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + def mock_resolve(items): + # Both 124 and 125 should be processed, 'invalid' should be ignored (it has error status) + for item in items: + if item.pr_ref in ("#124", "#125"): + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + # Should have updated body to add 124, 125, and invalid + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] + self.assertIn("- [ ] #124", call1_args[1]) + self.assertIn("- [ ] #125", call1_args[1]) + self.assertIn("- [ ] invalid | status=error-invalid-pr", call1_args[1]) + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py index edf5f603f0..cfc6da6ae4 100644 --- a/tests/tools/private/release/release_issue_test.py +++ b/tests/tools/private/release/release_issue_test.py @@ -1,6 +1,7 @@ import unittest from tools.private.release.release_issue import ( + add_backports_to_body, format_metadata_line, parse_metadata_line, ) @@ -65,6 +66,36 @@ def test_format_metadata_line(self): expected = "- [ ] Tag Final" self.assertEqual(format_metadata_line(False, "Tag Final", {}), expected) + def test_add_backports_to_body(self): + body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +- [ ] #123 | status=done +""" + items = [ + {"ref": "124"}, + {"ref": "#124"}, + {"ref": "125"}, + {"ref": "#123"}, + ] + updated_body = add_backports_to_body(body, items) + expected_body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +- [ ] #123 | status=done +- [ ] #124 +- [ ] #125 +""" + self.assertEqual(updated_body.strip(), expected_body.strip()) + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py new file mode 100644 index 0000000000..c25f509b9c --- /dev/null +++ b/tools/private/release/add_backports.py @@ -0,0 +1,115 @@ +"""Subcommand to add PRs to the release tracking issue backports checklist.""" + +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import ( + add_backports_to_body, + add_rc_task_to_body, + parse_checklist_state, +) + + +class AddBackports: + """Class to add PRs to the release tracking issue.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the add-backports subcommand.""" + args = self.args + + issue_num = args.issue + if not issue_num: + print( + "No issue specified. Trying to auto-discover active release" + " tracking issue..." + ) + try: + open_issues = self.gh.get_open_tracking_issues() + if not open_issues: + print("Error: No open release tracking issues found.") + return 1 + if len(open_issues) > 1: + print( + "Error: Multiple open release tracking issues found." + " Cannot determine active one:" + ) + for issue in open_issues: + print(f"- #{issue['number']}: {issue['title']}") + return 1 + issue_num = open_issues[0]["number"] + print(f"Auto-discovered active release tracking issue: #{issue_num}") + except Exception as e: + print(f"Error auto-discovering tracking issue: {e}") + return 1 + + resolved_prs = [] + for pr_ref in args.prs: + try: + pr_num = self.gh.resolve_pr_number(pr_ref) + resolved_prs.append(pr_num) + except Exception as e: + print(f"Error resolving PR ref '{pr_ref}': {e}") + return 1 + + print( + f"Adding backports {resolved_prs} (resolved from {args.prs}) to tracking issue #{issue_num}..." + ) + try: + body = self.gh.get_issue_body(issue_num) + items_to_add = [{"ref": f"#{pr}"} for pr in resolved_prs] + body = add_backports_to_body(body, items_to_add) + state = parse_checklist_state(body) + rc_tags = state.get("rc_tags", {}) + has_pending_rc = any( + not task.checked and task.status != "done" for task in rc_tags.values() + ) + next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0 + if not has_pending_rc: + print( + f"No pending RC task found. Adding 'Tag" + f" RC{next_rc_num}' to checklist..." + ) + body = add_rc_task_to_body(body, next_rc_num) + except ValueError as e: + print(f"Error: {e}") + return 1 + except Exception as e: + print(f"Failed to update tracking issue: {e}") + return 1 + + try: + self.gh.update_issue_body(issue_num, body) + print("Successfully updated tracking issue checklist.") + except Exception as e: + print(f"Failed to update tracking issue body: {e}") + return 1 + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for add-backports subcommand.""" + parser = subparsers.add_parser( + "add-backports", + help="Add PRs to the release tracking issue backports checklist.", + ) + parser.add_argument( + "prs", + type=str, + nargs="+", + help="PR references (numbers, #numbers, or URLs) to add (positional, space-separated).", + ) + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number. If omitted, will try to auto-discover the active release tracking issue.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/complete_prepare.py b/tools/private/release/complete_prepare.py index 7657738aad..8a60cbf0f3 100644 --- a/tools/private/release/complete_prepare.py +++ b/tools/private/release/complete_prepare.py @@ -25,7 +25,7 @@ def run(self) -> int: return 1 # Resolve issue number from PR body - pr_body = pr_info.get("body", "") + pr_body = pr_info.get("body") or "" match = re.search(r"Work towards #(\d+)", pr_body) if not match: match = re.search(r"#(\d+)", pr_body) diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 0916377925..2e61e7d80a 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -84,7 +84,10 @@ def _run_internal(self) -> int: # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done backports = parse_backports(body) conflicting_or_pending = [ - b for b in backports if not b.checked or b.status != "done" + b + for b in backports + if (b.checked and b.status != "done") + or (not b.checked and b.status != "ignore") ] if conflicting_or_pending: print( diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index e670fc98df..8990ef5abf 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -2,6 +2,7 @@ import json import os +import re import tempfile from tools.private.release.release_issue import BackportTask @@ -316,6 +317,53 @@ def get_pr_info(self, pr_num: int) -> dict: ) return json.loads(output) if output else {} + def get_pr_comments(self, pr_num: int) -> list[dict]: + """Gets comments for a PR. + + Args: + pr_num: The PR number. + + Returns: + A list of comments. + """ + output = self._gh_pr( + "view", + str(pr_num), + "--json=comments", + ) + return json.loads(output).get("comments") or [] + + def resolve_pr_number(self, pr_ref: str) -> int: + """Resolves a PR reference (number, #number, URL) to a PR number. + + Args: + pr_ref: The PR reference string. + + Returns: + The resolved PR number. + + Raises: + ValueError: If the reference cannot be resolved. + """ + # 1. Try number (e.g. "123" or "#123") + clean_ref = pr_ref.lstrip("#") + if clean_ref.isdigit(): + return int(clean_ref) + + # 2. Try URL (starts with http) + if pr_ref.startswith("http"): + # Try to extract PR number from URL using regex + # Pattern matches: github.com//pull/ followed by /, ?, or EOF + pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)" + match = re.search(pattern, pr_ref, re.IGNORECASE) + if match: + return int(match.group(1)) + raise ValueError( + f"URL is not for the configured repository ({self.repo}): {pr_ref}" + ) + + raise ValueError(f"Could not resolve PR reference: {pr_ref}") + def post_issue_comment(self, issue_num: int, comment_body: str) -> None: """Posts a comment to a specific issue. diff --git a/tools/private/release/on_pr_merged.py b/tools/private/release/on_pr_merged.py new file mode 100644 index 0000000000..8d451580fb --- /dev/null +++ b/tools/private/release/on_pr_merged.py @@ -0,0 +1,113 @@ +"""Subcommand to handle PR merge event by processing backports.""" + +import argparse +import re + +from tools.private.release.gh import GitHub +from tools.private.release.git import Git +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.release_issue import parse_backports + + +class OnPrMerged: + """Class to handle PR merge event.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the on-pr-merged subcommand.""" + args = self.args + pr_num = args.pr + pr_ref = f"#{pr_num}" + + print(f"Verifying PR {pr_ref} has backport comment...") + try: + comments = self.gh.get_pr_comments(pr_num) + has_comment = any( + re.match( + r"^\s*/backport(\s|$)", + comment.get("body") or "", + re.IGNORECASE, + ) + for comment in comments + ) + if not has_comment: + print(f"PR {pr_ref} does not have a /backport comment. Skipping.") + return 1 + except Exception as e: + print(f"Error checking PR comments: {e}") + return 1 + + print(f"Searching for active release tracking issue containing PR {pr_ref}...") + open_issues = self.gh.get_open_tracking_issues() + if not open_issues: + print("No open release tracking issues found.") + return 1 + + found_issue = None + for issue in open_issues: + issue_num = issue["number"] + body = self.gh.get_issue_body(issue_num) + backports = parse_backports(body) + + if any(item.pr_ref == pr_ref for item in backports): + if found_issue: + print( + f"Error: PR {pr_ref} found in multiple open release" + f" tracking issues: #{found_issue} and #{issue_num}" + ) + return 1 + found_issue = issue_num + + if not found_issue: + print(f"PR {pr_ref} not found in any active release tracking issue.") + return 1 + + print(f"Found PR {pr_ref} in tracking issue #{found_issue}") + + # Now run ProcessBackports for this issue + process_args = argparse.Namespace( + issue=found_issue, + remote=args.remote, + add=None, + triggering_comment=None, + dry_run=args.dry_run, + ) + print(f"Processing backports for issue #{found_issue}...") + return ProcessBackports(process_args, self.git, self.gh).run() + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for on-pr-merged subcommand.""" + parser = subparsers.add_parser( + "on-pr-merged", + help="Handle PR merge event by processing backports.", + ) + parser.add_argument( + "pr", + type=int, + help="PR number that was merged.", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push changes to (required).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 81dbe2bd5e..c8a936eab5 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -206,9 +206,23 @@ def _run_internal(self) -> int: body = self.gh.get_issue_body(args.issue) if args.add: - print(f"Adding backports {args.add} to tracking issue #{args.issue}...") + items_to_add = [] + for pr_ref in args.add: + try: + pr_num = self.gh.resolve_pr_number(pr_ref) + items_to_add.append({"ref": f"#{pr_num}"}) + except Exception as e: + print(f"Warning: PR ref '{pr_ref}' is invalid: {e}") + items_to_add.append( + { + "ref": pr_ref, + "metadata": {"status": "error-invalid-pr"}, + } + ) + + print(f"Adding backports {items_to_add} to tracking issue #{args.issue}...") try: - body = add_backports_to_body(body, args.add) + body = add_backports_to_body(body, items_to_add) state = parse_checklist_state(body) rc_tags = state.get("rc_tags", {}) has_pending_rc = any( @@ -356,7 +370,7 @@ def add_parser(cls, subparsers): parser.add_argument( "--add", type=parse_pr_list, - help="PR numbers (comma or space separated) to add before processing.", + help="PR references (numbers, #numbers, or URLs, comma/space separated) to add before processing.", ) parser.add_argument( "--triggering-comment", diff --git a/tools/private/release/release.py b/tools/private/release/release.py index a6f3543e71..6364c61b23 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -4,11 +4,13 @@ import os import sys +from tools.private.release.add_backports import AddBackports from tools.private.release.complete_prepare import CompletePrepare from tools.private.release.create_rc import CreateRc from tools.private.release.create_release_branch import CreateReleaseBranch from tools.private.release.create_release_issue import CreateReleaseIssue from tools.private.release.determine_next_version import DetermineNextVersion +from tools.private.release.on_pr_merged import OnPrMerged from tools.private.release.prepare import Prepare from tools.private.release.process_backports import ProcessBackports from tools.private.release.promote_rc import PromoteRc @@ -19,7 +21,9 @@ Prepare, CompletePrepare, CreateReleaseBranch, + AddBackports, ProcessBackports, + OnPrMerged, CreateRc, PromoteRc, ] diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 2650b6607c..359249d11c 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -247,8 +247,14 @@ def parse_backports(body): return items -def add_backports_to_body(body: str, prs: list[int]) -> str: - """Adds new backport checklist items to the ## Backports section.""" +def add_backports_to_body(body: str, items: list[dict]) -> str: + """Adds new backport checklist items to the ## Backports section. + + Args: + body: The issue body. + items: A list of dicts, where each dict has a 'ref' key (str) and + optional 'metadata' key (dict). + """ body = body.replace("\r\n", "\n") # Find the Backports section pattern = r"(## Backports\n)(.*?)(?=\n##|\n---|\Z)" @@ -260,18 +266,24 @@ def add_backports_to_body(body: str, prs: list[int]) -> str: # Parse existing backports to avoid duplicates existing_items = parse_backports(body) - existing_prs = { - int(item.pr_ref.lstrip("#")) - for item in existing_items - if item.pr_ref.startswith("#") - } + existing_refs = {item.pr_ref for item in existing_items} new_lines = [] - for pr in prs: - if pr in existing_prs: - print(f"PR #{pr} is already in the backports list. Skipping.") + for item in items: + ref = item["ref"] + # Normalize numeric refs to #numeric + if not ref.startswith("#") and ref.isdigit(): + ref = f"#{ref}" + + if ref in existing_refs: + print(f"PR {ref} is already in the backports list. Skipping.") continue - new_lines.append(f"- [ ] #{pr}") + existing_refs.add(ref) + + metadata = item.get("metadata", {}) + new_lines.append( + format_metadata_line(checked=False, name=ref, metadata=metadata) + ) if not new_lines: return body diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index db7b96a378..cdd1b7724d 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -174,23 +174,12 @@ def replace_version_next(version): f.write(new_content) -def parse_pr_list(value: str) -> list[int]: - """Parses a comma or space separated list of PR numbers. +def parse_pr_list(value: str) -> list[str]: + """Parses a comma or space separated list of PR references. - PR numbers can optionally be prefixed with '#'. + PR references can be numbers (optionally prefixed with '#') or URLs. """ if not value: return [] # Split by space and/or comma - pr_strings = [p for p in re.split(r"[\s,]+", value.strip()) if p] - prs = [] - for pr_str in pr_strings: - clean_pr_str = pr_str.lstrip("#") - try: - prs.append(int(clean_pr_str)) - except ValueError as e: - raise argparse.ArgumentTypeError( - f"Invalid PR reference '{pr_str}'. Must be integer optionally" - f" prefixed with '#'." - ) from e - return prs + return [p for p in re.split(r"[\s,]+", value.strip()) if p] From 0896b4791e3d9e8785890a3798cfd8b5faf90e16 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 21:01:10 -0700 Subject: [PATCH 067/179] chore(release): fix promote_rc arguments and align comments (#3898) chore(release): fix promote_rc arguments and align comments `promote_rc.py` was calling `Git.fetch` with `refspec` keyword argument which was not supported by `Git.fetch` signature, leading to fatal errors. The release promotion comment format was different from the release candidate creation comment format, leading to inconsistency. - Added `refspec` argument support to `Git.fetch` in `git.py`. - Added `sys.argv` printing at startup in `release.py` for debugging. - Updated `promote_rc.py` comment body to match `create_rc.py` style and added necessary helper variables. - Updated `git_test.py` to test `Git.fetch` with `refspec`. - Updated `promote_rc_test.py` to match the new comment format. --- tests/tools/private/release/git_test.py | 38 +++++++++++++++++++ .../tools/private/release/promote_rc_test.py | 27 ++++++++----- tools/private/release/git.py | 9 ++++- tools/private/release/promote_rc.py | 24 +++++++++--- tools/private/release/release.py | 1 + 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/tests/tools/private/release/git_test.py b/tests/tools/private/release/git_test.py index 59b5c31989..16667b8715 100644 --- a/tests/tools/private/release/git_test.py +++ b/tests/tools/private/release/git_test.py @@ -44,5 +44,43 @@ def test_checkout_track_remote_existing_branch( mock_reset_hard.assert_called_once_with("origin/my-branch") +class GitFetchTest(unittest.TestCase): + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_fetch_default(self): + self.git.fetch() + self.mock_run_git.assert_called_once_with( + "fetch", "origin", capture_output=False + ) + + def test_fetch_custom_remote(self): + self.git.fetch("upstream") + self.mock_run_git.assert_called_once_with( + "fetch", "upstream", capture_output=False + ) + + def test_fetch_with_refspec(self): + self.git.fetch("origin", refspec="my-branch") + self.mock_run_git.assert_called_once_with( + "fetch", "origin", "my-branch", capture_output=False + ) + + def test_fetch_with_tags_and_force(self): + self.git.fetch("origin", tags=True, force=True) + self.mock_run_git.assert_called_once_with( + "fetch", "origin", "--tags", "--force", capture_output=False + ) + + def test_fetch_all_options(self): + self.git.fetch("origin", refspec="my-branch", tags=True, force=True) + self.mock_run_git.assert_called_once_with( + "fetch", "origin", "my-branch", "--tags", "--force", capture_output=False + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_rc_test.py index 68797e2e1e..f6fa2da970 100644 --- a/tests/tools/private/release/promote_rc_test.py +++ b/tests/tools/private/release/promote_rc_test.py @@ -50,9 +50,12 @@ def test_promote_rc_success(self): 123, expected_updated_body ) expected_comment = ( - "Version 2.0.0 has been tagged.\n\n" - "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" - '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' + "**New Release Tagged!** 🐍🌿\n\n" + "Version **2.0.0** has been successfully generated and tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0).\n\n" + "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" + "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) @@ -95,9 +98,12 @@ def test_promote_rc_resolve_issue_success(self): 123, expected_updated_body ) expected_comment = ( - "Version 2.0.0 has been tagged.\n\n" - "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0\n" - '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.0" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)' + "**New Release Tagged!** 🐍🌿\n\n" + "Version **2.0.0** has been successfully generated and tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0).\n\n" + "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" + "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) @@ -142,9 +148,12 @@ def test_promote_rc_resolves_version_from_issue(self): 123, expected_updated_body ) expected_comment = ( - "Version 2.0.1 has been tagged.\n\n" - "- **Release Page**: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1\n" - '- **BCR PR Search**: [is:pr ("bazel-contrib/rules_python" in:title) ("@2.0.1" in:title)](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)' + "**New Release Tagged!** 🐍🌿\n\n" + "Version **2.0.1** has been successfully generated and tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0).\n\n" + "- [Github Release 2.0.1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1)\n" + "- [BCR Entry 2.0.1](https://registry.bazel.build/modules/rules_python/2.0.1)\n" + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)\n" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 69edba3565..3e7f77f39a 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -135,16 +135,23 @@ def push( self._run_git(*cmd, capture_output=False) def fetch( - self, remote: str = "origin", tags: bool = False, force: bool = False + self, + remote: str = "origin", + refspec: str | None = None, + tags: bool = False, + force: bool = False, ) -> None: """Fetches updates from a remote repository. Args: remote: The remote repository name. Defaults to 'origin'. + refspec: The refspec to fetch. tags: If True, fetches all tags. force: If True, force fetches updates. """ cmd = ["fetch", remote] + if refspec: + cmd.append(refspec) if tags: cmd.append("--tags") if force: diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py index ac3a841526..4a261c3b84 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote_rc.py @@ -1,6 +1,7 @@ """Subcommand to promote a release candidate to final release.""" import argparse +import os import urllib.parse from tools.private.release.gh import GitHub @@ -161,16 +162,29 @@ def run(self) -> int: print(f"Posting comment to tracking issue #{issue_num}...") + branch_url = f"{REPO_URL}/tree/{branch_name}" release_url = f"{REPO_URL}/releases/tag/{version}" + bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" bcr_query = ( f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' ) bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" - comment_body = ( - f"Version {version} has been tagged.\n\n" - f"- **Release Page**: {release_url}\n" - f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" - ) + + if run_id := os.environ.get("GITHUB_RUN_ID"): + release_workflow_url = f"{REPO_URL}/actions/runs/{run_id}" + else: + release_workflow_url = ( + f"{REPO_URL}/actions/workflows/release_promote_rc.yaml" + ) + + comment_body = f"""**New Release Tagged!** 🐍🌿 + +Version **{version}** has been successfully generated and tagged on branch [`{branch_name}`]({branch_url}). + +- [Github Release {version}]({release_url}) +- [BCR Entry {version}]({bcr_entry_url}) +- [BCR PRs]({bcr_search_url}) +- [Release workflow status]({release_workflow_url})""" self.gh.post_issue_comment(issue_num, comment_body) return 0 diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 6364c61b23..8ad9c2e33e 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -46,6 +46,7 @@ def create_parser(): def main(): + print(f"sys.argv: {sys.argv}") if "BUILD_WORKSPACE_DIRECTORY" in os.environ: os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"]) From 5650cc8966ca42ed5dea1ca0c1b3e0c5549071af Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 21:14:00 -0700 Subject: [PATCH 068/179] chore(release): fix promote-rc command and update workflow args (#3899) Currently, the `promote-rc` command fails because it calls `Git.get_commit_sha` with an invalid keyword argument `remote_ref`. Additionally, we want to align argument passing in the workflow with the `=` style. To fix this: - Remove the invalid `remote_ref` keyword argument from `Git.get_commit_sha` call in `promote_rc.py`. - Update `promote_rc_test.py` mock assertions to match the corrected call. - Update `release_promote_rc.yaml` to pass `--issue` and `--remote` arguments using `=`. --- .github/workflows/release_promote_rc.yaml | 4 ++-- tests/tools/private/release/promote_rc_test.py | 8 ++++---- tools/private/release/promote_rc.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote_rc.yaml index 7dc1c118b2..6dd02f1a25 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote_rc.yaml @@ -52,9 +52,9 @@ jobs: ARGS+=("$VERSION") fi if [ -n "$ISSUE" ]; then - ARGS+=("--issue" "$ISSUE") + ARGS+=("--issue=$ISSUE") fi - ARGS+=("--remote" "origin") + ARGS+=("--remote=origin") ARGS+=("--no-dry-run") bazel run //tools/private/release -- promote-rc "${ARGS[@]}" diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_rc_test.py index f6fa2da970..5bfd122c4c 100644 --- a/tests/tools/private/release/promote_rc_test.py +++ b/tests/tools/private/release/promote_rc_test.py @@ -34,7 +34,7 @@ def test_promote_rc_success(self): ] ) self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")] + [call("2.0.0-rc1"), call("my-remote/release/2.0")] ) self.mock_git.checkout.assert_not_called() self.mock_git.tag_exists.assert_called_once_with("2.0.0") @@ -85,7 +85,7 @@ def test_promote_rc_resolve_issue_success(self): ) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")] + [call("2.0.0-rc1"), call("my-remote/release/2.0")] ) self.mock_git.checkout.assert_not_called() self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") @@ -136,7 +136,7 @@ def test_promote_rc_resolves_version_from_issue(self): self.mock_git.checkout.assert_not_called() self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.1-rc0"), call(remote_ref="my-remote/release/2.0")] + [call("2.0.1-rc0"), call("my-remote/release/2.0")] ) self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") self.mock_git.push.assert_called_once_with("my-remote", "2.0.1") @@ -181,7 +181,7 @@ def test_promote_rc_dry_run_success(self, mock_print): ] ) self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")] + [call("2.0.0-rc1"), call("my-remote/release/2.0")] ) self.mock_git.tag_exists.assert_called_once_with("2.0.0") diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py index 4a261c3b84..ed59f0b76e 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote_rc.py @@ -89,7 +89,7 @@ def run(self) -> int: print(f"Fetching remote branch {remote_branch}...") self.git.fetch(args.remote, refspec=branch_name) try: - branch_sha = self.git.get_commit_sha(remote_ref=remote_branch) + branch_sha = self.git.get_commit_sha(remote_branch) except Exception as e: print( f"Error: Could not get commit SHA for remote branch" From dcbdf1b36306c7aa73feac5dc5e42c2eab20919a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 23:33:38 -0700 Subject: [PATCH 069/179] chore(release): make promote_rc workflow call publish_release workflow to finish publishing (#3900) Why: The tagging done by promote_rc doesn't trigger the on-tag trigger of the publish workflow (due to GITHUB_TOKEN restriction). How: - Modified promote_rc.py to output the promoted version to GITHUB_OUTPUT. - Modified release_promote_rc.yaml to capture this version and chain the release_publish workflow. - Added unit tests for GITHUB_OUTPUT writing in promote_rc_test.py. --- .github/workflows/release_promote_rc.yaml | 11 ++++++++ .../tools/private/release/promote_rc_test.py | 27 +++++++++++++++++++ tools/private/release/promote_rc.py | 4 +++ 3 files changed, 42 insertions(+) diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote_rc.yaml index 6dd02f1a25..399e7b1fa2 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote_rc.yaml @@ -25,6 +25,8 @@ permissions: jobs: promote: runs-on: ubuntu-latest + outputs: + version: ${{ steps.promote.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@v7 @@ -42,6 +44,7 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Run Promote RC + id: promote env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} @@ -58,3 +61,11 @@ jobs: ARGS+=("--no-dry-run") bazel run //tools/private/release -- promote-rc "${ARGS[@]}" + + publish: + needs: promote + uses: ./.github/workflows/release_publish.yaml + with: + tag_name: ${{ needs.promote.outputs.version }} + publish_to_pypi: true + secrets: inherit diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_rc_test.py index 5bfd122c4c..b5dff3e478 100644 --- a/tests/tools/private/release/promote_rc_test.py +++ b/tests/tools/private/release/promote_rc_test.py @@ -1,5 +1,8 @@ import argparse +import os +import tempfile import unittest +from pathlib import Path from unittest.mock import call, patch from tests.tools.private.release.release_test_helper import _mock_git_and_gh @@ -10,6 +13,8 @@ class CmdPromoteRcTest(unittest.TestCase): def setUp(self): _mock_git_and_gh(self) + self.test_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.test_dir.cleanup) def test_promote_rc_success(self): # Arrange @@ -59,6 +64,28 @@ def test_promote_rc_success(self): ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) + def test_promote_rc_writes_github_output(self): + # Arrange + github_output_path = os.path.join(self.test_dir.name, "github_output") + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=False, remote="my-remote" + ) + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_commit_sha.return_value = "abcdef123456" + self.mock_git.tag_exists.return_value = False + initial_body = "- [ ] Tag Final" + self.mock_gh.get_issue_body.return_value = initial_body + + # Act + with patch.dict("os.environ", {"GITHUB_OUTPUT": github_output_path}): + result = PromoteRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertTrue(os.path.exists(github_output_path)) + content = Path(github_output_path).read_text(encoding="utf-8") + self.assertEqual(content, "version=2.0.0\n") + def test_promote_rc_resolve_issue_success(self): # Arrange args = argparse.Namespace( diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py index ed59f0b76e..34c8107af4 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote_rc.py @@ -157,6 +157,10 @@ def run(self) -> int: self.git.tag(version, commit_sha) self.git.push(args.remote, version) + if github_output := os.environ.get("GITHUB_OUTPUT"): + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"version={version}\n") + print(f"Updating tracking issue #{issue_num} checklist...") self.gh.update_issue_body(issue_num, updated_body) From 52c51be21d612a8b385438bebc594375f1cd5a09 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 5 Jul 2026 11:35:34 -0700 Subject: [PATCH 070/179] docs: sync 2.2 changelog and news to main (#3901) The 2.2 release was completed, but the changelog updates and news file deletions were only performed on the release branch. We need to sync these changes back to main so that they are reflected in the unreleased state and future releases. --- CHANGELOG.md | 9 +++++++++ docs/pypi/download.md | 2 +- news/3854.fixed.md | 2 -- news/3886.fixed.md | 1 - news/3890.fixed.md | 2 -- news/pip-dep-tag-class.added.md | 4 ---- 6 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 news/3854.fixed.md delete mode 100644 news/3886.fixed.md delete mode 100644 news/3890.fixed.md delete mode 100644 news/pip-dep-tag-class.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb6c6ddee..17f176e5b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,12 +46,17 @@ compatibility. * Fixed a flaky error on Windows 2022 when looking up the win32 version during site initialization by retrying the lookup ([#3721](https://github.com/bazel-contrib/rules_python/issues/3721)). +* (binaries) Fixed building of legacy zipapps on Windows execution platforms by + using a hermetic tool instead of host `cat`. +* (bootstrap) Fixed stage 1 bootstrap imports when target outputs shadow + standard library modules. * (coverage) Skip lcov report when no data was collected. * (pypi) Fixed `experimental_index_url` checking truthiness before envsubst expansion. * (rules) Fixed venv output paths for `py_binary` and `py_test` targets whose names contain path separators so distinct targets with the same basename no longer share the same venv output directory. +* (windows) Fixed build data generation on localized Windows installations. {#v2-2-0-added} ### Added @@ -60,6 +65,10 @@ exported by bzl files. * Exposed {bzl:obj}`VenvSymlinkEntry` and {bzl:obj}`VenvSymlinkKind` in {bzl:target}`//python:py_info.bzl`. * (pypi) Added `@pypi` repo: a unified hub of `pip.parse` hubs. +* (pypi) Added a `dep` tag class to the `pip` bzlmod extension. This allows + modules to declare abstract PyPI dependencies, ensuring target structures + exist in the unified hub, while allowing other modules to provide the + concrete implementation via `pip.parse`. * (uv) Support for basic `uv.lock` generation via the `lock` rule and basic support for importing the `uv.lock` file itself. Since this may have bugs, please report this by creating new tickets. diff --git a/docs/pypi/download.md b/docs/pypi/download.md index 88c2dd296d..e819ed0791 100644 --- a/docs/pypi/download.md +++ b/docs/pypi/download.md @@ -123,7 +123,7 @@ wheel version from the active hub during the build. ### Declaring Abstract Dependencies (pip.dep) -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.2.0 Declaring abstract PyPI dependencies via `pip.dep` tags. ::: diff --git a/news/3854.fixed.md b/news/3854.fixed.md deleted file mode 100644 index 5c38b27642..0000000000 --- a/news/3854.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -(bootstrap) Fixed stage 1 bootstrap imports when target outputs shadow standard -library modules. diff --git a/news/3886.fixed.md b/news/3886.fixed.md deleted file mode 100644 index 2285813f8b..0000000000 --- a/news/3886.fixed.md +++ /dev/null @@ -1 +0,0 @@ -(windows) Fixed build data generation on localized Windows installations. diff --git a/news/3890.fixed.md b/news/3890.fixed.md deleted file mode 100644 index 59cd5fc5ca..0000000000 --- a/news/3890.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -(binaries) Fixed building of legacy zipapps on Windows execution platforms by -using a hermetic tool instead of host `cat`. diff --git a/news/pip-dep-tag-class.added.md b/news/pip-dep-tag-class.added.md deleted file mode 100644 index 5306186081..0000000000 --- a/news/pip-dep-tag-class.added.md +++ /dev/null @@ -1,4 +0,0 @@ -(pypi) Added a `dep` tag class to the `pip` bzlmod extension. This allows -modules to declare abstract PyPI dependencies, ensuring target structures -exist in the unified hub, while allowing other modules to provide the -concrete implementation via `pip.parse`. From bcaf08a64d24cfb8f6d05f032fbf95cd314b2474 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 5 Jul 2026 19:15:46 -0700 Subject: [PATCH 071/179] chore(release): sync changelog and version markers to main during backports (#3903) This PR implements the changes to sync both CHANGELOG.md and version next markers to the `main` branch when backports are processed. Key changes: - Adds git `diff`, `apply`, and `apply_check` helpers to the release tool. - Updates the GitHub helper to support custom PR base and auto-merge. - Updates `changelog_news.py` to support selective news merging and correct semver-sorted insertion, while preserving the static `Unreleased` section. - Updates `process_backports.py` to collect version marker diffs during cherry-pick, check if they apply cleanly to main, apply them, and report any failures in the PR body. These changes ensure that the `main` branch's changelog and version markers remain in sync with the release branch during the backport process. --- .github/workflows/on_pr_closed.yaml | 30 ++ tests/tools/private/release/BUILD.bazel | 9 + .../private/release/add_backports_test.py | 3 + .../private/release/changelog_news_test.py | 141 +++++- .../release/complete_sync_changelog_test.py | 120 +++++ tests/tools/private/release/git_test.py | 105 +++- tests/tools/private/release/prepare_test.py | 18 +- .../private/release/process_backports_test.py | 467 ++++++++++++++++-- .../private/release/release_issue_test.py | 61 +++ tools/private/release/add_backports.py | 3 + tools/private/release/changelog_news.py | 72 +-- .../release/complete_sync_changelog.py | 97 ++++ tools/private/release/gh.py | 46 +- tools/private/release/git.py | 52 +- tools/private/release/prepare.py | 6 +- tools/private/release/process_backports.py | 256 +++++++++- tools/private/release/release.py | 2 + tools/private/release/release_issue.py | 61 +++ 18 files changed, 1442 insertions(+), 107 deletions(-) create mode 100644 tests/tools/private/release/complete_sync_changelog_test.py create mode 100644 tools/private/release/complete_sync_changelog.py diff --git a/.github/workflows/on_pr_closed.yaml b/.github/workflows/on_pr_closed.yaml index 5dfcb89748..94c49b6561 100644 --- a/.github/workflows/on_pr_closed.yaml +++ b/.github/workflows/on_pr_closed.yaml @@ -81,3 +81,33 @@ jobs: "$PR_NUMBER" \ --remote origin \ --no-dry-run + + complete_sync_changelog: + if: | + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'type: sync-changelog') + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: read + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Complete Sync Changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + bazel run //tools/private/release -- complete-sync-changelog \ + --pr "$PR_NUMBER" + + diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index bc3b6f7e8b..633cfa328f 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -26,6 +26,15 @@ py_test( ], ) +py_test( + name = "complete_sync_changelog_test", + srcs = ["complete_sync_changelog_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + py_test( name = "create_rc_test", srcs = ["create_rc_test.py"], diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py index 7a5d96a23b..75729e87fe 100644 --- a/tests/tools/private/release/add_backports_test.py +++ b/tests/tools/private/release/add_backports_test.py @@ -35,6 +35,8 @@ def test_add_backports_explicit_issue(self): self.assertIn("- [ ] #125", call_args[1]) # Should also auto-add Tag RC0 self.assertIn("- [ ] Tag RC0", call_args[1]) + self.assertIn("- [ ] Sync Changelog #124", call_args[1]) + self.assertIn("- [ ] Sync Changelog #125", call_args[1]) def test_add_backports_auto_discover_success(self): args = argparse.Namespace(issue=None, prs=["124"]) @@ -98,6 +100,7 @@ def test_add_backports_no_auto_add_rc_if_pending(self): self.assertNotIn("Tag RC1", call_args[1]) # Tag RC0 should still be there self.assertIn("- [ ] Tag RC0", call_args[1]) + self.assertIn("- [ ] Sync Changelog #124", call_args[1]) if __name__ == "__main__": diff --git a/tests/tools/private/release/changelog_news_test.py b/tests/tools/private/release/changelog_news_test.py index f8bd141a55..83ab2cc959 100644 --- a/tests/tools/private/release/changelog_news_test.py +++ b/tests/tools/private/release/changelog_news_test.py @@ -16,21 +16,9 @@ def test_update_changelog_with_news(self): [unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -{#unreleased-removed} -### Removed -* Nothing removed. - -{#unreleased-changed} -### Changed -* Nothing changed. - -{#unreleased-fixed} -### Fixed -* Nothing fixed. - -{#unreleased-added} -### Added -* Nothing added. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). {#v2-0-2} ## [2.0.2] - 2026-05-14 @@ -408,6 +396,129 @@ def test_update_changelog_empty_news(self): self.assertNotIn("{#v3-0-0-fixed}", new_content) self.assertNotIn("{#v3-0-0-added}", new_content) + def test_update_changelog_selective_news_files(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Create news files + (news_dir / "123.fixed.md").write_text("Fix A") + (news_dir / "456.fixed.md").write_text("Fix B") + + # Act: Only process 123.fixed.md + changelog_news.update_changelog( + "2.0.3", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + news_files=[news_dir / "123.fixed.md"], + ) + + # Assert + # 1. Only 123.fixed.md should be deleted + self.assertFalse((news_dir / "123.fixed.md").exists()) + self.assertTrue((news_dir / "456.fixed.md").exists()) + + new_content = changelog_path.read_text() + + # 2. Only Fix A should be in the changelog + self.assertIn("Fix A", new_content) + self.assertNotIn("Fix B", new_content) + + def test_update_changelog_insertion_point(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +{#v2-2-0} +## [2.2.0] - 2026-06-30 + +[2.2.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.2.0 + +{#v2-0-0} +## [2.0.0] - 2026-04-09 + +[2.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0 +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + (news_dir / "123.fixed.md").write_text("Fix in 2.1.0") + + # Act: Insert 2.1.0 + changelog_news.update_changelog( + "2.1.0", + "2026-06-17", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # Verify 2.1.0 is inserted BEFORE 2.0.0 but AFTER 2.2.0 + idx_2_2_0 = new_content.index("{#v2-2-0}") + idx_2_1_0 = new_content.index("{#v2-1-0}") + idx_2_0_0 = new_content.index("{#v2-0-0}") + + self.assertTrue(idx_2_2_0 < idx_2_1_0 < idx_2_0_0) + self.assertIn("Fix in 2.1.0", new_content) + + def test_update_changelog_insertion_point_too_small(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +{#v2-0-0} +## [2.0.0] - 2026-04-09 + +[2.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0 +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + (news_dir / "123.fixed.md").write_text("Fix in 1.0.0") + + # Act & Assert + with self.assertRaises(ValueError) as ctx: + changelog_news.update_changelog( + "1.0.0", + "2026-01-01", + changelog_path=changelog_path, + news_dir=news_dir, + ) + self.assertIn( + "Could not find a version in CHANGELOG.md smaller than 1.0.0", + str(ctx.exception), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/private/release/complete_sync_changelog_test.py b/tests/tools/private/release/complete_sync_changelog_test.py new file mode 100644 index 0000000000..66c40c01d3 --- /dev/null +++ b/tests/tools/private/release/complete_sync_changelog_test.py @@ -0,0 +1,120 @@ +import argparse +import unittest +from unittest.mock import patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.complete_sync_changelog import CompleteSyncChangelog + + +class CompleteSyncChangelogTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.addCleanup(patch.stopall) + + # Dynamic mock for issue body + self.issue_body = "" + + def mock_get_body(issue_num): + return self.issue_body + + def mock_update_body(issue_num, body): + self.issue_body = body + + self.mock_gh.get_issue_body.side_effect = mock_get_body + self.mock_gh.update_issue_body.side_effect = mock_update_body + + def test_complete_sync_changelog_success(self): + args = argparse.Namespace(pr=999) + self.mock_gh.get_pr_info.return_value = { + "state": "MERGED", + "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + self.issue_body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Sync Changelog #124 | status=pending pr=#999 +- [ ] Sync Changelog #125 | status=pending pr=#999 +- [ ] Sync Changelog #126 | status=pending pr=#888 +- [ ] Tag Final + +## Backports +""" + result = CompleteSyncChangelog(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_pr_info.assert_called_once_with(999) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_gh.update_issue_body.assert_called_once() + + # Check that only tasks pointing to #999 were marked checked=True and status=done + self.assertIn( + "- [x] Sync Changelog #124 | status=done pr=#999 commit= abcdef12", + self.issue_body, + ) + self.assertIn( + "- [x] Sync Changelog #125 | status=done pr=#999 commit= abcdef12", + self.issue_body, + ) + # Task pointing to #888 should remain unchanged + self.assertIn( + "- [ ] Sync Changelog #126 | status=pending pr=#888", + self.issue_body, + ) + + def test_complete_sync_changelog_not_merged(self): + args = argparse.Namespace(pr=999) + self.mock_gh.get_pr_info.return_value = { + "state": "OPEN", + "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", + } + + result = CompleteSyncChangelog(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_info.assert_called_once_with(999) + self.mock_gh.update_issue_body.assert_not_called() + + def test_complete_sync_changelog_missing_tracking_issue_link(self): + args = argparse.Namespace(pr=999) + self.mock_gh.get_pr_info.return_value = { + "state": "MERGED", + "body": "Updates CHANGELOG.md without tracking issue link", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + + result = CompleteSyncChangelog(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_info.assert_called_once_with(999) + self.mock_gh.update_issue_body.assert_not_called() + + def test_complete_sync_changelog_no_matching_tasks(self): + args = argparse.Namespace(pr=999) + self.mock_gh.get_pr_info.return_value = { + "state": "MERGED", + "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + # Checklist has no tasks pointing to #999 + self.issue_body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Sync Changelog #124 | status=pending pr=#888 +- [ ] Tag Final + +## Backports +""" + result = CompleteSyncChangelog(args, self.mock_gh).run() + + # Should log warning but return 0 (success/noop) + self.assertEqual(result, 0) + self.mock_gh.get_pr_info.assert_called_once_with(999) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_gh.update_issue_body.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/git_test.py b/tests/tools/private/release/git_test.py index 16667b8715..34643fc016 100644 --- a/tests/tools/private/release/git_test.py +++ b/tests/tools/private/release/git_test.py @@ -41,7 +41,7 @@ def test_checkout_track_remote_existing_branch( self.mock_run_git.assert_called_once_with( "checkout", "my-branch", capture_output=False ) - mock_reset_hard.assert_called_once_with("origin/my-branch") + mock_reset_hard.assert_called_once_with(reset_to="origin/my-branch") class GitFetchTest(unittest.TestCase): @@ -82,5 +82,108 @@ def test_fetch_all_options(self): ) +class GitGetModifiedFilesTest(unittest.TestCase): + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_get_modified_files(self): + self.mock_run_git.return_value = "file1.txt\nfile2.py\n\n" + files = self.git.get_modified_files("HEAD") + self.mock_run_git.assert_called_once_with( + "show", "--name-only", "--format=", "HEAD" + ) + self.assertEqual(files, ["file1.txt", "file2.py"]) + + def test_get_modified_files_empty(self): + self.mock_run_git.return_value = "" + files = self.git.get_modified_files("HEAD") + self.assertEqual(files, []) + + +class GitDiffTest(unittest.TestCase): + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_diff_has_changes(self): + self.mock_run_git.return_value = "some diff output" + output = self.git.diff() + self.mock_run_git.assert_called_once_with("diff") + self.assertEqual(output, "some diff output") + + def test_diff_empty(self): + self.mock_run_git.return_value = "" + output = self.git.diff() + self.mock_run_git.assert_called_once_with("diff") + self.assertEqual(output, "") + + +class GitApplyTest(unittest.TestCase): + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_apply(self): + self.git.apply("patch.patch") + self.mock_run_git.assert_called_once_with( + "apply", "patch.patch", capture_output=False + ) + + +class GitApplyCheckTest(unittest.TestCase): + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_apply_check_clean(self): + self.mock_run_git.return_value = "" + result = self.git.apply_check("patch.patch") + self.mock_run_git.assert_called_once_with( + "apply", "--check", "patch.patch", capture_output=False + ) + self.assertTrue(result) + + def test_apply_check_conflict(self): + import subprocess + + self.mock_run_git.side_effect = subprocess.CalledProcessError( + 1, ["git", "apply", "--check", "patch.patch"] + ) + result = self.git.apply_check("patch.patch") + self.mock_run_git.assert_called_once_with( + "apply", "--check", "patch.patch", capture_output=False + ) + self.assertFalse(result) + + +class GitResetHardTest(unittest.TestCase): + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_reset_hard_default(self): + self.git.reset_hard() + self.mock_run_git.assert_called_once_with( + "reset", "--hard", "HEAD", capture_output=False + ) + + def test_reset_hard_custom(self): + self.git.reset_hard(reset_to="my-commit") + self.mock_run_git.assert_called_once_with( + "reset", "--hard", "my-commit", capture_output=False + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/private/release/prepare_test.py b/tests/tools/private/release/prepare_test.py index 3d92b131e2..45b92a636f 100644 --- a/tests/tools/private/release/prepare_test.py +++ b/tests/tools/private/release/prepare_test.py @@ -36,7 +36,11 @@ def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): self.assertEqual(result, 0) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_gh.create_pr.assert_called_once_with( + title="Prepare release v2.0.0", + body="Work towards #123", + base="main", + ) self.mock_git.add_modified_and_deleted.assert_called_once() @patch("tools.private.release.prepare.changelog_news") @@ -67,7 +71,11 @@ def test_prepare_success_create_issue(self, mock_replace, mock_changelog): self.mock_gh.create_tracking_issue.assert_called_once_with( "2.0.0", "dummy template content" ) - self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_gh.create_pr.assert_called_once_with( + title="Prepare release v2.0.0", + body="Work towards #123", + base="main", + ) self.mock_git.add_modified_and_deleted.assert_called_once() @patch("tools.private.release.prepare.changelog_news") @@ -172,7 +180,11 @@ def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changel "origin", "prepare-2.0.0", set_upstream=True, force=True ) self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") - self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_gh.create_pr.assert_called_once_with( + title="Prepare release v2.0.0", + body="Work towards #123", + base="main", + ) self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] self.assertIn("pr=#789", call_args[1]) diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py index fe7bae256d..d5df43ae46 100644 --- a/tests/tools/private/release/process_backports_test.py +++ b/tests/tools/private/release/process_backports_test.py @@ -20,12 +20,26 @@ def setUp(self): self.mock_gh.resolve_pr_number.side_effect = lambda x: int( x.lstrip("#").split("/")[-1] ) + self.mock_git.diff.return_value = "" + self.mock_git.apply_check.return_value = True + + # Dynamic mock for issue body + self.issue_body = "" + + def mock_get_body(issue_num): + return self.issue_body + + def mock_update_body(issue_num, body): + self.issue_body = body + + self.mock_gh.get_issue_body.side_effect = mock_get_body + self.mock_gh.update_issue_body.side_effect = mock_update_body def test_process_backports_no_pending(self): args = argparse.Namespace( issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None ) - self.mock_gh.get_issue_body.return_value = "No backports here" + self.issue_body = "No backports here" result = ProcessBackports(args, self.mock_git, self.mock_gh).run() @@ -40,10 +54,12 @@ def test_process_backports_success(self, mock_datetime): issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ + self.issue_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch +- [ ] Sync Changelog #124 +- [ ] Tag Final ## Backports - [ ] #124 | status=pending @@ -60,33 +76,204 @@ def mock_resolve(items): self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_sha.side_effect = ["12345678", "12345678", "main_sha"] self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + self.mock_git.get_modified_files.return_value = ["news/124.fixed.md"] + self.mock_git.diff.return_value = "version diff for 124" + self.mock_git.apply_check.return_value = True + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( - [call("origin", tags=True, force=True), call("origin")] + [ + call("origin", tags=True, force=True), + call("origin"), + call("origin", refspec="main"), + ] ) - self.mock_git.checkout.assert_called_once_with( - "release/2.0", track_remote="origin" + self.mock_git.checkout.assert_has_calls( + [ + call("release/2.0", track_remote="origin"), + call("main", track_remote="origin"), + call("prepare-2.0.0-backports-6affdae", create_branch=True), + call("release/2.0"), + ] ) self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_changelog_news.update_changelog.assert_called_once_with( - "2.0.0", "2026-07-01" + self.mock_git.diff.assert_called_once() + self.mock_git.apply_check.assert_called_once_with(unittest.mock.ANY) + self.mock_git.apply.assert_called_once_with(unittest.mock.ANY) + self.mock_changelog_news.update_changelog.assert_has_calls( + [ + call("2.0.0", "2026-07-01"), + call( + "2.0.0", + "2026-07-01", + news_files=["news/124.fixed.md"], + delete_news=True, + ), + ] ) - self.mock_git.add_modified_and_deleted.assert_called_once() + self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 2) self.mock_replace_version_next.assert_called_once_with("2.0.0") - self.mock_git.commit.assert_called_once_with( - 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True + self.mock_git.commit.assert_has_calls( + [ + call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), + call("chore(release): sync changelog for v2.0.0 backports"), + ] + ) + self.mock_git.push.assert_has_calls( + [ + call("origin", "release/2.0"), + call( + "origin", + "prepare-2.0.0-backports-6affdae", + set_upstream=True, + force=True, + ), + ] ) - self.mock_git.push.assert_called_once_with("origin", "release/2.0") - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("- [x] #124 | status=done rc=rc0 commit= 12345678", call_args[1]) + self.mock_gh.create_pr.assert_called_once_with( + title="chore(release): sync changelog for v2.0.0 backports", + body="Updates CHANGELOG.md and removes news files for backports:\n- #124\n\nWork towards #123\nRelease-Tracking-Issue: #123", + base="main", + labels=["type: sync-changelog"], + ) + self.mock_gh.enable_auto_merge.assert_called_once_with(999) + + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + call_args_list = self.mock_gh.update_issue_body.call_args_list + self.assertEqual(call_args_list[0][0][0], 123) + self.assertIn( + "- [x] #124 | status=done rc=rc0 commit= 12345678", call_args_list[0][0][1] + ) + self.assertEqual(call_args_list[1][0][0], 123) + self.assertIn( + "- [ ] Sync Changelog #124 | status=pending pr=#999", + call_args_list[1][0][1], + ) + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_sync_branch_exists(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.issue_body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Sync Changelog #124 +- [ ] Tag Final + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.get_commit_sha.side_effect = ["12345678", "12345678", "main_sha"] + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + self.mock_git.get_modified_files.return_value = ["news/124.fixed.md"] + self.mock_git.diff.return_value = "version diff for 124" + self.mock_git.apply_check.return_value = True + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" + + # Configure branch to exist + self.mock_git.branch_exists.return_value = True + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [ + call("origin", tags=True, force=True), + call("origin"), + call("origin", refspec="main"), + ] + ) + self.mock_git.checkout.assert_has_calls( + [ + call("release/2.0", track_remote="origin"), + call("main", track_remote="origin"), + # Called without create_branch=True + call("prepare-2.0.0-backports-6affdae"), + call("release/2.0"), + ] + ) + # Verify reset_hard was called to reset the existing branch to main + self.mock_git.reset_hard.assert_has_calls( + [ + call(reset_to="main"), + ] + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_git.diff.assert_called_once() + self.mock_git.apply_check.assert_called_once_with(unittest.mock.ANY) + self.mock_git.apply.assert_called_once_with(unittest.mock.ANY) + self.mock_changelog_news.update_changelog.assert_has_calls( + [ + call("2.0.0", "2026-07-01"), + call( + "2.0.0", + "2026-07-01", + news_files=["news/124.fixed.md"], + delete_news=True, + ), + ] + ) + self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 2) + self.mock_replace_version_next.assert_called_once_with("2.0.0") + self.mock_git.commit.assert_has_calls( + [ + call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), + call("chore(release): sync changelog for v2.0.0 backports"), + ] + ) + self.mock_git.push.assert_has_calls( + [ + call("origin", "release/2.0"), + call( + "origin", + "prepare-2.0.0-backports-6affdae", + set_upstream=True, + force=True, + ), + ] + ) + + self.mock_gh.create_pr.assert_called_once_with( + title="chore(release): sync changelog for v2.0.0 backports", + body="Updates CHANGELOG.md and removes news files for backports:\n- #124\n\nWork towards #123\nRelease-Tracking-Issue: #123", + base="main", + labels=["type: sync-changelog"], + ) + self.mock_gh.enable_auto_merge.assert_called_once_with(999) + + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + call_args_list = self.mock_gh.update_issue_body.call_args_list + self.assertEqual(call_args_list[0][0][0], 123) + self.assertIn( + "- [x] #124 | status=done rc=rc0 commit= 12345678", call_args_list[0][0][1] + ) + self.assertEqual(call_args_list[1][0][0], 123) + self.assertIn( + "- [ ] Sync Changelog #124 | status=pending pr=#999", + call_args_list[1][0][1], + ) @patch("tools.private.release.process_backports.datetime") def test_process_backports_dry_run(self, mock_datetime): @@ -95,10 +282,12 @@ def test_process_backports_dry_run(self, mock_datetime): issue=123, remote="origin", dry_run=True, add=None, triggering_comment=None ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ + self.issue_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch +- [ ] Sync Changelog #124 +- [ ] Tag Final ## Backports - [ ] #124 | status=pending @@ -115,28 +304,55 @@ def mock_resolve(items): self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_sha.side_effect = ["12345678", "main_sha"] self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + self.mock_git.get_modified_files.return_value = ["news/124.fixed.md"] + self.mock_git.diff.return_value = "version diff for 124" + self.mock_git.apply_check.return_value = True result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( - [call("origin", tags=True, force=True), call("origin")] + [ + call("origin", tags=True, force=True), + call("origin"), + call("origin", refspec="main"), + ] ) - self.mock_git.checkout.assert_called_once_with( - "release/2.0", track_remote="origin" + self.mock_git.checkout.assert_has_calls( + [ + call("release/2.0", track_remote="origin"), + call("main", track_remote="origin"), + call("release/2.0"), + ] ) self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_changelog_news.update_changelog.assert_called_once_with( - "2.0.0", "2026-07-01" + self.mock_git.diff.assert_called_once() + self.mock_git.apply_check.assert_called_once_with(unittest.mock.ANY) + self.mock_git.apply.assert_not_called() + self.mock_changelog_news.update_changelog.assert_has_calls( + [ + call("2.0.0", "2026-07-01"), + call( + "2.0.0", + "2026-07-01", + news_files=["news/124.fixed.md"], + delete_news=True, + ), + ] ) - self.mock_git.add_modified_and_deleted.assert_called_once() + self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 1) self.mock_replace_version_next.assert_called_once_with("2.0.0") self.mock_git.commit.assert_called_once_with( 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True ) - self.mock_git.reset_hard.assert_called_once_with("12345678") + self.mock_git.reset_hard.assert_has_calls( + [ + call(reset_to="12345678"), + call(reset_to="main_sha"), + ] + ) self.mock_git.push.assert_not_called() self.mock_gh.update_issue_body.assert_not_called() @@ -145,7 +361,7 @@ def test_process_backports_ignored_and_failed_states(self): issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ + self.issue_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -186,7 +402,7 @@ def test_process_backports_ignored_error_status(self): issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ + self.issue_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -211,7 +427,7 @@ def test_process_backports_cherry_pick_failed(self, mock_datetime): issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ + self.issue_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -261,7 +477,7 @@ def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetim triggering_comment=None, ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ + self.issue_body = """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 @@ -284,14 +500,18 @@ def mock_resolve(items): self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + # Mock create_pr to return a string to avoid int(MagicMock) returning 1 + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) - # update_issue_body should be called twice: + # update_issue_body should be called 3 times: # 1. When adding backports and auto-adding Tag RC1 task. # 2. When updating the backport status to done. - self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + # 3. When updating the sync task status to pending. + self.assertEqual(self.mock_gh.update_issue_body.call_count, 3) call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] call2_args = self.mock_gh.update_issue_body.call_args_list[1][0] @@ -299,15 +519,22 @@ def mock_resolve(items): self.assertEqual(call1_args[0], 123) self.assertIn("- [ ] #124", call1_args[1]) self.assertIn("- [ ] Tag RC1", call1_args[1]) + self.assertIn("- [ ] Sync Changelog #124", call1_args[1]) self.assertIn( "- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]" - " Tag RC1\n- [ ] Tag Final", + " Tag RC1\n- [ ] Sync Changelog #124\n- [ ] Tag Final", call1_args[1].strip(), ) self.assertEqual(call2_args[0], 123) self.assertIn("- [x] #124 | status=done rc=rc1 commit= 12345678", call2_args[1]) + call3_args = self.mock_gh.update_issue_body.call_args_list[2][0] + self.assertEqual(call3_args[0], 123) + self.assertIn( + "- [ ] Sync Changelog #124 | status=pending pr=#999", call3_args[1] + ) + @patch("tools.private.release.process_backports.datetime") def test_process_backports_add_backports_marks_invalid(self, mock_datetime): mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) @@ -319,7 +546,7 @@ def test_process_backports_add_backports_marks_invalid(self, mock_datetime): triggering_comment=None, ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ + self.issue_body = """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 @@ -335,23 +562,189 @@ def test_process_backports_add_backports_marks_invalid(self, mock_datetime): def mock_resolve(items): # Both 124 and 125 should be processed, 'invalid' should be ignored (it has error status) for item in items: - if item.pr_ref in ("#124", "#125"): - item.commit = "abcdef12" + if item.pr_ref == "#124": + item.commit = "sha_124" + item.status = "done" + elif item.pr_ref == "#125": + item.commit = "sha_125" item.status = "done" return items self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.sort_commits_chronologically.return_value = ["sha_124", "sha_125"] + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) # Should have updated body to add 124, 125, and invalid - self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + # update_issue_body should be called 4 times: + # 1. When adding backports. + # 2. When updating 124 status to done. + # 3. When updating 125 status to done. + # 4. When updating sync tasks status to pending. + self.assertEqual(self.mock_gh.update_issue_body.call_count, 4) call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] self.assertIn("- [ ] #124", call1_args[1]) self.assertIn("- [ ] #125", call1_args[1]) self.assertIn("- [ ] invalid | status=error-invalid-pr", call1_args[1]) + self.assertIn("- [ ] Sync Changelog #124", call1_args[1]) + self.assertIn("- [ ] Sync Changelog #125", call1_args[1]) + + call4_args = self.mock_gh.update_issue_body.call_args_list[3][0] + self.assertIn( + "- [ ] Sync Changelog #124 | status=pending pr=#999", call4_args[1] + ) + self.assertIn( + "- [ ] Sync Changelog #125 | status=pending pr=#999", call4_args[1] + ) + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_version_sync_failure(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.issue_body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Sync Changelog #124 +- [ ] Sync Changelog #125 +- [ ] Tag Final + +## Backports +- [ ] #124 | status=pending +- [ ] #125 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref in ("#124", "#125"): + item.commit = "sha_" + item.pr_ref.lstrip("#") + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["sha_124", "sha_125"] + self.mock_git.get_commit_sha.side_effect = [ + "12345678", + "sha_124_amended", + "sha_125_amended", + "main_sha", + ] + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + self.mock_git.get_modified_files.side_effect = [ + ["news/124.fixed.md"], + ["news/125.fixed.md"], + ] + self.mock_git.diff.side_effect = ["diff 124", "diff 125"] + self.mock_git.apply_check.side_effect = [False, True] + self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [ + call("origin", tags=True, force=True), + call("origin"), + call("origin", refspec="main"), + ] + ) + self.mock_git.checkout.assert_has_calls( + [ + call("release/2.0", track_remote="origin"), + call("main", track_remote="origin"), + call("prepare-2.0.0-backports-b552a96", create_branch=True), + call("release/2.0"), + ] + ) + self.mock_git.cherry_pick.assert_has_calls( + [ + call("sha_124"), + call("sha_125"), + ] + ) + # diff should be called for each successful cherry-pick + self.assertEqual(self.mock_git.diff.call_count, 2) + # apply_check should be called for both patches + self.assertEqual(self.mock_git.apply_check.call_count, 2) + # apply should only be called for 125 (since 124 failed check) + self.mock_git.apply.assert_called_once_with(unittest.mock.ANY) + + self.mock_changelog_news.update_changelog.assert_has_calls( + [ + call("2.0.0", "2026-07-01"), + call("2.0.0", "2026-07-01"), + call( + "2.0.0", + "2026-07-01", + news_files=["news/124.fixed.md", "news/125.fixed.md"], + delete_news=True, + ), + ] + ) + # add_modified_and_deleted called: + # - once per cherry-pick (2) + # - once on main backport branch (1) + # Total = 3 + self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 3) + # replace_version_next called once per cherry-pick + self.assertEqual(self.mock_replace_version_next.call_count, 2) + + self.mock_git.commit.assert_has_calls( + [ + call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), + call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), + call("chore(release): sync changelog for v2.0.0 backports"), + ] + ) + self.mock_git.push.assert_has_calls( + [ + call("origin", "release/2.0"), + call("origin", "release/2.0"), + call( + "origin", + "prepare-2.0.0-backports-b552a96", + set_upstream=True, + force=True, + ), + ] + ) + + # PR body should contain warning about 124 + expected_body = ( + "Updates CHANGELOG.md and removes news files for backports:\n" + "- #124\n" + "- #125\n" + "\n" + "Warning: These PRs failed to update their version markers:\n" + "- #124\n" + "\n" + "Work towards #123\n" + "Release-Tracking-Issue: #123" + ) + self.mock_gh.create_pr.assert_called_once_with( + title="chore(release): sync changelog for v2.0.0 backports", + body=expected_body, + base="main", + labels=["type: sync-changelog"], + ) + self.mock_gh.enable_auto_merge.assert_called_once_with(999) + + # update_issue_body called 3 times (twice for backports, once for sync tasks) + self.assertEqual(self.mock_gh.update_issue_body.call_count, 3) + call3_args = self.mock_gh.update_issue_body.call_args_list[2][0] + self.assertIn( + "- [ ] Sync Changelog #124 | status=pending pr=#999", call3_args[1] + ) + self.assertIn( + "- [ ] Sync Changelog #125 | status=pending pr=#999", call3_args[1] + ) if __name__ == "__main__": diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py index cfc6da6ae4..36f0dbd704 100644 --- a/tests/tools/private/release/release_issue_test.py +++ b/tests/tools/private/release/release_issue_test.py @@ -2,7 +2,9 @@ from tools.private.release.release_issue import ( add_backports_to_body, + add_sync_changelog_task_to_body, format_metadata_line, + parse_checklist_state, parse_metadata_line, ) @@ -96,6 +98,65 @@ def test_add_backports_to_body(self): """ self.assertEqual(updated_body.strip(), expected_body.strip()) + def test_add_sync_changelog_task_to_body(self): + body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final +""" + # Insert first task (should go before Tag Final) + body = add_sync_changelog_task_to_body(body, 124) + expected = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Sync Changelog #124 +- [ ] Tag Final +""" + self.assertEqual(body.strip(), expected.strip()) + + # Insert second task (should go after the last Sync Changelog task) + body = add_sync_changelog_task_to_body(body, 125) + expected = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Sync Changelog #124 +- [ ] Sync Changelog #125 +- [ ] Tag Final +""" + self.assertEqual(body.strip(), expected.strip()) + + # Insert duplicate (should be ignored) + body = add_sync_changelog_task_to_body(body, 124) + self.assertEqual(body.strip(), expected.strip()) + + def test_parse_checklist_state_with_sync_changelogs(self): + body = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 +- [ ] Sync Changelog #124 | status=pending pr=#125 +- [ ] Sync Changelog #126 +- [ ] Tag Final +""" + state = parse_checklist_state(body) + self.assertIn(124, state["sync_changelogs"]) + self.assertIn(126, state["sync_changelogs"]) + + task_124 = state["sync_changelogs"][124] + self.assertEqual(task_124.name, "Sync Changelog #124") + self.assertFalse(task_124.checked) + self.assertEqual(task_124.status, "pending") + self.assertEqual(task_124.pr, "#125") + + task_126 = state["sync_changelogs"][126] + self.assertEqual(task_126.name, "Sync Changelog #126") + self.assertFalse(task_126.checked) + self.assertIsNone(task_126.status) + self.assertIsNone(task_126.pr) + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py index c25f509b9c..63c70af7d7 100644 --- a/tools/private/release/add_backports.py +++ b/tools/private/release/add_backports.py @@ -4,6 +4,7 @@ from tools.private.release.release_issue import ( add_backports_to_body, add_rc_task_to_body, + add_sync_changelog_task_to_body, parse_checklist_state, ) @@ -60,6 +61,8 @@ def run(self) -> int: body = self.gh.get_issue_body(issue_num) items_to_add = [{"ref": f"#{pr}"} for pr in resolved_prs] body = add_backports_to_body(body, items_to_add) + for pr in resolved_prs: + body = add_sync_changelog_task_to_body(body, pr) state = parse_checklist_state(body) rc_tags = state.get("rc_tags", {}) has_pending_rc = any( diff --git a/tools/private/release/changelog_news.py b/tools/private/release/changelog_news.py index b896af1f6b..e09b082ade 100644 --- a/tools/private/release/changelog_news.py +++ b/tools/private/release/changelog_news.py @@ -3,14 +3,6 @@ import pathlib import re -_UNRELEASED_TEMPLATE_BODY = """## Unreleased - -[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased - -Unreleased changes are tracked as individual files in the [news/](./news) -directory, or view the [latest generated -changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).""" - def _get_sub_category(content): """Extracts the sub-category in parentheses from the entry content.""" @@ -108,6 +100,34 @@ def generate_release_block(version, release_date, news_entries): return "\n".join(lines) +def _parse_simple_version(ver_str): + """Parses a version string (X-Y-Z or X.Y.Z) into a tuple of ints.""" + normalized = ver_str.replace("-", ".") + return tuple(int(x) for x in normalized.split(".")) + + +def _find_insertion_point(changelog_content, new_version): + """Finds the character index to insert the new version block. + + It should be inserted before the first version in the changelog that is + smaller than new_version. + """ + # Find all version anchors: {#vX-Y-Z} + matches = list(re.finditer(r"\{#v(?P\d+-\d+-\d+)\}", changelog_content)) + + parsed_new_ver = _parse_simple_version(new_version) + + for m in matches: + ver_str = m.group("ver") + parsed_ver = _parse_simple_version(ver_str) + if parsed_ver < parsed_new_ver: + return m.start() + + raise ValueError( + f"Could not find a version in CHANGELOG.md smaller than {new_version} to insert before." + ) + + def _add_news_to_changelog(input_path, output_path, version, entries, release_date): """Adds or merges news entries into CHANGELOG.md.""" input_path = pathlib.Path(input_path) @@ -253,26 +273,15 @@ def _add_news_to_changelog(input_path, output_path, version, entries, release_da " release section from news entries..." ) new_release_block = generate_release_block(version, release_date, entries) - replacement = ( - f"{{#unreleased}}\n{_UNRELEASED_TEMPLATE_BODY}\n\n{new_release_block}\n" - ) - - # Replace the active Unreleased section (from {#unreleased} to the first release anchor) - pattern = ( - r"(?P\{#unreleased\})(?P.*?)(?=\n\s*\{#v\d+-\d+-\d+\}|\Z)" - ) - if not re.search(pattern, changelog_content, re.DOTALL): - raise RuntimeError( - "Could not find active Unreleased section to replace in CHANGELOG.md" - ) + # Find insertion point + insertion_point = _find_insertion_point(changelog_content, version) - new_content = re.sub( - pattern, - replacement, - changelog_content, - count=1, - flags=re.DOTALL, + new_content = ( + changelog_content[:insertion_point] + + new_release_block + + "\n\n" + + changelog_content[insertion_point:] ) output_path.write_text(new_content, encoding="utf-8") @@ -284,9 +293,13 @@ def merge_new_into_changelog( version, release_date, delete_news=False, + news_files=None, ): """Merges news entries from news_dir into changelog_path and writes to output_path.""" - news_files = _get_news_files(news_dir) + if news_files is None: + news_files = _get_news_files(news_dir) + else: + news_files = [pathlib.Path(f) for f in news_files] entries = _parse_new_files(news_files) _add_news_to_changelog( input_path=changelog_path, @@ -297,7 +310,8 @@ def merge_new_into_changelog( ) if delete_news: for p in news_files: - p.unlink() + if p.exists(): + p.unlink() if news_files: print(f"Removed {len(news_files)} processed news files.") @@ -309,6 +323,7 @@ def update_changelog( output_path=None, news_dir="news", delete_news=True, + news_files=None, ): """Performs the version replacements in CHANGELOG.md.""" if output_path is None: @@ -320,4 +335,5 @@ def update_changelog( version=version, release_date=release_date, delete_news=delete_news, + news_files=news_files, ) diff --git a/tools/private/release/complete_sync_changelog.py b/tools/private/release/complete_sync_changelog.py new file mode 100644 index 0000000000..9c8d9028f0 --- /dev/null +++ b/tools/private/release/complete_sync_changelog.py @@ -0,0 +1,97 @@ +"""Subcommand to mark sync changelog tasks as complete.""" + +import re + +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import ( + parse_checklist_state, + update_task_in_body, +) + + +class CompleteSyncChangelog: + """Class to mark sync changelog tasks as complete.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the complete-sync-changelog subcommand.""" + args = self.args + print(f"Completing sync changelog for PR #{args.pr}...") + + pr_info = self.gh.get_pr_info(args.pr) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") + return 1 + + # Resolve issue number from PR body using Release-Tracking-Issue: # + pr_body = pr_info.get("body") or "" + match = re.search(r"Release-Tracking-Issue:\s*#(\d+)", pr_body) + if not match: + print( + f"Error: Could not find 'Release-Tracking-Issue: #' in" + f" PR #{args.pr} body: {pr_body}" + ) + return 1 + + issue_num = int(match.group(1)) + print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") + + commit_sha = pr_info["mergeCommit"]["oid"] + short_commit = commit_sha[:8] + print( + f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue..." + ) + + # Update checklist: mark all Sync Changelog tasks pointing to this PR as done + body = self.gh.get_issue_body(issue_num) + state = parse_checklist_state(body) + sync_changelogs = state.get("sync_changelogs", {}) + + updated_any = False + for pr_num, task in sync_changelogs.items(): + # Check if this task points to our merged PR + task_pr = task.metadata.get("pr") + if task_pr == f"#{args.pr}": + print(f"Marking task '{task.name}' as complete...") + metadata = { + "status": "done", + "pr": f"#{args.pr}", + "commit": short_commit, + } + body = update_task_in_body( + body, task.name, checked=True, metadata=metadata + ) + updated_any = True + + if not updated_any: + print(f"Warning: No 'Sync Changelog' tasks found pointing to PR #{args.pr}") + return 0 + + self.gh.update_issue_body(issue_num, body) + print("Sync changelog tasks marked complete successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for complete-sync-changelog subcommand.""" + parser = subparsers.add_parser( + "complete-sync-changelog", + help="Mark the Sync Changelog tasks as complete in the tracking issue.", + ) + parser.add_argument( + "--pr", + type=int, + required=True, + help="The merged sync changelog PR number.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 8990ef5abf..92cc3ca5c4 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -263,24 +263,52 @@ def update_issue_body(self, issue_num: int, body: str) -> None: if os.path.exists(temp_path): os.unlink(temp_path) - def create_pr(self, version: str, issue_num: int) -> str: - """Creates a pull request for release preparation. + def create_pr( + self, + title: str, + body: str, + base: str = "main", + labels: list[str] | None = None, + ) -> str: + """Creates a pull request. Args: - version: The version being prepared. - issue_num: The associated tracking issue number. + title: The title of the PR. + body: The body of the PR. + base: The base branch to merge into (default: 'main'). + labels: Optional list of labels to add to the PR. Returns: The URL of the created PR. """ - output = self._gh_pr( + cmd = [ "create", - f"--title=Prepare release v{version}", - f"--body=Work towards #{issue_num}", - "--base=main", - ) + f"--title={title}", + f"--body={body}", + f"--base={base}", + ] + if labels: + for label in labels: + cmd.append(f"--label={label}") + output = self._gh_pr(*cmd) return output if output else "" + def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: + """Enables auto-merge for a PR. + + Args: + pr_num: The PR number. + method: The merge method ('squash', 'rebase', or 'merge'). + """ + cmd = ["merge", str(pr_num), "--auto"] + if method == "squash": + cmd.append("--squash") + elif method == "rebase": + cmd.append("--rebase") + elif method == "merge": + cmd.append("--merge") + self._gh_pr(*cmd, capture_output=False) + def get_open_pr(self, branch_name: str) -> dict | None: """Returns PR info if an open PR exists for the given branch. diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 3e7f77f39a..4305de855f 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -80,7 +80,7 @@ def checkout( self._run_git(*cmd, capture_output=False) if should_reset_hard: - self.reset_hard(f"{track_remote}/{ref}") + self.reset_hard(reset_to=f"{track_remote}/{ref}") def add(self, *files: str) -> None: """Stages files for commit. @@ -191,13 +191,13 @@ def cherry_pick_abort(self) -> None: """Aborts an in-progress cherry-pick operation.""" self._run_git("cherry-pick", "--abort", capture_output=False) - def reset_hard(self, ref: str = "HEAD") -> None: + def reset_hard(self, *, reset_to: str = "HEAD") -> None: """Resets the index and working tree to a specific reference. Args: - ref: The git reference to reset to. Defaults to 'HEAD'. + reset_to: The git reference to reset to. Defaults to 'HEAD'. """ - self._run_git("reset", "--hard", ref, capture_output=False) + self._run_git("reset", "--hard", reset_to, capture_output=False) def status(self) -> str: """Returns the output of git status --porcelain. @@ -353,3 +353,47 @@ def get_remote_tags(self, remote: str) -> list[str]: if not tag.endswith("^{}"): tags.append(tag) return tags + + def get_modified_files(self, ref: str) -> list[str]: + """Returns a list of files modified in a given reference. + + Args: + ref: The git reference. + + Returns: + A list of file paths. + """ + output = self._run_git("show", "--name-only", "--format=", ref) + return [line for line in output.splitlines() if line.strip()] if output else [] + + def diff(self) -> str: + """Returns the diff of unstaged changes. + + Returns: + The diff output as a string. + """ + output = self._run_git("diff") + return output if output else "" + + def apply(self, patch_file: str) -> None: + """Applies a patch file. + + Args: + patch_file: The path to the patch file. + """ + self._run_git("apply", patch_file, capture_output=False) + + def apply_check(self, patch_file: str) -> bool: + """Verifies if a patch can be applied cleanly. + + Args: + patch_file: The path to the patch file. + + Returns: + True if the patch can be applied cleanly, False otherwise. + """ + try: + self._run_git("apply", "--check", patch_file, capture_output=False) + return True + except subprocess.CalledProcessError: + return False diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 610f2369e7..fd19362bec 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -171,7 +171,11 @@ def run(self) -> int: ) pr_num = "" else: - pr_url = self.gh.create_pr(version, issue_num) + pr_url = self.gh.create_pr( + title=f"Prepare release v{version}", + body=f"Work towards #{issue_num}", + base="main", + ) pr_num = pr_url.split("/")[-1] print(f"Created Pull Request: {pr_url} (PR #{pr_num})") diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index c8a936eab5..27eb3226b1 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -2,6 +2,10 @@ import argparse import datetime +import hashlib +import os +import tempfile +from dataclasses import dataclass from typing import Any from tools.private.release import changelog_news @@ -11,6 +15,7 @@ RELEASE_TITLE_RE, add_backports_to_body, add_rc_task_to_body, + add_sync_changelog_task_to_body, parse_backports, parse_checklist_state, update_task_in_body, @@ -22,6 +27,20 @@ ) +@dataclass +class CherryPickAndUpdatePrsResult: + # List of PR references that failed to cherry-pick. + failed_prs: list[str] + # List of news files collected from the successful cherry-picks. + collected_news_files: list[str] + # List of PR numbers that were successfully cherry-picked. + successful_pr_nums: list[int] + # List of tuples mapping successful PR numbers to their version marker diffs. + collected_diffs: list[tuple[int, str]] + # The updated checklist body for the release tracking issue. + body: str + + class ProcessBackports: """Class to process pending backports.""" @@ -84,23 +103,35 @@ def _cherry_pick_and_update_prs( version, branch_name, next_rc_suffix, - ) -> tuple[list[str], str]: + ) -> CherryPickAndUpdatePrsResult: failed_prs = [] + collected_news_files = [] + successful_pr_nums = [] + collected_diffs = [] for sha in sorted_shas: item = sha_to_item[sha] print(f"Cherry-picking {item.pr_ref} / {sha}...") try: self.git.cherry_pick(sha) + # Collect news files before they are deleted by update_changelog + modified_files = self.git.get_modified_files("HEAD") + for f in modified_files: + if changelog_news.is_news_file(f): + collected_news_files.append(f) + + # Replace version markers FIRST to isolate diff + print(f"Replacing version markers for PR {item.pr_ref}...") + replace_version_next(version) + + # Get diff of unstaged changes (version marker replacement) + diff_content = self.git.diff() + # Perform news processing (merging news/ files into the changelog) print(f"Merging news fragments into changelog for PR {item.pr_ref}...") release_date = datetime.date.today().strftime("%Y-%m-%d") changelog_news.update_changelog(version, release_date) - # Replace version markers that might have been introduced by the backport - print(f"Replacing version markers for PR {item.pr_ref}...") - replace_version_next(version) - # Stage changelog changes, news/ deletions, and version placeholder updates self.git.add_modified_and_deleted() @@ -111,6 +142,16 @@ def _cherry_pick_and_update_prs( new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" self.git.commit(new_msg, amend=True) + try: + pr_num = self.gh.resolve_pr_number(item.pr_ref) + if diff_content: + collected_diffs.append((pr_num, diff_content)) + successful_pr_nums.append(pr_num) + except Exception as e: + print( + f"Warning: Failed to resolve PR number for {item.pr_ref}: {e}" + ) + if not dry_run: # Push amended commit self.git.push(remote, branch_name) @@ -177,7 +218,180 @@ def _cherry_pick_and_update_prs( f"ERROR: Failed to update tracking issue for" f" failed PR {item.pr_ref}: {e}" ) - return failed_prs, body + return CherryPickAndUpdatePrsResult( + failed_prs=failed_prs, + collected_news_files=collected_news_files, + successful_pr_nums=successful_pr_nums, + collected_diffs=collected_diffs, + body=body, + ) + + def _sync_changelog_to_main( + self, + version: str, + collected_news_files: list[str], + successful_pr_nums: list[int], + collected_diffs: list[tuple[int, str]], + release_branch: str, + ) -> None: + args = self.args + sorted_prs = sorted(successful_pr_nums) + prs_str = ",".join(str(n) for n in sorted_prs) + prs_hash = hashlib.sha256(prs_str.encode()).hexdigest()[:7] + + main_branch = "main" + backport_branch = f"prepare-{version}-backports-{prs_hash}" + + print(f"Syncing changelog to {main_branch} via branch {backport_branch}...") + + self.git.fetch(args.remote, refspec=main_branch) + self.git.checkout(main_branch, track_remote=args.remote) + main_start_sha = self.git.get_commit_sha("HEAD") + + failed_version_sync_prs = [] + try: + if args.dry_run: + print( + f"[DRY RUN] Would create and checkout branch {backport_branch} from {main_branch}" + ) + else: + if self.git.branch_exists(backport_branch): + self.git.checkout(backport_branch) + self.git.reset_hard(reset_to=main_branch) + else: + self.git.checkout(backport_branch, create_branch=True) + + print( + f"Updating CHANGELOG.md and removing news files on {backport_branch}..." + ) + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog( + version, + release_date, + news_files=collected_news_files, + delete_news=True, + ) + + # Apply version marker diffs + failed_version_sync_prs = self._apply_version_marker_diffs(collected_diffs) + + if args.dry_run: + print( + f"[DRY RUN] Would commit: 'chore(release): sync changelog for v{version} backports'" + ) + print(f"[DRY RUN] Would push {backport_branch} to {args.remote}") + print( + f"[DRY RUN] Would create PR to {main_branch} with label 'type: sync-changelog'" + ) + print( + f"[DRY RUN] Would update tracking issue #{args.issue} checklist tasks 'Sync Changelog #' to PENDING" + ) + print("[DRY RUN] Diff of changes:") + print(self.git.status()) + else: + self.git.add_modified_and_deleted() + self.git.commit( + f"chore(release): sync changelog for v{version} backports" + ) + self.git.push( + args.remote, backport_branch, set_upstream=True, force=True + ) + + pr_title = f"chore(release): sync changelog for v{version} backports" + pr_body_lines = [ + "Updates CHANGELOG.md and removes news files for backports:", + ] + for pr_num in sorted_prs: + pr_body_lines.append(f"- #{pr_num}") + + if failed_version_sync_prs: + pr_body_lines.append("") + pr_body_lines.append( + "Warning: These PRs failed to update their version markers:" + ) + for pr_num in sorted(failed_version_sync_prs): + pr_body_lines.append(f"- #{pr_num}") + + pr_body_lines.append("") + pr_body_lines.append(f"Work towards #{args.issue}") + pr_body_lines.append(f"Release-Tracking-Issue: #{args.issue}") + pr_body = "\n".join(pr_body_lines) + + print(f"Creating PR to {main_branch}...") + pr_url = self.gh.create_pr( + title=pr_title, + body=pr_body, + base=main_branch, + labels=["type: sync-changelog"], + ) + print(f"Created PR: {pr_url}") + + try: + pr_num = int(pr_url.split("/")[-1]) + print(f"Enabling auto-merge for PR #{pr_num}...") + self.gh.enable_auto_merge(pr_num) + + print( + f"Updating tracking issue #{args.issue} checklist with" + " Sync Changelog tasks..." + ) + issue_body = self.gh.get_issue_body(args.issue) + for pr in successful_pr_nums: + task_name = f"Sync Changelog #{pr}" + metadata = {"status": "pending", "pr": f"#{pr_num}"} + issue_body = update_task_in_body( + issue_body, + task_name, + checked=False, + metadata=metadata, + ) + self.gh.update_issue_body(args.issue, issue_body) + except Exception as e: + print( + f"Warning: Failed to update tracking issue or enable" + f" auto-merge: {e}" + ) + finally: + if args.dry_run: + self.git.reset_hard(reset_to=main_start_sha) + self.git.checkout(release_branch) + + def _apply_version_marker_diffs( + self, + collected_diffs: list[tuple[int, str]], + ) -> list[int]: + """Applies version marker diffs on main branch and returns failed PR numbers.""" + args = self.args + failed_version_sync_prs = [] + if not collected_diffs: + return failed_version_sync_prs + + with tempfile.TemporaryDirectory() as temp_dir: + print(f"Applying {len(collected_diffs)} version marker patches...") + for pr_num, diff_content in collected_diffs: + if args.dry_run: + print( + f"[DRY RUN] Would check and apply version marker patch for PR #{pr_num}" + ) + + patch_filepath = os.path.join(temp_dir, f"{pr_num}.patch") + with open(patch_filepath, "w", encoding="utf-8") as f: + f.write(diff_content) + + if self.git.apply_check(patch_filepath): + if args.dry_run: + print( + f"[DRY RUN] Version marker patch for PR #{pr_num} applies cleanly." + ) + else: + print(f"Applying version marker patch for PR #{pr_num}...") + self.git.apply(patch_filepath) + else: + print( + f"Warning: Version marker patch for PR #{pr_num} could not be applied cleanly to main. Skipping." + ) + failed_version_sync_prs.append(pr_num) + return failed_version_sync_prs def run(self) -> int: """Executes the process-backports subcommand.""" @@ -223,6 +437,14 @@ def _run_internal(self) -> int: print(f"Adding backports {items_to_add} to tracking issue #{args.issue}...") try: body = add_backports_to_body(body, items_to_add) + for item in items_to_add: + if ( + "metadata" in item + and item["metadata"].get("status") == "error-invalid-pr" + ): + continue + pr_num = int(item["ref"].lstrip("#")) + body = add_sync_changelog_task_to_body(body, pr_num) state = parse_checklist_state(body) rc_tags = state.get("rc_tags", {}) has_pending_rc = any( @@ -318,8 +540,11 @@ def _run_internal(self) -> int: self.git.checkout(branch_name, track_remote=args.remote) start_sha = self.git.get_commit_sha("HEAD") + collected_news_files = [] + successful_pr_nums = [] + collected_diffs = [] try: - new_failed_prs, body = self._cherry_pick_and_update_prs( + result = self._cherry_pick_and_update_prs( sorted_shas, sha_to_item, body, @@ -330,11 +555,24 @@ def _run_internal(self) -> int: branch_name, next_rc_suffix, ) - failed_prs.extend(new_failed_prs) + failed_prs.extend(result.failed_prs) + collected_news_files.extend(result.collected_news_files) + successful_pr_nums.extend(result.successful_pr_nums) + collected_diffs.extend(result.collected_diffs) + body = result.body finally: if args.dry_run: print(f"[DRY RUN] Resetting branch {branch_name} to {start_sha}") - self.git.reset_hard(start_sha) + self.git.reset_hard(reset_to=start_sha) + + if successful_pr_nums: + self._sync_changelog_to_main( + version, + collected_news_files, + successful_pr_nums, + collected_diffs, + branch_name, + ) if failed_prs: print("ERROR: One or more cherry-picks/resolutions failed:") diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 8ad9c2e33e..f008c093b1 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -6,6 +6,7 @@ from tools.private.release.add_backports import AddBackports from tools.private.release.complete_prepare import CompletePrepare +from tools.private.release.complete_sync_changelog import CompleteSyncChangelog from tools.private.release.create_rc import CreateRc from tools.private.release.create_release_branch import CreateReleaseBranch from tools.private.release.create_release_issue import CreateReleaseIssue @@ -20,6 +21,7 @@ CreateReleaseIssue, Prepare, CompletePrepare, + CompleteSyncChangelog, CreateReleaseBranch, AddBackports, ProcessBackports, diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 359249d11c..220348bd47 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -161,6 +161,7 @@ def parse_checklist_state(body): "create_branch": ReleaseTask("Create Release branch", False), "tag_final": ReleaseTask("Tag Final", False), "rc_tags": {}, # Dynamically mapped: int -> ReleaseTask + "sync_changelogs": {}, # Dynamically mapped: int -> ReleaseTask } lines = body.splitlines() @@ -214,6 +215,19 @@ def parse_checklist_state(body): commit=meta.get("commit"), metadata=meta, ) + else: + # Match Sync Changelog # + sync_match = re.match(r"Sync Changelog #(\d+)", name, re.IGNORECASE) + if sync_match: + pr_num = int(sync_match.group(1)) + state["sync_changelogs"][pr_num] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + pr=meta.get("pr"), + commit=meta.get("commit"), + metadata=meta, + ) return state @@ -325,3 +339,50 @@ def add_rc_task_to_body(body: str, rc_num: int) -> str: lines.insert(last_rc_idx + 1, new_task_line) return "\n".join(lines) + + +def add_sync_changelog_task_to_body(body: str, pr_num: int) -> str: + """Adds a new 'Sync Changelog #' task to the checklist in the issue body.""" + body = body.replace("\r\n", "\n") + + # Check if already exists + task_name = f"Sync Changelog #{pr_num}" + lines = body.splitlines() + for line in lines: + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == task_name.lower(): + print(f"Task '{task_name}' already exists. Skipping.") + return body + + # Find the index of the last "Sync Changelog #" line + last_sync_idx = -1 + for i, line in enumerate(lines): + parsed = parse_metadata_line(line) + if parsed and re.match(r"Sync Changelog #\d+", parsed["name"], re.IGNORECASE): + last_sync_idx = i + + if last_sync_idx == -1: + # If no Sync Changelog task found, insert before "Tag Final" + for i, line in enumerate(lines): + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == "tag final": + last_sync_idx = i - 1 + break + + if last_sync_idx == -1: + # If "Tag Final" not found, insert after "Create Release branch" + for i, line in enumerate(lines): + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == "create release branch": + last_sync_idx = i + break + + if last_sync_idx == -1: + raise ValueError( + "Could not find a place to insert the new Sync Changelog task." + ) + + new_task_line = f"- [ ] Sync Changelog #{pr_num}" + lines.insert(last_sync_idx + 1, new_task_line) + + return "\n".join(lines) From b5d754e82b5b8003a9374bf7df655d593e7b9ee3 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 5 Jul 2026 21:28:07 -0700 Subject: [PATCH 072/179] chore: remove stale process-backports-before-rc news file (#3904) This news file was left over from a previous release process and is no longer needed. Removed the file `news/process-backports-before-rc.changed.md`. --- news/process-backports-before-rc.changed.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 news/process-backports-before-rc.changed.md diff --git a/news/process-backports-before-rc.changed.md b/news/process-backports-before-rc.changed.md deleted file mode 100644 index 5591b700ce..0000000000 --- a/news/process-backports-before-rc.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -Changed `/create-rc` command to automatically process pending backports before -creating the RC, and react with a thumbs-down emoji on failure if triggered by -a comment. From 89eba163c82ad944880c273d9eb882ceea614382 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 6 Jul 2026 09:46:49 -0700 Subject: [PATCH 073/179] chore(release): fix invalid workflow file syntax (#3908) Quote the 'if' condition in release_create_release_branch.yaml to fix YAML syntax error. --- .github/workflows/release_create_release_branch.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release_create_release_branch.yaml b/.github/workflows/release_create_release_branch.yaml index 4c021814d0..4cbabc7d12 100644 --- a/.github/workflows/release_create_release_branch.yaml +++ b/.github/workflows/release_create_release_branch.yaml @@ -11,7 +11,7 @@ permissions: jobs: cut_branch: # Run only if the issue has the type: release label - if: contains(github.event.issue.labels.*.name, 'type: release') + if: "contains(github.event.issue.labels.*.name, 'type: release')" runs-on: ubuntu-latest steps: - name: Checkout repository From b443ec787b5f7c24e10b5be487756a0a160ddb45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:43:41 +0900 Subject: [PATCH 074/179] build(deps): bump astral-sh/ruff-action from 4.0.0 to 4.1.0 (#3910) Bumps [astral-sh/ruff-action](https://github.com/astral-sh/ruff-action) from 4.0.0 to 4.1.0.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/ruff-action&package-manager=github_actions&previous-version=4.0.0&new-version=4.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index daff8938e7..e7b2a4d956 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,12 +32,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: astral-sh/ruff-action@v4.0.0 + - uses: astral-sh/ruff-action@v4.1.0 with: # Keep in sync with .pre-commit-config.yaml version: 0.15.14 args: check --extend-exclude testdata - - uses: astral-sh/ruff-action@v4.0.0 + - uses: astral-sh/ruff-action@v4.1.0 with: version: 0.15.14 args: format --check --exclude testdata From 00922ad2dcc7cf7aa0c2fa0979e67cd89c07df51 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:17:18 +0900 Subject: [PATCH 075/179] fix(pip): parse the index_url from uv.lock file (#3906) This is so that the purl can be generated correctly and we pass the right index_url parameter to the whl_library file. Work towards #1975 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Richard Levasseur --- news/3906.fixed.md | 3 ++ python/private/pypi/parse_requirements.bzl | 9 ++++- .../parse_requirements_tests.bzl | 36 +++++++++---------- 3 files changed, 29 insertions(+), 19 deletions(-) create mode 100644 news/3906.fixed.md diff --git a/news/3906.fixed.md b/news/3906.fixed.md new file mode 100644 index 0000000000..64a16fd9c8 --- /dev/null +++ b/news/3906.fixed.md @@ -0,0 +1,3 @@ +(pypi) correctly parse the `index_url` for each wheel so that the source registry is forwarded to +the {obj}`whl_library`. This is so that the `purl` for `package_metadata` can be correctly +constructed. diff --git a/python/private/pypi/parse_requirements.bzl b/python/private/pypi/parse_requirements.bzl index fd0399fc86..be3ad9a5e5 100644 --- a/python/private/pypi/parse_requirements.bzl +++ b/python/private/pypi/parse_requirements.bzl @@ -176,6 +176,13 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p "versions": {}, }) entry["versions"][version] = None + source = pkg.get("source") or {} + registry = (source.get("registry") or "").rstrip("/") + if registry.startswith("http://") or registry.startswith("https://"): + index_url = "{}/{}".format(registry, norm_name.replace("_", "-")) + else: + index_url = "" + entry["index_url"] = index_url pkg_extras = sorted(extras_map.get(name, [])) extra_str = "[{}]".format(",".join(pkg_extras)) if pkg_extras else "" @@ -287,7 +294,7 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p name = norm_name, is_exposed = True, is_multiple_versions = len(versions) > 1, - index_url = "", + index_url = info["index_url"], srcs = info["resolved_srcs"], ) ret.append(item) diff --git a/tests/pypi/parse_requirements/parse_requirements_tests.bzl b/tests/pypi/parse_requirements/parse_requirements_tests.bzl index 8dd35c2260..576a31ae5d 100644 --- a/tests/pypi/parse_requirements/parse_requirements_tests.bzl +++ b/tests/pypi/parse_requirements/parse_requirements_tests.bzl @@ -1052,7 +1052,7 @@ def _test_uv_lock_consistent(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1080,7 +1080,7 @@ def _test_uv_lock_primary_source(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1108,7 +1108,7 @@ def _test_uv_lock_primary_source_multiple_versions(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = True, srcs = [ @@ -1146,7 +1146,7 @@ def _test_uv_lock_primary_source_with_extras(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1174,7 +1174,7 @@ def _test_uv_lock_primary_source_includes_virtual(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1212,7 +1212,7 @@ def _test_uv_lock_cross_consistent(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1268,7 +1268,7 @@ def _test_uv_lock_rules_python_pkg_not_skipped(env): env.expect.that_collection(got).contains_exactly([ struct( name = "rules_python", - index_url = "", + index_url = "https://pypi.org/simple/rules-python", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1301,7 +1301,7 @@ def _test_uv_lock_no_consistency_check(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1329,7 +1329,7 @@ def _test_uv_lock_multiple_packages(env): env.expect.that_collection(got).contains_exactly([ struct( name = "bar", - index_url = "", + index_url = "https://pypi.org/simple/bar", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1347,7 +1347,7 @@ def _test_uv_lock_multiple_packages(env): ), struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1376,7 +1376,7 @@ def _test_uv_lock_with_extra_pip_args(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1408,7 +1408,7 @@ def _test_uv_lock_multi_os_with_requirements(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1436,7 +1436,7 @@ def _test_uv_lock_extras_optional_deps(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1464,7 +1464,7 @@ def _test_uv_lock_extras_dep_edge(env): env.expect.that_collection(got).contains_exactly([ struct( name = "bar", - index_url = "", + index_url = "https://pypi.org/simple/bar", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1482,7 +1482,7 @@ def _test_uv_lock_extras_dep_edge(env): ), struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1517,7 +1517,7 @@ def _test_uv_lock_wheel_dedup_single_version(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ @@ -1563,7 +1563,7 @@ def _test_uv_lock_wheel_dedup_resolution_markers(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = True, srcs = [ @@ -1601,7 +1601,7 @@ def _test_uv_lock_requires_dist_extras(env): env.expect.that_collection(got).contains_exactly([ struct( name = "foo", - index_url = "", + index_url = "https://pypi.org/simple/foo", is_exposed = True, is_multiple_versions = False, srcs = [ From fc18dc15138296efc0a3ac5ccc5b1586717382b7 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 8 Jul 2026 20:23:24 -0700 Subject: [PATCH 076/179] chore: automated code review using antigravity (#3905) Free gemini code assist is being disabled in a few weeks, and I've found the ai reviews useful enough to want to keep them, so replace it with a custom agent based upon the antigravity sdk that does code reviews. I've added a Gemini API key to the project for it to use. It's free-tier level quota, so is relatively limited, hence reviews are limited to project maintainer PRsor when a maintainer comments `/review`. --- .github/workflows/automated_pr_review.yaml | 61 +++++++++++++++++++ tools/private/reviewbot/antigravity_review.py | 48 +++++++++++++++ tools/private/reviewbot/prompt.txt | 3 + .../reviewbot/skills/review-pr/SKILL.md | 52 ++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 .github/workflows/automated_pr_review.yaml create mode 100644 tools/private/reviewbot/antigravity_review.py create mode 100644 tools/private/reviewbot/prompt.txt create mode 100644 tools/private/reviewbot/skills/review-pr/SKILL.md diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml new file mode 100644 index 0000000000..23d4369b2a --- /dev/null +++ b/.github/workflows/automated_pr_review.yaml @@ -0,0 +1,61 @@ +name: Automated Code Review + +# TODO: Eventually, use pull_request_target instead of pull_request. +# pull_request_target runs in the base branch context and has access +# to secrets (like GEMINI_API_KEY) even for fork PRs. +# Using pull_request for now during setup/testing. +on: + pull_request: + types: [opened] + pull_request_review_comment: + types: [created] + +permissions: + contents: read + pull-requests: read + +jobs: + review: + runs-on: ubuntu-latest + # Trigger only if: + # 1. It is a pull_request event, it is NOT a draft, and the author is a maintainer + # (OWNER, MEMBER, or COLLABORATOR). + # 2. OR it is a pull_request_review_comment event, the comment body has a line starting with "/review", + # and the commenter is a maintainer. + if: > + (github.event_name == 'pull_request' && !github.event.pull_request.draft && + contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)) || + (github.event_name == 'pull_request_review_comment' && + (startsWith(github.event.comment.body, '/review') || contains(github.event.comment.body, '\n/review')) && + contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) + steps: + - name: Checkout PR Branch + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Checkout Reviewbot (Base Branch) + uses: actions/checkout@v7 + with: + sparse-checkout: | + tools/private/reviewbot + path: reviewbot + + - name: Install uv + uses: astral-sh/setup-uv@v8.3.0 + + - name: Set up Python + run: uv python install 3.13 + + - name: Install Dependencies + run: | + uv pip install google-antigravity requests + + - name: Run Antigravity Review + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run reviewbot/tools/private/reviewbot/antigravity_review.py \ + --prompt reviewbot/tools/private/reviewbot/prompt.txt diff --git a/tools/private/reviewbot/antigravity_review.py b/tools/private/reviewbot/antigravity_review.py new file mode 100644 index 0000000000..1bb23283be --- /dev/null +++ b/tools/private/reviewbot/antigravity_review.py @@ -0,0 +1,48 @@ +import argparse +import asyncio +from pathlib import Path + +from google.antigravity import Agent, CapabilitiesConfig, LocalAgentConfig + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--prompt", required=True, help="Path to prompt file") + return parser.parse_args() + + +async def main(): + args = parse_args() + + # Read prompt file + prompt = Path(args.prompt).read_text() + + # Initialize the Antigravity Agent in read-only mode for security. + # Register the review-pr skill from the local reviewbot folder. + config = LocalAgentConfig( + skills_paths=["tools/private/reviewbot/skills/review-pr"], + capabilities=CapabilitiesConfig( + allow_filesystem_read=True, + allow_filesystem_write=False, + allow_network=False, + ), + ) + + # General coordinator instructions for the reviewer agent. + system_instructions = ( + "You are a code review assistant. Use your available skills to perform " + "reviews on pull requests." + ) + + async with Agent(config, system_instructions=system_instructions) as agent: + response = await agent.chat(prompt) + report = await response.text() + + print("--- REVIEW REPORT GENERATED ---") + print(report) + + # TODO: Use GITHUB_TOKEN to post the report back to the PR comments. + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/private/reviewbot/prompt.txt b/tools/private/reviewbot/prompt.txt new file mode 100644 index 0000000000..e406164af1 --- /dev/null +++ b/tools/private/reviewbot/prompt.txt @@ -0,0 +1,3 @@ +Use the review-pr skill to review the files modified in this pull request. +Summarize your findings and suggest specific, actionable improvements. +Group your findings into clear, descriptive nits or suggestions. diff --git a/tools/private/reviewbot/skills/review-pr/SKILL.md b/tools/private/reviewbot/skills/review-pr/SKILL.md new file mode 100644 index 0000000000..18c998f9ed --- /dev/null +++ b/tools/private/reviewbot/skills/review-pr/SKILL.md @@ -0,0 +1,52 @@ +--- +name: review-pr +description: Perform a read-only code review on a pull request. +--- + +# review-pr + +You are an expert Starlark, Python, and Bazel code reviewer. Analyze the changed files for +correctness, edge cases, and performance. Focus strictly on logical +correctness, concurrency safety, system architecture, performance bottlenecks, +and resource management. Do not comment on style nits or formatting issues +that an automated formatter can handle. Be constructive and concise. + +For every issue or improvement you identify, you MUST output the finding in the +GitHub Actions workflow command warning format. Specify the exact file path +and line numbers that the comment applies to. + +Format each finding exactly as a single line to stdout matching this template: +`::warning file={file_path},line={line_number},endLine={end_line},title={category}::{comment_body}` + +Where: +* `file_path` is the relative file path from the repository root. +* `line_number` is the starting line number in the file where the comment applies. +* `end_line` is the ending line number in the file where the comment applies (equal to line_number if the issue is on a single line). +* `category` is a short tag for the type of issue (e.g., "Error Handling", "Correctness", "Performance"). +* `comment_body` is your constructive and concise feedback. + +Do not write any markdown commentary outside of these GHA command formatted lines. + +Follow these checklists during your review: + +### General Quality & Architecture Checklist +* **PR Description Audit**: Verify the description contains the Why + (business/technical reason), a brief high level overview of changes, + Issue/Bug Link, and explicit Testing Evidence. +* **Separation of Concerns**: Suggest extracting large hardcoded data structures + (e.g., massive templates, complex regexes) to resource files. +* **Logic Correctness**: Verify calculations, negative values, division-by-zero, + and null safety before member access. +* **Error Handling**: Flag silent failures (e.g., empty except blocks) and + unconditional defaults that override configs. +* **Deterministic Operations**: Sort collections/keys to guarantee + reproducible/deterministic execution. + +### Skeptical Critic (Adversarial Specialist Review) +* **Dynamic Filtering**: Filter the PR diff and run only the specialist checks + that have relevant files changed (e.g. skip the C++ checks if only Python + files are modified). +* **Specialist Review Pillars**: Run parallel audits focusing on: + 1. Crash Regression: Null safety and resource lifecycle. + 2. Performance & Latency: Thread bottlenecks, locks, and network calls. + 3. Test Integrity: Coverage validity, change detectors defense. From 84d313bdc5bf09929a00ec242cce4fb0bc890cd8 Mon Sep 17 00:00:00 2001 From: Artemy Granat Date: Thu, 9 Jul 2026 09:55:44 +0300 Subject: [PATCH 077/179] fix: add explicit `data` attribute to `compile_pip_requirements` and forward it to the generated `py_binary` (#3865) As stated in #3858, the `compile_pip_requirements` macro accepts a `data` attribute, but this attribute is not forwarded to the internal `py_binary` target generated for the `.update` target. As a result, using `$(location :pip_cert)` inside `extra_args` fails during analysis because the generated `py_binary` does not declare `:pip_cert` as a prerequisite. Before: ``` ERROR: /.../BUILD.bazel:8:25: in args attribute of py_binary rule //:requirements.update: label '//:pip_cert' in $(location) expression is not a declared prerequisite of this rule. Since this rule was created by the macro 'pip_compile', the error might have been caused by the macro implementation ERROR: /.../BUILD.bazel:8:25: Analysis of target '//:requirements.update' (config: 7f8856b) failed ERROR: Analysis of target '//:requirements.update' failed; build aborted ``` After: ``` INFO: Analyzed target //:requirements.update (87 packages loaded, 4078 targets configured). INFO: Found 1 target... Target //:requirements.update up-to-date: bazel-bin/requirements.update INFO: Elapsed time: 6.240s, Critical Path: 0.11s INFO: 1 process: 11 action cache hit, 1 internal. INFO: Build completed successfully, 1 total action INFO: Running command line: bazel-bin/requirements.update '--src=rules_python_pip_parse_example/requirements.in' rules_python_pip_parse_example/requirements_lock.txt //:requirements '--resolver=backtracking' --allow-unsafe --generate-hashes '--cert=./pip.cert' ``` Closes #3858 --------- Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Co-authored-by: Richard Levasseur --- news/3858.fixed.md | 3 +++ python/private/pypi/pip_compile.bzl | 17 +++++++++++++++-- .../compile_pip_requirements/BUILD.bazel | 13 +++++++++++++ .../compile_pip_requirements/WORKSPACE | 5 +++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 news/3858.fixed.md diff --git a/news/3858.fixed.md b/news/3858.fixed.md new file mode 100644 index 0000000000..dd991e4654 --- /dev/null +++ b/news/3858.fixed.md @@ -0,0 +1,3 @@ +(compile_pip_requirements) Add the explicit `data` attribute and forward it +directly to the generated `py_binary`, so files passed via `data` can be +referenced from `extra_args` using `$(location ...)`. diff --git a/python/private/pypi/pip_compile.bzl b/python/private/pypi/pip_compile.bzl index 3ef2cdb39c..58f7ba3a59 100644 --- a/python/private/pypi/pip_compile.bzl +++ b/python/private/pypi/pip_compile.bzl @@ -22,6 +22,15 @@ make it possible to have multiple tools inside the `pypi` directory load("//python:py_binary.bzl", _py_binary = "py_binary") load("//python:py_test.bzl", _py_test = "py_test") +# The `data` attribute does not allow duplicate labels, but user-provided `data` +# can overlap with labels added from attributes like `src`. +def _dedupe_data(data): + res = [] + for d in data: + if d not in res: + res.append(d) + return res + def pip_compile( name, srcs = None, @@ -39,6 +48,7 @@ def pip_compile( visibility = ["//visibility:private"], tags = None, constraints = [], + data = [], **kwargs): """Generates targets for managing pip dependencies with pip-compile (piptools). @@ -81,6 +91,7 @@ def pip_compile( tags: tagging attribute common to all build rules, passed to both the _test and .update rules. visibility: passed to both the _test and .update rules. constraints: a list of files containing constraints to pass to pip-compile with `--constraint`. + data: A list of labels to include as part of the `data` attribute in the generated `py_binary`. **kwargs: other bazel attributes passed to the "_test" rule. """ if len([x for x in [srcs, src, requirements_in] if x != None]) > 1: @@ -95,16 +106,18 @@ def pip_compile( requirements_txt = name + ".txt" if requirements_txt == None else requirements_txt + data = data or [] + # "Default" target produced by this macro # Allow a compile_pip_requirements rule to include another one in the data # for a requirements file that does `-r ../other/requirements.txt` native.filegroup( name = name, - srcs = kwargs.pop("data", []) + [requirements_txt], + srcs = data + [requirements_txt], visibility = visibility, ) - data = [name, requirements_txt] + srcs + [f for f in (requirements_linux, requirements_darwin, requirements_windows) if f != None] + constraints + data = _dedupe_data(data + [name, requirements_txt] + srcs + [f for f in (requirements_linux, requirements_darwin, requirements_windows) if f != None] + constraints) # Use the Label constructor so this is expanded in the context of the file # where it appears, which is to say, in @rules_python diff --git a/tests/integration/compile_pip_requirements/BUILD.bazel b/tests/integration/compile_pip_requirements/BUILD.bazel index 6df46b8372..b1f398c4a8 100644 --- a/tests/integration/compile_pip_requirements/BUILD.bazel +++ b/tests/integration/compile_pip_requirements/BUILD.bazel @@ -29,6 +29,19 @@ compile_pip_requirements( requirements_txt = "requirements_lock.txt", ) +genquery( + name = "requirements_update_data", + expression = """ +some(labels(data, @compile_pip_requirements//:requirements.update) intersect @compile_pip_requirements//:requirements.in) union +some(labels(data, @compile_pip_requirements//:requirements.update) intersect @compile_pip_requirements//:requirements_extra.in) +""", + scope = [ + "@compile_pip_requirements//:requirements.update", + "@compile_pip_requirements//:requirements.in", + "@compile_pip_requirements//:requirements_extra.in", + ], +) + compile_pip_requirements( name = "requirements_nohashes", src = "requirements.txt", diff --git a/tests/integration/compile_pip_requirements/WORKSPACE b/tests/integration/compile_pip_requirements/WORKSPACE index 0eeab2067c..397d828261 100644 --- a/tests/integration/compile_pip_requirements/WORKSPACE +++ b/tests/integration/compile_pip_requirements/WORKSPACE @@ -3,6 +3,11 @@ local_repository( path = "../../..", ) +local_repository( + name = "compile_pip_requirements", + path = ".", +) + load("@rules_python//python:repositories.bzl", "py_repositories", "python_register_toolchains") py_repositories() From 966329b0644d6377eefc59b2b86d80d9c12a6f83 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 10 Jul 2026 19:40:03 -0700 Subject: [PATCH 078/179] chore(pr-review): trigger automated review workflow on issue comments (#3918) Currently, the automated PR review workflow requires a diff review comment (`pull_request_review_comment`) to trigger on-demand reviews via `/review`. This prevents maintainers from triggering reviews from regular conversation comments on pull requests. To fix, change the event trigger from `pull_request_review_comment` to `issue_comment`, ensure the event is on a pull request by checking `github.event.issue.pull_request != null`, and check out `refs/pull/$NUMBER/head` so the PR branch is retrieved during comment events. * Also checks `\r\n/review` when parsing comment body for multiline `/review` commands. --- .github/workflows/automated_pr_review.yaml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index 23d4369b2a..a512ae2427 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -7,7 +7,7 @@ name: Automated Code Review on: pull_request: types: [opened] - pull_request_review_comment: + issue_comment: types: [created] permissions: @@ -20,19 +20,24 @@ jobs: # Trigger only if: # 1. It is a pull_request event, it is NOT a draft, and the author is a maintainer # (OWNER, MEMBER, or COLLABORATOR). - # 2. OR it is a pull_request_review_comment event, the comment body has a line starting with "/review", + # 2. OR it is a regular conversation comment on a pull request, the comment body has a line starting with "/review", # and the commenter is a maintainer. if: > (github.event_name == 'pull_request' && !github.event.pull_request.draft && contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)) || - (github.event_name == 'pull_request_review_comment' && - (startsWith(github.event.comment.body, '/review') || contains(github.event.comment.body, '\n/review')) && + (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && + (startsWith(github.event.comment.body, '/review') || + contains(github.event.comment.body, '\n/review') || + contains(github.event.comment.body, '\r\n/review')) && contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) steps: - name: Checkout PR Branch uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.sha }} + # Note: In GHA, during an issue_comment event on a PR, github.event.pull_request is null + # and the PR number is placed inside github.event.issue.number (because every PR is an issue). + # Therefore, github.event.issue.number is the PR number when checking out refs/pull//head. + ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head persist-credentials: false - name: Checkout Reviewbot (Base Branch) @@ -45,8 +50,10 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v8.3.0 - - name: Set up Python - run: uv python install 3.13 + - name: Set up Python and Virtual Environment + run: | + uv python install 3.13 + uv venv --python 3.13 - name: Install Dependencies run: | From cf152cd9dfef51915146e02e543d136100ae1030 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 11 Jul 2026 11:59:30 -0700 Subject: [PATCH 079/179] chore(reviewbot): fix antigravity_review execution and configuration (#3921) Currently, running `antigravity_review.py` with `uv run` fails because it lacks PEP 723 dependency metadata and passes `system_instructions` to `Agent` instead of `LocalAgentConfig`. Also, `skills_paths` points to the skill directory rather than the `SKILL.md` file. To fix, add inline PEP 723 script metadata declaring dependencies, pass `system_instructions` to `LocalAgentConfig`, and target `SKILL.md` directly in `skills_paths`. --- .github/workflows/automated_pr_review.yaml | 9 ------- tools/private/reviewbot/antigravity_review.py | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index a512ae2427..af2da7e0ca 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -50,15 +50,6 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v8.3.0 - - name: Set up Python and Virtual Environment - run: | - uv python install 3.13 - uv venv --python 3.13 - - - name: Install Dependencies - run: | - uv pip install google-antigravity requests - - name: Run Antigravity Review env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} diff --git a/tools/private/reviewbot/antigravity_review.py b/tools/private/reviewbot/antigravity_review.py index 1bb23283be..e415e42a91 100644 --- a/tools/private/reviewbot/antigravity_review.py +++ b/tools/private/reviewbot/antigravity_review.py @@ -1,3 +1,10 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "google-antigravity", +# "requests", +# ] +# /// import argparse import asyncio from pathlib import Path @@ -17,10 +24,17 @@ async def main(): # Read prompt file prompt = Path(args.prompt).read_text() + # General coordinator instructions for the reviewer agent. + system_instructions = ( + "You are a code review assistant. Use your available skills to perform " + "reviews on pull requests." + ) + # Initialize the Antigravity Agent in read-only mode for security. # Register the review-pr skill from the local reviewbot folder. config = LocalAgentConfig( - skills_paths=["tools/private/reviewbot/skills/review-pr"], + system_instructions=system_instructions, + skills_paths=[str(Path(__file__).parent / "skills" / "review-pr" / "SKILL.md")], capabilities=CapabilitiesConfig( allow_filesystem_read=True, allow_filesystem_write=False, @@ -28,13 +42,7 @@ async def main(): ), ) - # General coordinator instructions for the reviewer agent. - system_instructions = ( - "You are a code review assistant. Use your available skills to perform " - "reviews on pull requests." - ) - - async with Agent(config, system_instructions=system_instructions) as agent: + async with Agent(config) as agent: response = await agent.chat(prompt) report = await response.text() From a31feb9a32daf8acb44c8479b4831eb4dea52288 Mon Sep 17 00:00:00 2001 From: Kayce Basques Date: Sat, 11 Jul 2026 12:13:06 -0700 Subject: [PATCH 080/179] fix(sphinxdocs): invalidate cache on branch change (#3917) We had to disable the `sphinxdocs` persistent worker in Pigweed (https://pwbug.dev/493749800) because the cache invalidation logic seemed incomplete with regards to Git branches. E.g. in branch `feat` you create a new doc `foo.rst`. You switch back to branch `main` and try to build Sphinx, but Sphinx warns about needing to ignore unreadable document `foo.rst`. Because Pigweed builds Sphinx with `--fail-on-warning` this would break our docs build. This PR adds the bug fix and also starts a suite of tests that will be focused on persistent worker correctness. --------- Co-authored-by: Richard Levasseur --- sphinxdocs/.bazelignore | 4 + sphinxdocs/.bazelrc | 8 ++ sphinxdocs/.bazelrc.deleted_packages | 1 + sphinxdocs/BUILD.bazel | 16 +++ sphinxdocs/MODULE.bazel | 18 ++++ sphinxdocs/integration_tests/BUILD.bazel | 8 ++ sphinxdocs/integration_tests/bazel_from_env | 6 ++ .../integration_tests/integration_test.bzl | 68 +++++++++++++ .../persistent_worker/BUILD.bazel | 8 ++ .../persistent_worker_test.py | 54 ++++++++++ .../persistent_worker/workspace/.bazelrc | 8 ++ .../persistent_worker/workspace/BUILD.bazel | 17 ++++ .../persistent_worker/workspace/MODULE.bazel | 23 +++++ .../persistent_worker/workspace/WORKSPACE | 1 + .../persistent_worker/workspace/conf.py | 1 + .../workspace/downloader_config.cfg | 21 ++++ .../persistent_worker/workspace/index.md | 7 ++ sphinxdocs/integration_tests/runner.py | 99 +++++++++++++++++++ sphinxdocs/sphinxdocs/BUILD.bazel | 8 ++ sphinxdocs/sphinxdocs/private/BUILD.bazel | 6 ++ sphinxdocs/sphinxdocs/private/sphinx_build.py | 63 ++++++++++-- 21 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 sphinxdocs/.bazelignore create mode 100644 sphinxdocs/BUILD.bazel create mode 100644 sphinxdocs/integration_tests/BUILD.bazel create mode 100755 sphinxdocs/integration_tests/bazel_from_env create mode 100644 sphinxdocs/integration_tests/integration_test.bzl create mode 100644 sphinxdocs/integration_tests/persistent_worker/BUILD.bazel create mode 100644 sphinxdocs/integration_tests/persistent_worker/persistent_worker_test.py create mode 100644 sphinxdocs/integration_tests/persistent_worker/workspace/.bazelrc create mode 100644 sphinxdocs/integration_tests/persistent_worker/workspace/BUILD.bazel create mode 100644 sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel create mode 100644 sphinxdocs/integration_tests/persistent_worker/workspace/WORKSPACE create mode 100644 sphinxdocs/integration_tests/persistent_worker/workspace/conf.py create mode 100644 sphinxdocs/integration_tests/persistent_worker/workspace/downloader_config.cfg create mode 100644 sphinxdocs/integration_tests/persistent_worker/workspace/index.md create mode 100644 sphinxdocs/integration_tests/runner.py diff --git a/sphinxdocs/.bazelignore b/sphinxdocs/.bazelignore new file mode 100644 index 0000000000..80f2d3514f --- /dev/null +++ b/sphinxdocs/.bazelignore @@ -0,0 +1,4 @@ +bazel-bin +bazel-out +bazel-testlogs +bazel-sphinxdocs diff --git a/sphinxdocs/.bazelrc b/sphinxdocs/.bazelrc index 65c996c678..ce4d782113 100644 --- a/sphinxdocs/.bazelrc +++ b/sphinxdocs/.bazelrc @@ -21,5 +21,13 @@ common --experimental_repository_downloader_retries=10 common --incompatible_python_disallow_native_rules common --incompatible_no_implicit_file_export +# Pass host PATH to build and test actions so bazel_from_env (used by rules_bazel_integration_test +# for local 'self' testing) can locate the user's bazel binary without disabling strict action env. +common --action_env=PATH +common --test_env=PATH build --lockfile_mode=update + +common:fast-tests --build_tests_only=true +common:fast-tests --build_tag_filters=-large,-enormous,-integration-test +common:fast-tests --test_tag_filters=-large,-enormous,-integration-test diff --git a/sphinxdocs/.bazelrc.deleted_packages b/sphinxdocs/.bazelrc.deleted_packages index 442c80e960..9a90ef21be 100644 --- a/sphinxdocs/.bazelrc.deleted_packages +++ b/sphinxdocs/.bazelrc.deleted_packages @@ -1 +1,2 @@ common --deleted_packages=integration_tests/bcr +common --deleted_packages=integration_tests/persistent_worker/workspace diff --git a/sphinxdocs/BUILD.bazel b/sphinxdocs/BUILD.bazel new file mode 100644 index 0000000000..8ff9bc956f --- /dev/null +++ b/sphinxdocs/BUILD.bazel @@ -0,0 +1,16 @@ +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "distribution", + srcs = glob( + ["**/*"], + exclude = [ + "bazel-*/**", + "integration_tests/**", + "tests/**", + ], + ) + [ + "//sphinxdocs:distribution", + ], + visibility = ["//visibility:public"], +) diff --git a/sphinxdocs/MODULE.bazel b/sphinxdocs/MODULE.bazel index 30fb2196dc..3d4caf2bb2 100644 --- a/sphinxdocs/MODULE.bazel +++ b/sphinxdocs/MODULE.bazel @@ -21,3 +21,21 @@ dev_pip.parse( requirements_lock = "@rules_python//docs:requirements.txt", ) use_repo(dev_pip, "dev_pip") + +bazel_dep(name = "rules_bazel_integration_test", version = "0.37.1", dev_dependency = True) + +bazel_binaries = use_extension( + "@rules_bazel_integration_test//:extensions.bzl", + "bazel_binaries", + dev_dependency = True, +) +bazel_binaries.local( + name = "self", + path = "integration_tests/bazel_from_env", +) +use_repo( + bazel_binaries, + "bazel_binaries", + "bazel_binaries_bazelisk", + "build_bazel_bazel_self", +) diff --git a/sphinxdocs/integration_tests/BUILD.bazel b/sphinxdocs/integration_tests/BUILD.bazel new file mode 100644 index 0000000000..45e894fc2e --- /dev/null +++ b/sphinxdocs/integration_tests/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_python//python:py_library.bzl", "py_library") + +package(default_visibility = ["//visibility:public"]) + +py_library( + name = "runner_lib", + srcs = ["runner.py"], +) diff --git a/sphinxdocs/integration_tests/bazel_from_env b/sphinxdocs/integration_tests/bazel_from_env new file mode 100755 index 0000000000..a372736f32 --- /dev/null +++ b/sphinxdocs/integration_tests/bazel_from_env @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# +# A simple wrapper so rules_bazel_integration_test can use the +# bazel version inherited from the environment. + +bazel "$@" diff --git a/sphinxdocs/integration_tests/integration_test.bzl b/sphinxdocs/integration_tests/integration_test.bzl new file mode 100644 index 0000000000..b6786b9c6b --- /dev/null +++ b/sphinxdocs/integration_tests/integration_test.bzl @@ -0,0 +1,68 @@ +"""Helpers for running bazel-in-bazel integration tests for sphinxdocs.""" + +load( + "@rules_bazel_integration_test//bazel_integration_test:defs.bzl", + "bazel_integration_test", + "integration_test_utils", +) +load("@rules_python//python:py_test.bzl", "py_test") + +def _test_runner(*, name, bazel_version, py_main, py_deps): + test_runner = "{}_bazel_{}_py_runner".format(name, bazel_version) + py_test( + name = test_runner, + srcs = [py_main], + main = py_main, + deps = ["//integration_tests:runner_lib"] + py_deps, + tags = ["manual"], + ) + return test_runner + +def sphinxdocs_integration_test( + name, + workspace_path = "workspace", + tags = None, + py_main = None, + py_deps = None, + bazel_versions = None, + **kwargs): + """Runs a bazel-in-bazel integration test for sphinxdocs. + + Args: + name: Name of the test. + workspace_path: The directory name of the sub-workspace. + tags: Test tags. + py_main: Main Python test runner script. + py_deps: Dependencies for py_main. + bazel_versions: List of bazel versions to test. + **kwargs: Passed to bazel_integration_test. + """ + workspace_files = integration_test_utils.glob_workspace_files(workspace_path) + native.filegroup( + name = name + "_workspace_files", + srcs = workspace_files + [ + "//:distribution", + ], + ) + kwargs.setdefault("size", "large") + for bazel_version in bazel_versions or ["self"]: + test_runner = _test_runner( + name = name, + bazel_version = bazel_version, + py_main = py_main, + py_deps = py_deps or [], + ) + bazel_integration_test( + name = "{}_bazel_{}".format(name, bazel_version), + workspace_path = workspace_path, + test_runner = test_runner, + bazel_version = bazel_version, + workspace_files = [name + "_workspace_files"], + tags = (tags or []) + [ + "exclusive", + "no-sandbox", + "no-remote-exec", + "integration-test", + ], + **kwargs + ) diff --git a/sphinxdocs/integration_tests/persistent_worker/BUILD.bazel b/sphinxdocs/integration_tests/persistent_worker/BUILD.bazel new file mode 100644 index 0000000000..60fb4cbc6c --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/BUILD.bazel @@ -0,0 +1,8 @@ +load("//integration_tests:integration_test.bzl", "sphinxdocs_integration_test") + +package(default_visibility = ["//visibility:public"]) + +sphinxdocs_integration_test( + name = "persistent_worker_test", + py_main = "persistent_worker_test.py", +) diff --git a/sphinxdocs/integration_tests/persistent_worker/persistent_worker_test.py b/sphinxdocs/integration_tests/persistent_worker/persistent_worker_test.py new file mode 100644 index 0000000000..30a8b46c00 --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/persistent_worker_test.py @@ -0,0 +1,54 @@ +import unittest + +from integration_tests import runner + + +class PersistentWorkerTest(runner.TestCase): + def _check_index_html(self, text: str, should_exist: bool): + index_html = ( + self.repo_root / "bazel-bin" / "docs" / "_build" / "html" / "index.html" + ) + if not index_html.exists(): + self.fail(f"Could not find index.html at {index_html}") + content = index_html.read_text() + if should_exist: + self.assertIn( + text, + content, + f"Expected '{text}' in index.html after build, but not found.", + ) + else: + self.assertNotIn( + text, + content, + f"Expected '{text}' NOT to be in index.html after build, but found it.", + ) + + def test_incremental_add_and_remove_files(self): + # 1. Initial build + result = self.run_bazel("build", "//:docs") + self.assert_result_matches(result, "bazel-bin") + index_html = ( + self.repo_root / "bazel-bin" / "docs" / "_build" / "html" / "index.html" + ) + self.assertTrue( + index_html.exists(), "index.html should exist after initial build" + ) + + # 2. Add a new markdown file and verify it is included across incremental build + page2_md = self.repo_root / "page2.md" + page2_md.write_text("# Page 2\n\nThis is a newly added page.\n") + result = self.run_bazel("build", "//:docs") + self.assert_result_matches(result, "bazel-bin") + self._check_index_html("page2.html", should_exist=True) + + # 3. Remove the added markdown file and verify the persistent worker cleans up + # stale source files and invalidates toctrees without errors or warnings. + page2_md.unlink() + result = self.run_bazel("build", "//:docs") + self.assert_result_matches(result, "bazel-bin") + self._check_index_html("page2.html", should_exist=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/.bazelrc b/sphinxdocs/integration_tests/persistent_worker/workspace/.bazelrc new file mode 100644 index 0000000000..f3162b9547 --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/.bazelrc @@ -0,0 +1,8 @@ +# Disable disk caching and remote action caching so Bazel is forced to dispatch builds +# directly to the running persistent worker instead of restoring pre-built HTML cache results across test steps. +common --disk_cache= +build --noremote_accept_cached +common --http_timeout_scaling=10.0 +common --experimental_repository_downloader_retries=10 +common --experimental_downloader_config=downloader_config.cfg +common --lockfile_mode=off diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/BUILD.bazel b/sphinxdocs/integration_tests/persistent_worker/workspace/BUILD.bazel new file mode 100644 index 0000000000..60e66004fd --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/BUILD.bazel @@ -0,0 +1,17 @@ +load("@sphinxdocs//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") + +sphinx_docs( + name = "docs", + srcs = glob(["*.md"]), + config = "conf.py", + formats = ["html"], + sphinx = ":sphinx-build", +) + +sphinx_build_binary( + name = "sphinx-build", + deps = [ + "@dev_pip//myst_parser", + "@dev_pip//sphinx", + ], +) diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel b/sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel new file mode 100644 index 0000000000..4e2c25e8e4 --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel @@ -0,0 +1,23 @@ +module(version = "0.0.0") + +bazel_dep(name = "sphinxdocs", version = "0.0.0") +local_path_override( + module_name = "sphinxdocs", + path = "../../..", +) + +bazel_dep(name = "rules_python", version = "1.8.5") + +dev_pip = use_extension( + "@rules_python//python/extensions:pip.bzl", + "pip", + dev_dependency = True, +) +dev_pip.parse( + hub_name = "dev_pip", + python_version = "3.11", + requirements_lock = "@rules_python//docs:requirements.txt", +) +use_repo(dev_pip, "dev_pip") + +bazel_dep(name = "bazel_skylib", version = "1.8.2") diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/WORKSPACE b/sphinxdocs/integration_tests/persistent_worker/workspace/WORKSPACE new file mode 100644 index 0000000000..4be37fab94 --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/WORKSPACE @@ -0,0 +1 @@ +# Bzlmod-enabled workspace; this empty WORKSPACE file satisfies rules_bazel_integration_test checks. diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/conf.py b/sphinxdocs/integration_tests/persistent_worker/workspace/conf.py new file mode 100644 index 0000000000..de33bde101 --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/conf.py @@ -0,0 +1 @@ +extensions = ["myst_parser"] diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/downloader_config.cfg b/sphinxdocs/integration_tests/persistent_worker/workspace/downloader_config.cfg new file mode 100644 index 0000000000..3fa6264eda --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/downloader_config.cfg @@ -0,0 +1,21 @@ +# Try GitHub first (primary) +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) github.com/bazelbuild/stardoc/$1 + + +# Fall back to mirror (secondary) +# Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 +rewrite ^github\.com/bazel-contrib/bazel_features/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel_features/$1 +rewrite ^github\.com/bazel-contrib/rules_go/(.*) mirror.bazel.build/github.com/bazel-contrib/rules_go/$1 +rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/bazelbuild/bazel-skylib/$1 +rewrite ^github\.com/bazelbuild/platforms/(.*) mirror.bazel.build/github.com/bazelbuild/platforms/$1 +rewrite ^github\.com/bazelbuild/rules_kotlin/(.*) mirror.bazel.build/github.com/bazelbuild/rules_kotlin/$1 +rewrite ^github\.com/bazelbuild/rules_shell/(.*) mirror.bazel.build/github.com/bazelbuild/rules_shell/$1 +rewrite ^github\.com/bazelbuild/rules_java/(.*) mirror.bazel.build/github.com/bazelbuild/rules_java/$1 +rewrite ^github\.com/bazelbuild/stardoc/(.*) mirror.bazel.build/github.com/bazelbuild/stardoc/$1 diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/index.md b/sphinxdocs/integration_tests/persistent_worker/workspace/index.md new file mode 100644 index 0000000000..d1dca92350 --- /dev/null +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/index.md @@ -0,0 +1,7 @@ +# Test Documentation + +```{toctree} +:glob: true + +* +``` diff --git a/sphinxdocs/integration_tests/runner.py b/sphinxdocs/integration_tests/runner.py new file mode 100644 index 0000000000..cab9730bb8 --- /dev/null +++ b/sphinxdocs/integration_tests/runner.py @@ -0,0 +1,99 @@ +import logging +import os +import os.path +import pathlib +import re +import shlex +import subprocess +import unittest + +_logger = logging.getLogger(__name__) + + +class ExecuteError(Exception): + def __init__(self, result): + self.result = result + + def __str__(self): + return self.result.describe() + + +class ExecuteResult: + def __init__( + self, + args: list[str], + env: dict[str, str], + cwd: pathlib.Path, + proc_result: subprocess.CompletedProcess, + ): + self.args = args + self.env = env + self.cwd = cwd + self.exit_code = proc_result.returncode + self.stdout = proc_result.stdout + self.stderr = proc_result.stderr + + def describe(self) -> str: + env_lines = [ + " " + shlex.quote(f"{key}={value}") + for key, value in sorted(self.env.items()) + ] + env = " \\\n".join(env_lines) + args = shlex.join(self.args) + maybe_stdout_nl = "" if self.stdout.endswith("\n") else "\n" + maybe_stderr_nl = "" if self.stderr.endswith("\n") else "\n" + return f"""\ +COMMAND: +cd {self.cwd} && \\ +env \\ +{env} \\ + {args} +RESULT: exit_code: {self.exit_code} +===== STDOUT START ===== +{self.stdout}{maybe_stdout_nl}===== STDOUT END ===== +===== STDERR START ===== +{self.stderr}{maybe_stderr_nl}===== STDERR END ===== +""" + + +class TestCase(unittest.TestCase): + def setUp(self): + super().setUp() + self.repo_root = pathlib.Path(os.environ["BIT_WORKSPACE_DIR"]) + self.bazel = pathlib.Path(os.environ["BIT_BAZEL_BINARY"]) + outer_test_tmpdir = pathlib.Path(os.environ["TEST_TMPDIR"]) + self.test_tmp_dir = outer_test_tmpdir / "bit_test_tmp" + self.tmp_dir = outer_test_tmpdir / "bit_tmp" + self.bazel_env = { + "PATH": os.environ["PATH"], + "TEST_TMPDIR": str(self.test_tmp_dir), + "TMP": str(self.tmp_dir), + "RUNFILES_DIR": os.environ["TEST_SRCDIR"], + } + + def run_bazel(self, *args: str, check: bool = True) -> ExecuteResult: + args = [str(self.bazel), *args] + env = self.bazel_env + _logger.info("executing: %s", shlex.join(args)) + cwd = self.repo_root + proc_result = subprocess.run( + args=args, + text=True, + capture_output=True, + cwd=cwd, + env=env, + check=False, + ) + exec_result = ExecuteResult(args, env, cwd, proc_result) + if check and exec_result.exit_code: + raise ExecuteError(exec_result) + else: + return exec_result + + def assert_result_matches(self, result: ExecuteResult, regex: str) -> None: + if not re.search(regex, result.stdout + result.stderr): + self.fail( + "Bazel output did not match expected pattern\n" + + f"expected pattern: {regex}\n" + + f"invocation details:\n{result.describe()}" + ) diff --git a/sphinxdocs/sphinxdocs/BUILD.bazel b/sphinxdocs/sphinxdocs/BUILD.bazel index fbdd633a32..9a871f5268 100644 --- a/sphinxdocs/sphinxdocs/BUILD.bazel +++ b/sphinxdocs/sphinxdocs/BUILD.bazel @@ -20,6 +20,14 @@ package( default_visibility = ["//:__subpackages__"], ) +filegroup( + name = "distribution", + srcs = glob(["**/*"]) + [ + "//sphinxdocs/private:distribution", + ], + visibility = ["//:__pkg__"], +) + # Additional -D values to add to every Sphinx build. # This is usually used to override the version when building repeated_string_list_flag( diff --git a/sphinxdocs/sphinxdocs/private/BUILD.bazel b/sphinxdocs/sphinxdocs/private/BUILD.bazel index b054ed2611..0fe0ba4152 100644 --- a/sphinxdocs/sphinxdocs/private/BUILD.bazel +++ b/sphinxdocs/sphinxdocs/private/BUILD.bazel @@ -34,6 +34,12 @@ exports_files( visibility = ["//visibility:public"], ) +filegroup( + name = "distribution", + srcs = glob(["**/*"]), + visibility = ["//sphinxdocs:__pkg__"], +) + bzl_library( name = "util", srcs = ["util.bzl"], diff --git a/sphinxdocs/sphinxdocs/private/sphinx_build.py b/sphinxdocs/sphinxdocs/private/sphinx_build.py index 3605b4579d..07a123b496 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx_build.py +++ b/sphinxdocs/sphinxdocs/private/sphinx_build.py @@ -144,6 +144,30 @@ def _prepare_sphinx(self, request): logger.info("path %s changed", path) changed_paths.append(path) + # Remove any source files that were tracked in the previous request (`current_digests`) + # but are missing from the current `request["inputs"]` (`incoming_digests`). + # Across incremental branch switches or file removals, if these stale symlinks + # remain in `srcdir` on disk, Sphinx will discover broken/unreadable files during + # `find_files()` and abort with "WARNING: Ignored unreadable document" (fatal with -W). + for path in set(current_digests) - set(incoming_digests): + removed_path = os.path.join(srcdir, path) + if os.path.exists(removed_path) or os.path.islink(removed_path): + logger.info("removing stale source file %s", removed_path) + try: + if os.path.islink(removed_path): + try: + os.remove(removed_path) + except OSError: + os.rmdir(removed_path) + elif os.path.isdir(removed_path): + shutil.rmtree(removed_path) + else: + os.remove(removed_path) + except OSError as e: + logger.warning( + "failed to remove stale source %s: %s", removed_path, e + ) + self._digests[srcdir] = incoming_digests self._extension.changed_paths = changed_paths request_info["changed_sources"] = changed_paths @@ -188,6 +212,12 @@ def _process_request(self, request: "WorkRequest") -> "WorkResponse | None": stdout.truncate(0) stderr.seek(0) stderr.truncate(0) + # If Sphinx cache (`--doctree-dir`) becomes corrupted across incremental + # updates or branch checkouts, exit code 2 is returned. Wiping out the cached + # doctrees before retrying allows Sphinx to recover cleanly from scratch. + for arg in sphinx_args: + if arg.startswith("--doctree-dir="): + shutil.rmtree(arg.split("=", 1)[1], ignore_errors=True) exit_code = main(sphinx_args) if exit_code: @@ -247,14 +277,31 @@ def setup(self, app): return {"parallel_read_safe": True, "parallel_write_safe": True} def _handle_env_get_outdated(self, app, env, added, changed, removed): - changed = { - # NOTE: path2doc returns None if it's not a doc path - env.path2doc(p) - for p in self.changed_paths - } - - logger.info("changed docs: %s", changed) - return changed + changed_docs = set() + for p in self.changed_paths: + # Try multiple path resolutions because depending on how Sphinx and Bazel + # represent inputs (`p`), `env.path2doc` may require relative, srcdir-joined, + # or absolute paths to successfully resolve the document name. + doc = ( + env.path2doc(p) + or env.path2doc(os.path.join(env.srcdir, p)) + or env.path2doc(os.path.abspath(os.path.join(env.srcdir, p))) + ) + if doc: + changed_docs.add(doc) + + # When documents are added or removed across incremental builds or branch checkouts, + # parent documents whose `toctree` includes them (especially via glob patterns or + # explicit references to removed docs) must be invalidated and re-read. Otherwise, + # Sphinx retains stale table of contents entries or throws unresolvable reference errors. + if added or removed: + glob_toctrees = getattr(env, "glob_toctrees", set()) + for doc, includes in getattr(env, "toctree_includes", {}).items(): + if doc in glob_toctrees or not removed.isdisjoint(includes): + changed_docs.add(doc) + + logger.info("changed docs: %s", changed_docs) + return changed_docs def _worker_main(stdin, stdout, exec_root): From 5bdec9fd2173c57fc31dfffe4fe37ee029577f38 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 11 Jul 2026 20:18:32 -0700 Subject: [PATCH 081/179] refactor: use centralized NOT_ACTUALLY_PUBLIC across internal targets (#3920) Currently, several internal macros, toolchain definitions, and config settings either hardcode \`["//visibility:public"]\` or define private copies like \`_NOT_ACTUALLY_PUBLIC\`. This scatters visibility workarounds across the codebase and makes developer intent unclear. To clean this up, transition internal rules across \`python/private\` to use \`NOT_ACTUALLY_PUBLIC\` from \`python/private/visibility.bzl\`, and update corresponding \`bzl_library\` dependencies to match. --- python/config_settings/BUILD.bazel | 25 +++++++++-------- python/private/BUILD.bazel | 29 +++++++++++--------- python/private/api/BUILD.bazel | 3 +- python/private/config_settings.bzl | 21 ++++++-------- python/private/stamp_impl.bzl | 4 ++- sphinxdocs/sphinxdocs/private/BUILD.bazel | 12 ++++++-- sphinxdocs/sphinxdocs/private/visibility.bzl | 7 +++++ tools/precompiler/BUILD.bazel | 9 +++--- tools/private/zipapp/BUILD.bazel | 25 +++++++---------- 9 files changed, 73 insertions(+), 62 deletions(-) create mode 100644 sphinxdocs/sphinxdocs/private/visibility.bzl diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel index c7de1d00da..d92aa4261c 100644 --- a/python/config_settings/BUILD.bazel +++ b/python/config_settings/BUILD.bazel @@ -15,6 +15,7 @@ load( "VenvsUseDeclareSymlinkFlag", rp_string_flag = "string_flag", ) +load("//python/private:visibility.bzl", "NOT_ACTUALLY_PUBLIC") # buildifier: disable=bzl-visibility load("//python/private/pypi:flags.bzl", "define_pypi_internal_flags") load(":config_settings.bzl", "construct_config_settings") @@ -48,7 +49,7 @@ string_flag( build_setting_default = AddSrcsToRunfilesFlag.AUTO, values = AddSrcsToRunfilesFlag.flag_values(), # NOTE: Only public because it is dependency of public rules. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) string_flag( @@ -67,7 +68,7 @@ config_setting( }, # NOTE: Only public because it is used in py_toolchain_suite from toolchain # repositories - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) string_flag( @@ -75,7 +76,7 @@ string_flag( build_setting_default = PrecompileFlag.AUTO, values = sorted(PrecompileFlag.__members__.values()), # NOTE: Only public because it's an implicit dependency - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) string_flag( @@ -83,7 +84,7 @@ string_flag( build_setting_default = ValidateTestMainFlag.AUTO, values = ValidateTestMainFlag.flag_values(), # NOTE: Only public because it's an implicit dependency of py_test. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) string_flag( @@ -91,7 +92,7 @@ string_flag( build_setting_default = PrecompileSourceRetentionFlag.AUTO, values = sorted(PrecompileSourceRetentionFlag.__members__.values()), # NOTE: Only public because it's an implicit dependency - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) rp_string_flag( @@ -104,7 +105,7 @@ rp_string_flag( }), values = sorted(BootstrapImplFlag.__members__.values()), # NOTE: Only public because it's an implicit dependency - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) label_flag( @@ -127,7 +128,7 @@ string_flag( build_setting_default = LibcFlag.GLIBC, values = LibcFlag.flag_values(), # NOTE: Only public because it is used in pip hub and toolchain repos. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) string_flag( @@ -166,21 +167,21 @@ string_flag( name = "venv", build_setting_default = "auto", # NOTE: Only public because it is used in pip hub repos and executable transitions. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) string_flag( name = "pip_whl_osx_version", build_setting_default = "", # NOTE: Only public because it is used in pip hub repos. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) string_flag( name = "venvs_site_packages", build_setting_default = VenvsSitePackages.NO, # NOTE: Only public because it is used in pip hub repos. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) config_setting( @@ -189,7 +190,7 @@ config_setting( ":venvs_site_packages": VenvsSitePackages.YES, }, # NOTE: Only public because it is used in whl_library repos. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) define_pypi_internal_flags( @@ -200,7 +201,7 @@ label_flag( name = "pip_env_marker_config", build_setting_default = ":_pip_env_marker_default_config", # NOTE: Only public because it is used in pip hub repos. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) bool_flag( diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index e4d06d4a89..5f51b7650c 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -22,6 +22,7 @@ load(":py_interpreter_program.bzl", "py_interpreter_program") load(":sentinel_impl.bzl", "sentinel") load(":stamp_impl.bzl", "stamp_build_setting") load(":uncachable_version_file.bzl", "define_uncachable_version_file") +load(":visibility.bzl", "NOT_ACTUALLY_PUBLIC") package( default_visibility = [ @@ -95,7 +96,7 @@ exports_files( ["python_bootstrap_template.txt"], # Not actually public. Only public because it's an implicit dependency of # py_runtime. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) filegroup( @@ -103,7 +104,7 @@ filegroup( srcs = ["stage1_bootstrap_template.sh"], # Not actually public. Only public because it's an implicit dependency of # py_runtime. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) filegroup( @@ -111,7 +112,7 @@ filegroup( srcs = ["stage2_bootstrap_template.py"], # Not actually public. Only public because it's an implicit dependency of # py_runtime. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) filegroup( @@ -119,7 +120,7 @@ filegroup( srcs = ["site_init_template.py"], # Not actually public. Only public because it's an implicit dependency of # py_runtime. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) # NOTE: Windows builds don't use this bootstrap. Instead, a native Windows @@ -133,7 +134,7 @@ alias( }), # Not actually public. Only public because it's an implicit dependency of # py_runtime. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) # Used to determine the use of `--stamp` in Starlark rules @@ -169,7 +170,7 @@ bool_flag( name = "visible_for_testing", build_setting_default = False, # This is only because it is an implicit dependency by the toolchains. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) # Used for py_console_script_gen rule @@ -203,7 +204,7 @@ current_interpreter_executable( name = "current_interpreter_executable", # Not actually public. Only public because it's an implicit dependency of # py_exec_tools_toolchain. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) py_library( @@ -227,7 +228,7 @@ py_interpreter_program( main = "py_test_main_validator.py", # Not actually public. Only public because it's an implicit dependency of # the py_test rule. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) py_library( @@ -330,6 +331,7 @@ bzl_library( deps = [ ":text_util", ":version", + ":visibility", "@bazel_skylib//lib:selects", "@bazel_skylib//rules:common_settings", ], @@ -906,6 +908,12 @@ bzl_library( ], ) +bzl_library( + name = "stamp_impl", + srcs = ["stamp_impl.bzl"], + deps = [":visibility"], +) + bzl_library( name = "bzlmod_enabled", srcs = ["bzlmod_enabled.bzl"], @@ -986,11 +994,6 @@ bzl_library( srcs = ["sentinel_impl.bzl"], ) -bzl_library( - name = "stamp_impl", - srcs = ["stamp_impl.bzl"], -) - bzl_library( name = "text_util", srcs = ["text_util.bzl"], diff --git a/python/private/api/BUILD.bazel b/python/private/api/BUILD.bazel index b3cc7360a7..cd7eda1bd2 100644 --- a/python/private/api/BUILD.bazel +++ b/python/private/api/BUILD.bazel @@ -13,6 +13,7 @@ # limitations under the License. load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("//python/private:visibility.bzl", "NOT_ACTUALLY_PUBLIC") load(":py_common_api.bzl", "py_common_api") package( @@ -27,7 +28,7 @@ filegroup( py_common_api( name = "py_common_api_impl", # NOTE: Not actually public. Implicit dependency of public rules. - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) bzl_library( diff --git a/python/private/config_settings.bzl b/python/private/config_settings.bzl index 91fbbba8cb..9cd729aff2 100644 --- a/python/private/config_settings.bzl +++ b/python/private/config_settings.bzl @@ -17,8 +17,9 @@ load("@bazel_skylib//lib:selects.bzl", "selects") load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") -load("//python/private:text_util.bzl", "render") +load(":text_util.bzl", "render") load(":version.bzl", "version") +load(":visibility.bzl", "NOT_ACTUALLY_PUBLIC") _PYTHON_VERSION_FLAG = Label("//python/config_settings:python_version") _PYTHON_VERSION_MAJOR_MINOR_FLAG = Label("//python/config_settings:python_version_major_minor") @@ -31,10 +32,6 @@ If the value is missing, then the default value is being used, see documentation {docs_url}/python/config_settings """ -# Indicates something needs public visibility so that other generated code can -# access it, but it's not intended for general public usage. -_NOT_ACTUALLY_PUBLIC = ["//visibility:public"] - def construct_config_settings( *, name, @@ -93,7 +90,7 @@ def construct_config_settings( native.config_setting( name = "_" + name, flag_values = {":python_version": ver}, - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) # An alias pointing to an underscore-prefixed config_setting_group @@ -111,7 +108,7 @@ def construct_config_settings( native.alias( name = name, actual = "_{}_group".format(name), - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) # This matches the raw flag value, e.g. --//python/config_settings:python_version=3.8 @@ -156,30 +153,30 @@ def construct_config_settings( # `whl_library` in the hub repo created by `pip.parse`. flag_values = {"current_config": "will-never-match"}, # Only public so that PyPI hub repo can access it - visibility = _NOT_ACTUALLY_PUBLIC, + visibility = NOT_ACTUALLY_PUBLIC, ) libc = Label("//python/config_settings:py_linux_libc") native.config_setting( name = "_is_py_linux_libc_glibc", flag_values = {libc: "glibc"}, - visibility = _NOT_ACTUALLY_PUBLIC, + visibility = NOT_ACTUALLY_PUBLIC, ) native.config_setting( name = "_is_py_linux_libc_musl", flag_values = {libc: "musl"}, - visibility = _NOT_ACTUALLY_PUBLIC, + visibility = NOT_ACTUALLY_PUBLIC, ) freethreaded = Label("//python/config_settings:py_freethreaded") native.config_setting( name = "_is_py_freethreaded_yes", flag_values = {freethreaded: "yes"}, - visibility = _NOT_ACTUALLY_PUBLIC, + visibility = NOT_ACTUALLY_PUBLIC, ) native.config_setting( name = "_is_py_freethreaded_no", flag_values = {freethreaded: "no"}, - visibility = _NOT_ACTUALLY_PUBLIC, + visibility = NOT_ACTUALLY_PUBLIC, ) def _python_version_flag_impl(ctx): diff --git a/python/private/stamp_impl.bzl b/python/private/stamp_impl.bzl index 6bc0cd9d23..77e3da1f72 100644 --- a/python/private/stamp_impl.bzl +++ b/python/private/stamp_impl.bzl @@ -18,6 +18,8 @@ This module can be removed likely after the following PRs ar addressed: - https://github.com/bazelbuild/bazel/issues/11164 """ +load(":visibility.bzl", "NOT_ACTUALLY_PUBLIC") + StampSettingInfo = provider( doc = "Information about the `--stamp` command line flag", fields = { @@ -50,7 +52,7 @@ Stamped binaries are not rebuilt unless their dependencies change. }, ) -def stamp_build_setting(name, visibility = ["//visibility:public"]): +def stamp_build_setting(name, visibility = NOT_ACTUALLY_PUBLIC): native.config_setting( name = "stamp_detect", values = {"stamp": "1"}, diff --git a/sphinxdocs/sphinxdocs/private/BUILD.bazel b/sphinxdocs/sphinxdocs/private/BUILD.bazel index 0fe0ba4152..fa5ded15f1 100644 --- a/sphinxdocs/sphinxdocs/private/BUILD.bazel +++ b/sphinxdocs/sphinxdocs/private/BUILD.bazel @@ -16,6 +16,7 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load("@com_google_protobuf//bazel:py_proto_library.bzl", "py_proto_library") load("@rules_python//python:py_binary.bzl", "py_binary") load("@rules_python//python:py_library.bzl", "py_library") +load(":visibility.bzl", "NOT_ACTUALLY_PUBLIC") package( default_visibility = ["//:__subpackages__"], @@ -108,18 +109,23 @@ bzl_library( ], ) +bzl_library( + name = "visibility", + srcs = ["visibility.bzl"], +) + py_binary( name = "inventory_builder", srcs = ["inventory_builder.py"], # Only public because it's an implicit attribute - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, ) py_binary( name = "proto_to_markdown", srcs = ["proto_to_markdown.py"], # Only public because it's an implicit attribute - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, deps = [":proto_to_markdown_lib"], ) @@ -127,7 +133,7 @@ py_library( name = "proto_to_markdown_lib", srcs = ["proto_to_markdown.py"], # Only public because it's an implicit attribute - visibility = ["//visibility:public"], + visibility = NOT_ACTUALLY_PUBLIC, deps = [ ":stardoc_output_proto_py_pb2", ], diff --git a/sphinxdocs/sphinxdocs/private/visibility.bzl b/sphinxdocs/sphinxdocs/private/visibility.bzl new file mode 100644 index 0000000000..706be45a3b --- /dev/null +++ b/sphinxdocs/sphinxdocs/private/visibility.bzl @@ -0,0 +1,7 @@ +"""Shared code for use with visibility specs.""" + +# Use when a target isn't actually public, but needs public +# visibility to keep Bazel happy. +# Such cases are typically for defaults of rule attributes or macro args that +# get used outside of sphinxdocs itself. +NOT_ACTUALLY_PUBLIC = ["//visibility:public"] diff --git a/tools/precompiler/BUILD.bazel b/tools/precompiler/BUILD.bazel index 268f41b032..e055980c72 100644 --- a/tools/precompiler/BUILD.bazel +++ b/tools/precompiler/BUILD.bazel @@ -14,6 +14,7 @@ load("@bazel_skylib//rules:common_settings.bzl", "string_list_flag") load("//python/private:py_interpreter_program.bzl", "py_interpreter_program") # buildifier: disable=bzl-visibility +load("//python/private:visibility.bzl", "NOT_ACTUALLY_PUBLIC") # buildifier: disable=bzl-visibility filegroup( name = "distribution", @@ -25,11 +26,9 @@ py_interpreter_program( name = "precompiler", execution_requirements = ":execution_requirements", main = "precompiler.py", - visibility = [ - # Not actually public. Only public so rules_python-generated toolchains - # are able to reference it. - "//visibility:public", - ], + # Not actually public. Only public so rules_python-generated toolchains + # are able to reference it. + visibility = NOT_ACTUALLY_PUBLIC, ) string_list_flag( diff --git a/tools/private/zipapp/BUILD.bazel b/tools/private/zipapp/BUILD.bazel index 7420776ef3..45bd126c6e 100644 --- a/tools/private/zipapp/BUILD.bazel +++ b/tools/private/zipapp/BUILD.bazel @@ -1,5 +1,6 @@ load("//python:py_library.bzl", "py_library") load("//python/private:py_interpreter_program.bzl", "py_interpreter_program") # buildifier: disable=bzl-visibility +load("//python/private:visibility.bzl", "NOT_ACTUALLY_PUBLIC") # buildifier: disable=bzl-visibility package( default_visibility = ["//:__subpackages__"], @@ -8,11 +9,9 @@ package( py_interpreter_program( name = "zipper", main = "zipper.py", - visibility = [ - # Not actually public. Only public so rules_python-generated toolchains - # are able to reference it. - "//visibility:public", - ], + # Not actually public. Only public so rules_python-generated toolchains + # are able to reference it. + visibility = NOT_ACTUALLY_PUBLIC, ) py_library( @@ -23,11 +22,9 @@ py_library( py_interpreter_program( name = "exe_zip_maker", main = "exe_zip_maker.py", - visibility = [ - # Not actually public. Only public so rules_python-generated toolchains - # are able to reference it. - "//visibility:public", - ], + # Not actually public. Only public so rules_python-generated toolchains + # are able to reference it. + visibility = NOT_ACTUALLY_PUBLIC, ) py_library( @@ -38,11 +35,9 @@ py_library( py_interpreter_program( name = "zip_main_maker", main = "zip_main_maker.py", - visibility = [ - # Not actually public. Only public so rules_python-generated toolchains - # are able to reference it. - "//visibility:public", - ], + # Not actually public. Only public so rules_python-generated toolchains + # are able to reference it. + visibility = NOT_ACTUALLY_PUBLIC, ) py_library( From 19ffd215cff201eccbe12b2a26c4a287413b902d Mon Sep 17 00:00:00 2001 From: 13steinj <13steinj@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:18:58 -0500 Subject: [PATCH 082/179] fix: make executable implicit defaults public so other modules can use rule builders (#3919) `create_executable_rule_builder()` (used by `py_binary_rule_builder()` / `py_test_rule_builder()`, the public `python/api/executables.bzl` API) bundles three implicit attrs whose defaults point at private targets under `//python/private`: `build_data_writer`, `debugger_if_target_config`, and `uncachable_version_file`. These targets had no explicit visibility, so they inherited the package's `default_visibility` (`//:__subpackages__`), which only covers packages inside the rules_python repo itself. Because the builder API is designed to let external modules call `rule()` themselves (via `builder.build()`), Bazel checks visibility of these attr defaults from the *calling* module's package, not from rules_python's. Any external repo constructing a rule via `py_binary_rule_builder()` / `py_test_rule_builder()` therefore fails at analysis time with a visibility error. This has been broken since the builder API's introduction; there's prior art in this same file for exactly this situation (e.g. `stage1_bootstrap_template`, among others) Co-authored-by: Richard Levasseur --- .bazelrc.deleted_packages | 1 + .../other_module/rule_builder/BUILD.bazel | 13 +++++++++++++ .../other_module/rule_builder/app.py | 1 + .../other_module/rule_builder/rule.bzl | 17 +++++++++++++++++ examples/bzlmod/tests/other_module/BUILD.bazel | 11 +++++++++++ news/3919.fixed.md | 3 +++ python/private/BUILD.bazel | 17 +++++++++++++++++ python/private/uncachable_version_file.bzl | 3 ++- 8 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 examples/bzlmod/other_module/other_module/rule_builder/BUILD.bazel create mode 100644 examples/bzlmod/other_module/other_module/rule_builder/app.py create mode 100644 examples/bzlmod/other_module/other_module/rule_builder/rule.bzl create mode 100644 news/3919.fixed.md diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index aca014bb56..db42040b5c 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -7,6 +7,7 @@ common --deleted_packages=examples/bzlmod/entry_points/tests common --deleted_packages=examples/bzlmod/libs/my_lib common --deleted_packages=examples/bzlmod/other_module common --deleted_packages=examples/bzlmod/other_module/other_module/pkg +common --deleted_packages=examples/bzlmod/other_module/other_module/rule_builder common --deleted_packages=examples/bzlmod/patches common --deleted_packages=examples/bzlmod/runfiles common --deleted_packages=examples/bzlmod/tests diff --git a/examples/bzlmod/other_module/other_module/rule_builder/BUILD.bazel b/examples/bzlmod/other_module/other_module/rule_builder/BUILD.bazel new file mode 100644 index 0000000000..40f66b3322 --- /dev/null +++ b/examples/bzlmod/other_module/other_module/rule_builder/BUILD.bazel @@ -0,0 +1,13 @@ +load(":rule.bzl", "custom_py_binary") + +# This target's mere existence is the regression test: analyzing it exercises +# the implicit attr defaults (build_data_writer, debugger_if_target_config, +# uncachable_version_file) that py_binary_rule_builder() bundles from +# rules_python's private package, from a rule() call made in this external +# module. +custom_py_binary( + name = "app", + srcs = ["app.py"], + main = "app.py", + visibility = ["//visibility:public"], +) diff --git a/examples/bzlmod/other_module/other_module/rule_builder/app.py b/examples/bzlmod/other_module/other_module/rule_builder/app.py new file mode 100644 index 0000000000..db51494ed6 --- /dev/null +++ b/examples/bzlmod/other_module/other_module/rule_builder/app.py @@ -0,0 +1 @@ +print("hello from a rule built via py_binary_rule_builder()") diff --git a/examples/bzlmod/other_module/other_module/rule_builder/rule.bzl b/examples/bzlmod/other_module/other_module/rule_builder/rule.bzl new file mode 100644 index 0000000000..7b1505987a --- /dev/null +++ b/examples/bzlmod/other_module/other_module/rule_builder/rule.bzl @@ -0,0 +1,17 @@ +"""A minimal custom py_binary built via the executables rule builder API. + +Regression coverage for https://github.com/bazel-contrib/rules_python/pull/3919: +py_binary_rule_builder()'s implicit attr defaults (build_data_writer, +debugger_if_target_config, uncachable_version_file) previously had no +visibility outside of rules_python, so any external module (like this one) +constructing a rule via the builder failed at analysis time with a +visibility error. +""" + +load("@rules_python//python/api:executables.bzl", "executables") + +def _make_rule(): + builder = executables.py_binary_rule_builder() + return builder.build() + +custom_py_binary = _make_rule() diff --git a/examples/bzlmod/tests/other_module/BUILD.bazel b/examples/bzlmod/tests/other_module/BUILD.bazel index 24231e651a..f4756e7c3e 100644 --- a/examples/bzlmod/tests/other_module/BUILD.bazel +++ b/examples/bzlmod/tests/other_module/BUILD.bazel @@ -14,6 +14,17 @@ build_test( ], ) +# Regression test for https://github.com/bazel-contrib/rules_python/pull/3919: py_binary_rule_builder()'s implicit +# attr defaults previously had no visibility outside of rules_python, so +# building this target (defined via the builder from an external module) +# failed at analysis time with a visibility error. +build_test( + name = "other_module_rule_builder_build_test", + targets = [ + "@our_other_module//other_module/rule_builder:app", + ], +) + py_test( name = "other_module_import_test", srcs = ["other_module_import_test.py"], diff --git a/news/3919.fixed.md b/news/3919.fixed.md new file mode 100644 index 0000000000..0e45d3ce9c --- /dev/null +++ b/news/3919.fixed.md @@ -0,0 +1,3 @@ +Fixed `py_binary_rule_builder()` / `py_test_rule_builder()` (from `python/api/executables.bzl`) +failing at analysis time with a visibility error when used to construct a custom rule from an +external module. diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index 5f51b7650c..17d18fef6f 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -70,10 +70,16 @@ alias( "@platforms//os:windows": ":build_data_writer.ps1", "//conditions:default": ":build_data_writer.sh", }), + # Not actually public. Only public because it's an implicit dependency of + # rules built via py_binary_rule_builder() / py_test_rule_builder(). + visibility = ["//visibility:public"], ) define_uncachable_version_file( name = "uncachable_version_file", + # Not actually public. Only public because it's an implicit dependency of + # rules built via py_binary_rule_builder() / py_test_rule_builder(). + visibility = ["//visibility:public"], ) # Needed to define bzl_library targets for docgen. (We don't define the @@ -160,6 +166,9 @@ alias( ":is_bazel_config_mode_target": "//python/config_settings:debugger", "//conditions:default": "//python/private:empty", }), + # Not actually public. Only public because it's an implicit dependency of + # rules built via py_binary_rule_builder() / py_test_rule_builder(). + visibility = ["//visibility:public"], ) bazel_config_mode(name = "bazel_config_mode") @@ -209,10 +218,18 @@ current_interpreter_executable( py_library( name = "empty", + # Not actually public. Only public because it's the resolved default of + # debugger_if_target_config, an implicit dependency of rules built via + # py_binary_rule_builder() / py_test_rule_builder(). + visibility = ["//visibility:public"], ) sentinel( name = "sentinel", + # Not actually public. Only public because it's the resolved default of + # uncachable_version_file, an implicit dependency of rules built via + # py_binary_rule_builder() / py_test_rule_builder(). + visibility = ["//visibility:public"], ) py_binary( diff --git a/python/private/uncachable_version_file.bzl b/python/private/uncachable_version_file.bzl index 9b1d65a469..0f58df8043 100644 --- a/python/private/uncachable_version_file.bzl +++ b/python/private/uncachable_version_file.bzl @@ -28,12 +28,13 @@ actions depending on this file will always re-run. implementation = _uncachable_version_file_impl, ) -def define_uncachable_version_file(name): +def define_uncachable_version_file(name, visibility = None): native.alias( name = name, actual = select({ ":stamp_detect": ":uncachable_version_file_impl", "//conditions:default": ":sentinel", }), + visibility = visibility, ) uncachable_version_file(name = "uncachable_version_file_impl") From 68d08786c9299b768f72a436e07cee332bd99f7c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 11 Jul 2026 23:26:06 -0700 Subject: [PATCH 083/179] chore(reviewbot): expand multi-model cascade with fallback endpoints (#3922) Currently, `LocalAgentConfig` only configures a single default model without an explicit endpoint attached when passing `ModelTarget` objects, which causes startup errors (`must have an endpoint configured`) or rate-limit failures when quota runs short. To fix, import `GeminiAPIEndpoint` and `ModelTarget`, create an explicit endpoint (`GeminiAPIEndpoint()`), and configure a prioritized multi-model cascade across all major Gemini 3 and Gemini 2 models so the agent automatically falls back to whichever model has open quota and full function calling capabilities. --- .github/workflows/automated_pr_review.yaml | 48 +++++++++++++------ tools/private/reviewbot/antigravity_review.py | 41 +++++++++++++--- .../reviewbot/skills/review-pr/SKILL.md | 2 + 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index af2da7e0ca..927adbbd4c 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -5,8 +5,6 @@ name: Automated Code Review # to secrets (like GEMINI_API_KEY) even for fork PRs. # Using pull_request for now during setup/testing. on: - pull_request: - types: [opened] issue_comment: types: [created] @@ -15,21 +13,24 @@ permissions: pull-requests: read jobs: + # Always runs so the workflow run exits cleanly without a "No jobs ran" error + # when the review job's if-condition evaluates to false. + noop: + runs-on: ubuntu-latest + steps: + - name: Workflow trigger check + run: echo "Workflow triggered successfully." + review: runs-on: ubuntu-latest - # Trigger only if: - # 1. It is a pull_request event, it is NOT a draft, and the author is a maintainer - # (OWNER, MEMBER, or COLLABORATOR). - # 2. OR it is a regular conversation comment on a pull request, the comment body has a line starting with "/review", - # and the commenter is a maintainer. + # Trigger only if it is a regular comment on a pull request, the comment body has a line + # starting with "/review", and the commenter is a maintainer (OWNER, MEMBER, or COLLABORATOR). if: > - (github.event_name == 'pull_request' && !github.event.pull_request.draft && - contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)) || - (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && - (startsWith(github.event.comment.body, '/review') || - contains(github.event.comment.body, '\n/review') || - contains(github.event.comment.body, '\r\n/review')) && - contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) + github.event_name == 'issue_comment' && github.event.issue.pull_request != null && + (startsWith(github.event.comment.body, '/review') || + contains(github.event.comment.body, '\n/review') || + contains(github.event.comment.body, '\r\n/review')) && + contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) steps: - name: Checkout PR Branch uses: actions/checkout@v7 @@ -40,6 +41,25 @@ jobs: ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head persist-credentials: false + - name: Fetch Base Branch and Negotiate Minimal Diff History + env: + PR_COMMITS: ${{ github.event.pull_request.commits }} + run: | + # 1. Fetch the tip of main + git fetch origin main:refs/remotes/origin/main --depth=1 + + # 2. Check if we already have the merge base (common ancestor) + if ! git merge-base origin/main HEAD >/dev/null 2>&1; then + # If we know the exact number of commits in the PR, deepen by (PR_COMMITS + 10) + if [ -n "$PR_COMMITS" ] && [ "$PR_COMMITS" != "null" ]; then + git fetch --deepen="$((PR_COMMITS + 10))" + fi + # 3. If it's an issue_comment event (where PR_COMMITS is null) or still shallow, unshallow/deepen + if ! git merge-base origin/main HEAD >/dev/null 2>&1; then + git fetch --unshallow || git fetch --deepen=50 + fi + fi + - name: Checkout Reviewbot (Base Branch) uses: actions/checkout@v7 with: diff --git a/tools/private/reviewbot/antigravity_review.py b/tools/private/reviewbot/antigravity_review.py index e415e42a91..3aef480f3b 100644 --- a/tools/private/reviewbot/antigravity_review.py +++ b/tools/private/reviewbot/antigravity_review.py @@ -2,14 +2,16 @@ # requires-python = ">=3.11" # dependencies = [ # "google-antigravity", -# "requests", # ] # /// import argparse import asyncio +import subprocess from pathlib import Path from google.antigravity import Agent, CapabilitiesConfig, LocalAgentConfig +from google.antigravity.models import GeminiAPIEndpoint, ModelTarget +from google.antigravity.types import BuiltinTools def parse_args(): @@ -18,11 +20,26 @@ def parse_args(): return parser.parse_args() +def get_pr_diff() -> str: + """Fetches the git diff for the current pull request against origin/main.""" + try: + return subprocess.check_output( + ["git", "diff", "origin/main...HEAD"], text=True, stderr=subprocess.DEVNULL + ) + except Exception: + return "No diff could be automatically extracted via git commands." + + async def main(): args = parse_args() - # Read prompt file - prompt = Path(args.prompt).read_text() + # Read prompt file and pre-hydrate with the exact PR code diff + base_prompt = Path(args.prompt).read_text() + diff_text = get_pr_diff() + prompt = ( + f"{base_prompt}\n\n## Pull Request Git Diff\n" + f"Here is the exact code diff for this pull request:\n```diff\n{diff_text}\n```" + ) # General coordinator instructions for the reviewer agent. system_instructions = ( @@ -30,15 +47,27 @@ async def main(): "reviews on pull requests." ) + # Create the default endpoint picking up GEMINI_API_KEY from the environment. + endpoint = GeminiAPIEndpoint() + # Initialize the Antigravity Agent in read-only mode for security. # Register the review-pr skill from the local reviewbot folder. + # Provide a comprehensive prioritized cascade across Gemini 3 models + # to automatically fall back if any model hits free-tier quota limits (429) + # or temporary unavailability. config = LocalAgentConfig( + models=[ + ModelTarget(name="gemini-3.5-flash", endpoint=endpoint), + ModelTarget(name="gemini-3.1-pro-preview", endpoint=endpoint), + ModelTarget(name="gemini-3.1-flash-lite", endpoint=endpoint), + ModelTarget(name="gemini-3-pro-preview", endpoint=endpoint), + ModelTarget(name="gemini-flash-latest", endpoint=endpoint), + ModelTarget(name="gemini-pro-latest", endpoint=endpoint), + ], system_instructions=system_instructions, skills_paths=[str(Path(__file__).parent / "skills" / "review-pr" / "SKILL.md")], capabilities=CapabilitiesConfig( - allow_filesystem_read=True, - allow_filesystem_write=False, - allow_network=False, + enabled_tools=BuiltinTools.read_only(), ), ) diff --git a/tools/private/reviewbot/skills/review-pr/SKILL.md b/tools/private/reviewbot/skills/review-pr/SKILL.md index 18c998f9ed..915a526686 100644 --- a/tools/private/reviewbot/skills/review-pr/SKILL.md +++ b/tools/private/reviewbot/skills/review-pr/SKILL.md @@ -5,6 +5,8 @@ description: Perform a read-only code review on a pull request. # review-pr +IMPORTANT: The exact git diff for the pull request is pre-provided right in your prompt. Analyze this provided diff directly in a single pass without calling exploratory directory listing or file reading tools unless you specifically need surrounding lines of context from a modified file. + You are an expert Starlark, Python, and Bazel code reviewer. Analyze the changed files for correctness, edge cases, and performance. Focus strictly on logical correctness, concurrency safety, system architecture, performance bottlenecks, From 8c5d11a50236baa56c8110267adac34a26979a74 Mon Sep 17 00:00:00 2001 From: MartinNeudecker Date: Sat, 18 Jul 2026 00:51:24 +0200 Subject: [PATCH 084/179] fix(sphinxdocs): clean stale worker output dir and doctrees on first request (#3933) The Sphinx worker keeps doctrees outside declared outputs so that incremental builds survive across invocations. A newly started worker has no digest history, so it treats every input as changed and re-reads all docs. Re-reading against the stale environment left by a previous worker can produce spurious warnings, e.g. duplicate labels, which become build failures when `--fail-on-warning` is enabled. On the first request, delete the worker output directory and doctree directory so the worker starts from a clean Sphinx environment. --- sphinxdocs/sphinxdocs/private/sphinx_build.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sphinxdocs/sphinxdocs/private/sphinx_build.py b/sphinxdocs/sphinxdocs/private/sphinx_build.py index 07a123b496..790d4dff84 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx_build.py +++ b/sphinxdocs/sphinxdocs/private/sphinx_build.py @@ -128,6 +128,7 @@ def _prepare_sphinx(self, request): incoming_digests = {} current_digests = self._digests.setdefault(srcdir, {}) + is_first_request = not current_digests changed_paths = [] request_info = {"exec_root": self._exec_root, "inputs": request["inputs"]} for entry in request["inputs"]: @@ -174,6 +175,18 @@ def _prepare_sphinx(self, request): bazel_outdir = sphinx_args[1] worker_outdir = bazel_outdir + ".worker-out.d" + # The doctree dir deliberately lives outside the declared outputs so + # it survives between invocations (that is what makes worker builds + # incremental). A new worker has no digest history: it reports every + # file as changed and re-reads all docs. Doing that against the stale + # Sphinx environment of a previous worker produces spurious warnings + # (e.g. duplicate labels), which --fail-on-warning turns into build + # failures. So on the first request start from a clean slate. + if is_first_request: + shutil.rmtree(worker_outdir, ignore_errors=True) + for arg in sphinx_args: + if arg.startswith("--doctree-dir="): + shutil.rmtree(arg.partition("=")[2], ignore_errors=True) self._worker_outdirs.add(worker_outdir) sphinx_args[1] = worker_outdir From 1faaa82e081c13e193b0cae096af458f358d5c0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:38:54 -0700 Subject: [PATCH 085/179] build(deps): bump myst-parser from 4.0.1 to 5.1.0 in /docs (#3927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [myst-parser](https://github.com/executablebooks/MyST-Parser) from 4.0.1 to 5.1.0.
Release notes

Sourced from myst-parser's releases.

v5.1.0

✨ New Features

👌 Improvements

  • 👌 Update myst_gfm_only mode to use the unified gfm_plugin, which now includes GFM autolinks, alerts, and improved strikethrough/tasklist handling by @​chrisjsewell in #1128
  • 👌 Improve MathJax 4 compatibility for Sphinx 9 by @​chrisjsewell in #1110
  • 👌 Stop directive-option parsing at colon fences, fixing nested colon fence directives by @​chrisjsewell in #1133

🐛 Bug Fixes

⬆️ Dependency Upgrades

New Contributors

Full Changelog: https://github.com/executablebooks/MyST-Parser/compare/v5.0.0...v5.1.0

v5.0.0

MyST-Parser 5.0.0

Release Date: 2026-01-15

This release significantly bumps the supported versions of core dependencies:

‼️ Breaking Changes

This release updates the minimum supported versions:

  • Python: >=3.11 (dropped Python 3.10, tests up to 3.14)
  • Sphinx: >=8,<10 (dropped Sphinx 7, added Sphinx 9)
  • Docutils: >=0.20,<0.23 (dropped docutils 0.19, added docutils 0.22)
  • markdown-it-py: ~=4.0 (upgraded from v3)

... (truncated)

Changelog

Sourced from myst-parser's changelog.

5.1.0 - 2026-05-13

✨ New Features

👌 Improvements

🐛 Bug Fixes

⬆️ Dependency Upgrades

Full Changelog: v5.0.0...v5.1.0

5.0.0 - 2026-01-15

This release significantly bumps the supported versions of core dependencies:

‼️ Breaking Changes

This release updates the minimum supported versions:

  • Python: >=3.11 (dropped Python 3.10, tests up to 3.14)
  • Sphinx: >=8,<10 (dropped Sphinx 7, added Sphinx 9)
  • Docutils: >=0.20,<0.23 (dropped docutils 0.19, added docutils 0.22)
  • markdown-it-py: ~=4.0 (upgraded from v3)

⬆️ Dependency Upgrades

... (truncated)

Commits
  • 2871eb9 🚀 Release v5.1.0 (#1135)
  • cc5db37 🐛 FIX: Pin mdit-py-plugins>=0.6.1 for nested field list fix (#1134)
  • 4ce57f9 👌 Stop directive-option parsing at colon fences (#1133)
  • cfcc327 ⬆️ Bump mypy from 2.0.0 to 2.1.0 (#1131)
  • 691738c ⬆️ Bump ruff from 0.15.10 to 0.15.12 (#1132)
  • 0fb1ae9 👌 IMPROVE: MathJax 4 compatibility (Sphinx 9) (#1110)
  • f153b4b ⬆️ Bump actions/setup-python from 5 to 6 (#1092)
  • 93acf8d [pre-commit.ci] pre-commit autoupdate (#1095)
  • a5f1d69 ⬆️ Update pygments requirement from <2.20 to <2.21 (#1117)
  • 8381296 🐛 FIX: Use docname instead of source path in warning locations (#1114)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=myst-parser&package-manager=pip&previous-version=4.0.1&new-version=5.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 468cd38c0d..24782bac8f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -331,9 +331,9 @@ myst-parser==3.0.1 ; python_full_version < '3.10' \ --hash=sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1 \ --hash=sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87 # via rules-python-docs (docs/pyproject.toml) -myst-parser==4.0.1 ; python_full_version == '3.10.*' \ - --hash=sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4 \ - --hash=sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d +myst-parser==5.1.0 ; python_full_version == '3.10.*' \ + --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ + --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 # via rules-python-docs (docs/pyproject.toml) myst-parser==5.1.0 ; python_full_version >= '3.11' \ --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ From 8fed9f37afa4d70ddc9259c2bbfe5628e725e78b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:39:56 -0700 Subject: [PATCH 086/179] build(deps): bump astral-sh/setup-uv from 8.3.0 to 8.3.2 (#3924) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.0 to 8.3.2.
Commits
  • 11f9893 chore: roll up Dependabot updates (#948)
  • f798556 docs: update version references to v8.3.1 (#946)
  • e80544d chore: update known checksums for 0.11.28 (#947)
  • f98e069 Change update-docs PR labels from 'update-docs' to 'documentation' (#945)
  • cd46263 chore: update known checksums for 0.11.27 (#944)
  • 11245c7 docs: update version references to v8.3.0 (#939)
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/setup-uv&package-manager=github_actions&previous-version=8.3.0&new-version=8.3.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/automated_pr_review.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index 927adbbd4c..1076101db6 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -68,7 +68,7 @@ jobs: path: reviewbot - name: Install uv - uses: astral-sh/setup-uv@v8.3.0 + uses: astral-sh/setup-uv@v8.3.2 - name: Run Antigravity Review env: From d0f0f35bd878c5672670f56226df857f66bc4208 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:31:15 +0900 Subject: [PATCH 087/179] fix(pypi): handle absolute file URLs in wheel downloader (#3935) This fixes the normalization function to return correctly normalized file URLs which is the minimum that we need to get absolute file URLs handled correctly. Related #3312 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- news/3935.fixed.md | 4 ++++ python/private/pypi/BUILD.bazel | 11 ++++++----- python/private/pypi/urllib.bzl | 21 +++++++++++++++++---- python/private/pypi/whl_library.bzl | 4 +++- tests/pypi/urllib/urllib_tests.bzl | 12 ++++++++++-- 5 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 news/3935.fixed.md diff --git a/news/3935.fixed.md b/news/3935.fixed.md new file mode 100644 index 0000000000..83cd0473e8 --- /dev/null +++ b/news/3935.fixed.md @@ -0,0 +1,4 @@ +(pypi) fixed the URL normalization function to correctly handle local paths +enabling wheel sources files to point to an absolute path. Currently it supports +the `file://` for linux and windows like paths. We also support +envsubst for the said paths from now on. diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 8dd32afb9c..07d2300ce7 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -510,6 +510,12 @@ bzl_library( ], ) +bzl_library( + name = "urllib", + srcs = ["urllib.bzl"], + deps = ["//python/private:envsubst"], +) + bzl_library( name = "argparse", srcs = ["argparse.bzl"], @@ -555,11 +561,6 @@ bzl_library( srcs = ["platform.bzl"], ) -bzl_library( - name = "urllib", - srcs = ["urllib.bzl"], -) - bzl_library( name = "version_from_filename", srcs = ["version_from_filename.bzl"], diff --git a/python/private/pypi/urllib.bzl b/python/private/pypi/urllib.bzl index ea4cd32cc9..0754334de9 100644 --- a/python/private/pypi/urllib.bzl +++ b/python/private/pypi/urllib.bzl @@ -1,5 +1,7 @@ """Utilities for getting an absolute URL from index_url and the URL we find on PyPI index.""" +load("//python/private:envsubst.bzl", _envsubst = "envsubst") + def _get_root_directory(url): scheme_end = url.find("://") if scheme_end == -1: @@ -53,6 +55,12 @@ def _absolute_url(index_url, candidate): # relative path without up-references return "{}/{}".format(index_url.rstrip("/"), candidate) +def _with_envsubst(url, envsubst = None, getenv = None): + if not url or not envsubst or not getenv: + return url + + return _envsubst(url, envsubst, getenv) + def _strip_empty_path_segments(url): """Removes empty path segments from a URL. Does nothing for urls with no scheme. @@ -65,18 +73,23 @@ def _strip_empty_path_segments(url): The url with empty path segments removed and any trailing slash preserved. If the url had no scheme it is returned unchanged. """ - scheme, _, rest = url.partition("://") + sep = "://" + scheme, _, rest = url.partition(sep) + if scheme.lower() == "file" and rest.startswith("/"): + sep = sep + "/" + rest = rest[1:] + if rest == "": return url stripped = "/".join([p for p in rest.split("/") if p]) if url.endswith("/"): - return "{}://{}/".format(scheme, stripped) + return "{}{}{}/".format(scheme, sep, stripped) else: - return "{}://{}".format(scheme, stripped) + return "{}{}{}".format(scheme, sep, stripped) urllib = struct( is_absolute = _is_downloadable, # Ensure that we strip empty path segments when making an absolute URL - absolute_url = lambda index_url, candidate: _strip_empty_path_segments(_absolute_url(index_url, candidate)), + absolute_url = lambda index_url, candidate, *, envsubst = None, getenv = None: _strip_empty_path_segments(_with_envsubst(_absolute_url(index_url, candidate), envsubst, getenv)), strip_empty_path_segments = _strip_empty_path_segments, ) diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl index 37cc36492e..1e75d69c8d 100644 --- a/python/private/pypi/whl_library.bzl +++ b/python/private/pypi/whl_library.bzl @@ -327,8 +327,10 @@ def _whl_library_impl(rctx): urls = rctx.attr.urls urls = [ urllib.absolute_url( - envsubst(rctx.attr.index_url, rctx.attr.envsubst, rctx.getenv), + rctx.attr.index_url, url, + envsubst = rctx.attr.envsubst, + getenv = rctx.getenv, ) for url in urls ] diff --git a/tests/pypi/urllib/urllib_tests.bzl b/tests/pypi/urllib/urllib_tests.bzl index 40c48dc854..1a175dc120 100644 --- a/tests/pypi/urllib/urllib_tests.bzl +++ b/tests/pypi/urllib/urllib_tests.bzl @@ -8,7 +8,8 @@ _tests = [] def _test_absolute_url(env): # Already absolute for already_absolute in [ - "file://foo", + "file:///foo", + "file:///c:/foo", "https://foo.com", "http://foo.com", ]: @@ -25,7 +26,13 @@ def _test_absolute_url(env): env.expect.that_str(urllib.absolute_url("https://example.com/relative/", "../relative/file.whl")).equals("https://example.com/relative/file.whl") # Relative URL for files - env.expect.that_str(urllib.absolute_url("file://{PYPI_BAZEL_WORKSPACE_ROOT}", "vendor/distro/file.whl")).equals("file://{PYPI_BAZEL_WORKSPACE_ROOT}/vendor/distro/file.whl") + env.expect.that_str(urllib.absolute_url("file://${PYPI_BAZEL_WORKSPACE_ROOT}", "vendor/distro/file.whl")).equals("file://${PYPI_BAZEL_WORKSPACE_ROOT}/vendor/distro/file.whl") + env.expect.that_str(urllib.absolute_url( + "file://${PYPI_BAZEL_WORKSPACE_ROOT}", + "vendor/distro/file.whl", + envsubst = ["PYPI_BAZEL_WORKSPACE_ROOT"], + getenv = {"PYPI_BAZEL_WORKSPACE_ROOT": "/some/dir"}.get, + )).equals("file:///some/dir/vendor/distro/file.whl") _tests.append(_test_absolute_url) @@ -36,6 +43,7 @@ def _test_strip_empty_path_segments(env): env.expect.that_str(urllib.strip_empty_path_segments("scheme://with///multiple//empty/segments")).equals("scheme://with/multiple/empty/segments") env.expect.that_str(urllib.strip_empty_path_segments("scheme://with//trailing/slash/")).equals("scheme://with/trailing/slash/") env.expect.that_str(urllib.strip_empty_path_segments("scheme://with/trailing/slashes///")).equals("scheme://with/trailing/slashes/") + env.expect.that_str(urllib.strip_empty_path_segments("file:///home/user//foo")).equals("file:///home/user/foo") _tests.append(_test_strip_empty_path_segments) From d92af74f2f719a608e588d71e70bf2256a962f6a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 17 Jul 2026 20:21:34 -0700 Subject: [PATCH 088/179] chore(release): automate multi-version backports and support direct patch promotion (#3936) Adds automated workflow and tooling support for multi-version backports and support direct patch release promotion. Previously, backports required manual preparation and tracking across multiple branches, and patch releases were promoted as RCs instead of direct patch releases. This change: * Adds backport_prepare and backport_create_releases scripts and GitHub Action workflows to track and automate cherry-picking PRs across multiple minor release branches. * Renames promote_rc to promote to support promoting patch releases directly. * Refactors release tools to use BackportMetadata dataclass and propagate errors with context (add_note). * Updates RELEASING.md documentation. --- .../workflows/backport_create_releases.yaml | 47 +++ .github/workflows/backport_prepare.yaml | 76 +++++ .github/workflows/on_comment.yaml | 98 ++++-- ...e_promote_rc.yaml => release_promote.yaml} | 4 +- RELEASING.md | 64 +++- tests/tools/private/release/BUILD.bazel | 23 +- .../release/backport_create_releases_test.py | 176 ++++++++++ .../private/release/backport_prepare_test.py | 247 ++++++++++++++ .../{promote_rc_test.py => promote_test.py} | 42 ++- tests/tools/private/release/release_test.py | 4 +- .../private/release/release_test_helper.py | 44 ++- tools/private/release/BUILD.bazel | 13 +- .../release/backport_create_releases.py | 237 ++++++++++++++ tools/private/release/backport_prepare.py | 305 ++++++++++++++++++ tools/private/release/create_release_issue.py | 8 + tools/private/release/gh.py | 55 +++- tools/private/release/git.py | 22 ++ tools/private/release/mock_gh.py | 84 +++++ .../release/{promote_rc.py => promote.py} | 93 +++--- tools/private/release/release.py | 8 +- 20 files changed, 1527 insertions(+), 123 deletions(-) create mode 100644 .github/workflows/backport_create_releases.yaml create mode 100644 .github/workflows/backport_prepare.yaml rename .github/workflows/{release_promote_rc.yaml => release_promote.yaml} (94%) create mode 100644 tests/tools/private/release/backport_create_releases_test.py create mode 100644 tests/tools/private/release/backport_prepare_test.py rename tests/tools/private/release/{promote_rc_test.py => promote_test.py} (90%) create mode 100644 tools/private/release/backport_create_releases.py create mode 100644 tools/private/release/backport_prepare.py create mode 100644 tools/private/release/mock_gh.py rename tools/private/release/{promote_rc.py => promote.py} (75%) diff --git a/.github/workflows/backport_create_releases.yaml b/.github/workflows/backport_create_releases.yaml new file mode 100644 index 0000000000..346bfdbbdb --- /dev/null +++ b/.github/workflows/backport_create_releases.yaml @@ -0,0 +1,47 @@ +name: "Backport: Create Releases" + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: true + type: string + workflow_call: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: true + type: string + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + backport_create_releases: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run Backport Create Releases Pipeline + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ inputs.issue }} + run: | + bazel run //tools/private/release -- \ + backport-create-releases --issue=$ISSUE --no-dry-run diff --git a/.github/workflows/backport_prepare.yaml b/.github/workflows/backport_prepare.yaml new file mode 100644 index 0000000000..587c0fc357 --- /dev/null +++ b/.github/workflows/backport_prepare.yaml @@ -0,0 +1,76 @@ +name: "Backport: Prepare" + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: false + type: string + pr: + description: 'PR to backport (e.g. #123) (if no issue)' + required: false + type: string + from_minor: + description: 'From minor version (e.g. 1.7) (if no issue)' + required: false + type: string + to_minor: + description: 'To minor version (e.g. 1.9) (if no issue, optional)' + required: false + type: string + workflow_call: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: false + type: string + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + backport_prepare: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run Backport Preparation Pipeline + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ inputs.issue }} + PR: ${{ inputs.pr }} + FROM_MINOR: ${{ inputs.from_minor }} + TO_MINOR: ${{ inputs.to_minor }} + run: | + ARGS=() + if [ -n "$ISSUE" ]; then + ARGS+=("--issue=$ISSUE") + else + if [ -n "$PR" ]; then + ARGS+=("--pr=$PR") + fi + if [ -n "$FROM_MINOR" ]; then + ARGS+=("--from-minor=$FROM_MINOR") + fi + if [ -n "$TO_MINOR" ]; then + ARGS+=("--to-minor=$TO_MINOR") + fi + fi + bazel run //tools/private/release -- \ + backport-prepare "${ARGS[@]}" --no-dry-run diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml index ed7813adac..7e4645f3fe 100644 --- a/.github/workflows/on_comment.yaml +++ b/.github/workflows/on_comment.yaml @@ -36,45 +36,67 @@ jobs: IS_PR: "${{ github.event.issue.pull_request != null }}" EVENT_NUMBER: "${{ github.event.issue.number }}" HAS_RELEASE_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: release') }}" + HAS_BACKPORT_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: backport-pr') }}" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ "$IS_PR" = "false" ] && [ "$HAS_RELEASE_LABEL" = "true" ]; then + if [ "$IS_PR" = "false" ]; then + # Set issue number for non-PR issues (release or backport tracking issues) issue_number=$EVENT_NUMBER - if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then - echo "command=create-rc" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then - echo "command=prepare" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then - echo "command=process-backports" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then - args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') - args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') - csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') - if [ -n "$csv" ]; then - echo "command=add-backports" >> "$GITHUB_OUTPUT" - echo "backports=$csv" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_ENV" + + # Check if it's a release tracking issue + if [ "$HAS_RELEASE_LABEL" = "true" ]; then + # Handle /create-rc comment + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then + echo "command=create-rc" >> "$GITHUB_OUTPUT" + # Handle /prepare comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then + echo "command=prepare" >> "$GITHUB_OUTPUT" + # Handle /process-backports comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then + echo "command=process-backports" >> "$GITHUB_OUTPUT" + # Handle /add-backports comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then + args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') + args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') + csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') + if [ -n "$csv" ]; then + echo "command=add-backports" >> "$GITHUB_OUTPUT" + echo "backports=$csv" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + echo "Error: No PRs specified for add-backports." >&2 + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -f "content=-1" + fi + # Handle /promote comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then + echo "command=promote" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + # Check if it's a backport tracking issue + elif [ "$HAS_BACKPORT_LABEL" = "true" ]; then + # Handle /prepare comment for backports + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then + echo "command=backport-prepare" >> "$GITHUB_OUTPUT" + # Handle /create-releases comment for backports + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-releases([[:space:]]|$)'; then + echo "command=backport-create-releases" >> "$GITHUB_OUTPUT" else echo "command=none" >> "$GITHUB_OUTPUT" - echo "Error: No PRs specified for add-backports." >&2 - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ - -f "content=-1" fi - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then - echo "command=promote" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" else echo "command=none" >> "$GITHUB_OUTPUT" fi elif [ "$IS_PR" = "true" ]; then pr_number=$EVENT_NUMBER + # Handle /backport comment on PR if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then echo "command=pr-backport" >> "$GITHUB_OUTPUT" echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" @@ -134,7 +156,23 @@ jobs: call_promote: needs: parse_comment if: needs.parse_comment.outputs.command == 'promote' - uses: ./.github/workflows/release_promote_rc.yaml + uses: ./.github/workflows/release_promote.yaml with: issue: ${{ needs.parse_comment.outputs.issue_number }} - secrets: inherit \ No newline at end of file + secrets: inherit + + call_backport_prepare: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'backport-prepare' + uses: ./.github/workflows/backport_prepare.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_backport_create_releases: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'backport-create-releases' + uses: ./.github/workflows/backport_create_releases.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote.yaml similarity index 94% rename from .github/workflows/release_promote_rc.yaml rename to .github/workflows/release_promote.yaml index 399e7b1fa2..93ec23a882 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote.yaml @@ -1,4 +1,4 @@ -name: "Release: Promote RC" +name: "Release: Promote" on: workflow_dispatch: @@ -60,7 +60,7 @@ jobs: ARGS+=("--remote=origin") ARGS+=("--no-dry-run") - bazel run //tools/private/release -- promote-rc "${ARGS[@]}" + bazel run //tools/private/release -- promote "${ARGS[@]}" publish: needs: promote diff --git a/RELEASING.md b/RELEASING.md index 2298b4c8a3..a03cd83652 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -123,7 +123,69 @@ If a backport fails to process (e.g., due to cherry-pick conflicts): cherry-pick the PR, resolve conflicts, push to remote, and manually check the box on the tracking issue checklist with `status=done` metadata. -## Patch release with cherry picks +## Automated patch releases to multiple versions + +If you need to backport a PR to multiple older active release branches (e.g., +backporting a fix to `1.7`, `1.8`, and `1.9` when the latest release is +`2.2.0`), you can automate the creation of release tracking issues and +verification of cherry-picks using a Backport Tracking Issue. + +### Steps + +1. **Create a Backport Tracking Issue**: Create a new issue on GitHub with + the label `type: backport-pr`. + * The title should describe the backport, e.g., `Backport: #1234` + (referencing the PR to backport). + * The body must contain the target range in the following format: + ```markdown + * PR: #1234 + * From version: 1.7 + * To version: 2.2 + ``` + This tells the tool to target all active release branches between + `release/1.7` and `release/2.2` inclusive. + +2. **Prepare the Backports**: Trigger the preparation by commenting `/prepare` + on the backport tracking issue, or by manually running the [Backport: + Prepare](https://github.com/bazel-contrib/rules_python/actions/workflows/backport_prepare.yaml) + workflow with the issue number. + * The workflow will checkout each release branch in the range, attempt to + cherry-pick the PR, and verify if the changelog can be updated. + * It will then update the backport tracking issue body with a list of + tasks: + * `Verify apply `: Automatically checked if the + cherry-pick succeeded, or left unchecked with + `status=failed-conflict` or `status=failed-changelog` if it failed. + * `Release issue `: A task to initiate the release for + each target version. + +3. **Resolve Conflicts (if any)**: If any `Verify apply` task failed: + * You must resolve the conflict manually on that specific release branch + (checkout branch, cherry-pick, resolve conflicts, push to remote). + * Once resolved, manually check the corresponding `Verify apply` task box + on the tracking issue and set its metadata to `status=success` (e.g., + `- [x] Verify apply 1.8 | status=success`). + +4. **Initiate Releases**: Once all `Verify apply` tasks are successful + (either automatically or after manual resolution), comment + `/create-releases` on the backport tracking issue, or manually run the + [Backport: Create + Releases](https://github.com/bazel-contrib/rules_python/actions/workflows/backport_create_releases.yaml) + workflow. + * This will automatically create a standard Release Tracking Issue for + each target version (e.g., `Release 1.7.1`, `Release 1.8.1`, etc.). + * For patch releases, the created release tracking issues will have `Tag + RC` tasks automatically removed, as release candidates are not + required for patch releases. + * The backport PR will be automatically added to the checklist of each + created release tracking issue. + +5. **Execute Releases**: Follow the standard release process for each created + release tracking issue. Since these are patch releases, you can skip the + `/create-rc` step and comment `/promote` directly to tag and publish the + release from the release branch head. + +## Manual patch release with cherry picks If a patch release from head would contain changes that aren't appropriate for a patch release, then the patch release needs to be based on the original diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 633cfa328f..d49d8fd221 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -4,6 +4,7 @@ py_library( name = "release_test_helper", srcs = ["release_test_helper.py"], deps = [ + "//tools/private/release:mock_gh", "//tools/private/release:release_lib", ], ) @@ -97,8 +98,8 @@ py_test( ) py_test( - name = "promote_rc_test", - srcs = ["promote_rc_test.py"], + name = "promote_test", + srcs = ["promote_test.py"], deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -130,3 +131,21 @@ py_test( "@dev_pip//packaging", ], ) + +py_test( + name = "backport_prepare_test", + srcs = ["backport_prepare_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "backport_create_releases_test", + srcs = ["backport_create_releases_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) diff --git a/tests/tools/private/release/backport_create_releases_test.py b/tests/tools/private/release/backport_create_releases_test.py new file mode 100644 index 0000000000..f1bdfe477f --- /dev/null +++ b/tests/tools/private/release/backport_create_releases_test.py @@ -0,0 +1,176 @@ +import argparse +import unittest + +from tests.tools.private.release.release_test_helper import ReleaseToolTestCase +from tools.private.release.backport_create_releases import BackportCreateReleases + + +class CmdBackportCreateReleasesTest(ReleaseToolTestCase): + def test_create_releases_all_success(self): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = self.gh + + # Setup backport issue in mock GH + backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [x] Verify apply 1.8 | status=success +- [x] Verify apply 1.9 | status=success +- [ ] Track Release 1.7.2 +- [ ] Track Release 1.8.1 +- [ ] Track Release 1.9.0""" + + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Act + result = BackportCreateReleases(args, gh).run() + + # Assert + self.assertEqual(result, 0) + + # Verify release issues created (IDs 1001, 1002, 1003) + self.assertIn(1001, gh.issues) + self.assertIn(1002, gh.issues) + self.assertIn(1003, gh.issues) + + # 1.7.2 (patch) should not have Tag RC0 + self.assertEqual(gh.issues[1001]["title"], "Release 1.7.2") + self.assertNotIn("Tag RC0", gh.issues[1001]["body"]) + self.assertIn("## Backports\n- [ ] #456", gh.issues[1001]["body"]) + + # 1.9.0 (minor) should have Tag RC0 + self.assertEqual(gh.issues[1003]["title"], "Release 1.9.0") + self.assertIn("Tag RC0", gh.issues[1003]["body"]) + + # Verify backport issue updated + expected_updated_backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [x] Verify apply 1.8 | status=success +- [x] Verify apply 1.9 | status=success +- [x] Track Release 1.7.2 | status=success release_issue=#1001 +- [x] Track Release 1.8.1 | status=success release_issue=#1002 +- [x] Track Release 1.9.0 | status=success release_issue=#1003""" + + self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + + def test_create_releases_dependency_blocking(self): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = self.gh + + # 1.8 failed, 1.7 and 1.9 succeeded + backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [ ] Verify apply 1.8 | status=failed-conflict +- [x] Verify apply 1.9 | status=success +- [ ] Track Release 1.7.2 +- [ ] Track Release 1.8.1 +- [ ] Track Release 1.9.0""" + + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Act + result = BackportCreateReleases(args, gh).run() + + # Assert + self.assertEqual(result, 0) + + # Only 1.9.0 (ID 1001) should be created. 1.7.2 and 1.8.1 are blocked by 1.8 failure. + self.assertEqual(len(gh.issues), 2) # Backport issue + 1 release issue + self.assertIn(1001, gh.issues) + self.assertEqual(gh.issues[1001]["title"], "Release 1.9.0") + + # Verify backport issue updated with block statuses + expected_updated_backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [ ] Verify apply 1.8 | status=failed-conflict +- [x] Verify apply 1.9 | status=success +- [ ] Track Release 1.7.2 | status=error-later-release-did-not-apply +- [ ] Track Release 1.8.1 | status=error-later-release-did-not-apply +- [x] Track Release 1.9.0 | status=success release_issue=#1001""" + + self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + + def test_create_releases_already_exists(self): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = self.gh + + backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.7 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [ ] Track Release 1.7.2""" + + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Pre-create the release issue to simulate it already existing + gh.create_issue( + title="Release 1.7.2", + body="existing body\n## Backports\n", + labels=["type: release"], + ) # Will get ID 1001 + + # Act + result = BackportCreateReleases(args, gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertEqual(len(gh.issues), 2) + + # Should link existing issue 1001 + expected_updated_backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.7 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [x] Track Release 1.7.2 | status=success release_issue=#1001""" + + self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/backport_prepare_test.py b/tests/tools/private/release/backport_prepare_test.py new file mode 100644 index 0000000000..26883b78b3 --- /dev/null +++ b/tests/tools/private/release/backport_prepare_test.py @@ -0,0 +1,247 @@ +import argparse +import unittest +from unittest.mock import call, patch + +from tests.tools.private.release.release_test_helper import ( + ReleaseToolTestCase, + _mock_git, +) +from tools.private.release.backport_prepare import BackportPrepare +from tools.private.release.gh import BACKPORT_LABEL + + +class CmdBackportPrepareTest(ReleaseToolTestCase): + def setUp(self): + super().setUp() + _mock_git(self) + # Mock changelog_news and determine_next_version + self.patcher_news = patch( + "tools.private.release.backport_prepare.changelog_news" + ) + self.mock_news = self.patcher_news.start() + + self.patcher_det = patch( + "tools.private.release.backport_prepare.determine_next_version" + ) + self.mock_det = self.patcher_det.start() + + self.addCleanup(self.patcher_news.stop) + self.addCleanup(self.patcher_det.stop) + + def test_prepare_from_issue_success(self): + # Arrange + args = argparse.Namespace( + issue=123, + pr=None, + from_minor=None, + to_minor=None, + remote="my-remote", + dry_run=False, + ) + + # Setup backport issue in mock GH + backport_body = "* PR: #456\n* From version: 1.7\n* To version: 1.9\n" + self.gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type: backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Setup PR info in mock GH + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + + # Mock remote branches + self.mock_git.get_remote_branches.return_value = [ + "main", + "release/1.6", + "release/1.7", + "release/1.8", + "release/1.9", + "release/2.0", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + + # Mock next versions + self.mock_det.side_effect = ["1.7.2", "1.8.1", "1.9.0"] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + + # Verify checkouts and cherry-picks + self.mock_git.checkout.assert_has_calls( + [ + call("release/1.7", track_remote="my-remote"), + call("release/1.8", track_remote="my-remote"), + call("release/1.9", track_remote="my-remote"), + call("work-branch"), # Restored branch + ] + ) + + self.mock_git.cherry_pick.assert_has_calls( + [ + call("pr_merge_sha_12345"), + call("pr_merge_sha_12345"), + call("pr_merge_sha_12345"), + ] + ) + + # Verify changelog updates + self.mock_news.update_changelog.assert_has_calls( + [ + call("1.7.2", unittest.mock.ANY), + call("1.8.1", unittest.mock.ANY), + call("1.9.0", unittest.mock.ANY), + ] + ) + + # Verify issue body update + expected_body = ( + "* PR: #456\n" + "* From version: 1.7\n" + "* To version: 1.9\n" + "\n" + "## Tasks\n" + "\n" + "- [x] Verify apply 1.7 | status=success\n" + "- [x] Verify apply 1.8 | status=success\n" + "- [x] Verify apply 1.9 | status=success\n" + "- [ ] Track Release 1.7.2\n" + "- [ ] Track Release 1.8.1\n" + "- [ ] Track Release 1.9.0" + ) + self.assertEqual(self.gh.issues[123]["body"], expected_body) + + def test_prepare_manual_success(self): + # Arrange + args = argparse.Namespace( + issue=None, + pr="#456", + from_minor="1.7", + to_minor="1.8", + remote="my-remote", + dry_run=False, + ) + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + self.mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + self.mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertIn(1001, self.gh.issues) + issue = self.gh.issues[1001] + self.assertEqual(issue["title"], "Backport: #456") + self.assertEqual(issue["labels"], [BACKPORT_LABEL]) + body = issue["body"] + self.assertIn("- [x] Verify apply 1.7 | status=success", body) + self.assertIn("- [x] Verify apply 1.8 | status=success", body) + self.assertIn("- [ ] Track Release 1.7.2", body) + self.assertIn("- [ ] Track Release 1.8.1", body) + + def test_prepare_manual_with_patch_versions(self): + # Arrange + args = argparse.Namespace( + issue=None, + pr="#456", + from_minor="1.7.0", + to_minor="1.8.0", + remote="my-remote", + dry_run=False, + ) + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + self.mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + self.mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertIn(1001, self.gh.issues) + body = self.gh.issues[1001]["body"] + self.assertIn("- [x] Verify apply 1.7 | status=success", body) + self.assertIn("- [x] Verify apply 1.8 | status=success", body) + + def test_prepare_verify_failed(self): + # Arrange + args = argparse.Namespace( + issue=123, + pr=None, + from_minor=None, + to_minor=None, + remote="my-remote", + dry_run=False, + ) + issue_body = "* PR: #456\n* From version: 1.7\n* To version: 1.8\n" + self.gh.issues[123] = { + "title": "Backport: #456", + "body": issue_body, + "labels": ["type: backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + self.mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + self.mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Mock cherry-pick failure on 1.7 and changelog failure on 1.8 + self.mock_git.cherry_pick.side_effect = [Exception("Conflict"), None] + self.mock_news.update_changelog.side_effect = [Exception("Changelog error")] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual( + result, 0 + ) # Returns 0 even if verification fails, it just updates tasks + + expected_body = ( + "* PR: #456\n" + "* From version: 1.7\n" + "* To version: 1.8\n" + "\n" + "## Tasks\n" + "\n" + "- [ ] Verify apply 1.7 | status=failed-conflict\n" + "- [ ] Verify apply 1.8 | status=failed-changelog\n" + "- [ ] Track Release 1.7.2\n" + "- [ ] Track Release 1.8.1" + ) + self.assertEqual(self.gh.issues[123]["body"], expected_body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_test.py similarity index 90% rename from tests/tools/private/release/promote_rc_test.py rename to tests/tools/private/release/promote_test.py index b5dff3e478..8d1d2cfb29 100644 --- a/tests/tools/private/release/promote_rc_test.py +++ b/tests/tools/private/release/promote_test.py @@ -7,10 +7,10 @@ from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.gh import NoTrackingIssueError -from tools.private.release.promote_rc import PromoteRc +from tools.private.release.promote import Promote -class CmdPromoteRcTest(unittest.TestCase): +class CmdPromoteTest(unittest.TestCase): def setUp(self): _mock_git_and_gh(self) self.test_dir = tempfile.TemporaryDirectory() @@ -28,7 +28,7 @@ def test_promote_rc_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -60,7 +60,7 @@ def test_promote_rc_success(self): "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) @@ -78,7 +78,7 @@ def test_promote_rc_writes_github_output(self): # Act with patch.dict("os.environ", {"GITHUB_OUTPUT": github_output_path}): - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -100,7 +100,7 @@ def test_promote_rc_resolve_issue_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -130,24 +130,22 @@ def test_promote_rc_resolve_issue_success(self): "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - def test_promote_rc_resolves_version_from_issue(self): + def test_promote_patch_success(self): # Arrange args = argparse.Namespace( - version=None, issue=123, dry_run=False, remote="my-remote" + version="2.0.1", issue=123, dry_run=False, remote="my-remote" ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.1" - self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"] - self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.tag_exists.return_value = False + self.mock_git.get_commit_sha.return_value = "12345678" initial_body = "- [ ] Tag Final" self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -159,12 +157,10 @@ def test_promote_rc_resolves_version_from_issue(self): ) self.mock_git.get_current_branch.assert_not_called() self.mock_git.get_tags.assert_not_called() - self.mock_git.get_remote_tags.assert_called_once_with("my-remote") + self.mock_git.get_remote_tags.assert_not_called() self.mock_git.checkout.assert_not_called() - self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.1-rc0"), call("my-remote/release/2.0")] - ) + self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") self.mock_git.push.assert_called_once_with("my-remote", "2.0.1") @@ -180,7 +176,7 @@ def test_promote_rc_resolves_version_from_issue(self): "- [Github Release 2.0.1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1)\n" "- [BCR Entry 2.0.1](https://registry.bazel.build/modules/rules_python/2.0.1)\n" "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) @@ -197,7 +193,7 @@ def test_promote_rc_dry_run_success(self, mock_print): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -242,7 +238,7 @@ def test_promote_rc_tag_already_exists(self): self.mock_git.tag_exists.return_value = True # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -264,7 +260,7 @@ def test_promote_rc_issue_not_found(self): ) # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -286,7 +282,7 @@ def test_promote_rc_issue_malformed(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -304,7 +300,7 @@ def test_promote_rc_no_rc_found(self): self.mock_git.get_remote_tags.return_value = [] # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index c7ba1b17b7..f0cdbdfe1d 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -7,9 +7,7 @@ class ReleaseCLITest(unittest.TestCase): def test_valid_version(self): # These should not raise an exception releaser.create_parser().parse_args(["prepare", "0.28.0"]) - releaser.create_parser().parse_args( - ["promote-rc", "1.0.0", "--remote", "origin"] - ) + releaser.create_parser().parse_args(["promote", "1.0.0", "--remote", "origin"]) releaser.create_parser().parse_args( ["create-release-issue", "--version", "1.2.3rc4"] ) diff --git a/tests/tools/private/release/release_test_helper.py b/tests/tools/private/release/release_test_helper.py index c35cd0fa40..12945193ef 100644 --- a/tests/tools/private/release/release_test_helper.py +++ b/tests/tools/private/release/release_test_helper.py @@ -9,30 +9,36 @@ MultipleTrackingIssuesError, NoTrackingIssueError, ) +from tools.private.release.mock_gh import MockGitHub -def _mock_git_and_gh(test_case): +def _mock_git(test_case): mock_git = MagicMock() - mock_gh = MagicMock() test_case.mock_git = mock_git - test_case.mock_gh = mock_gh # Mock Git inside utils.py since it instantiates it locally patch("tools.private.release.utils.Git", return_value=mock_git).start() - mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError - mock_gh.NoTrackingIssueError = NoTrackingIssueError - test_case.addCleanup(patch.stopall) # Apply safe defaults mock_git.get_current_branch.return_value = None mock_git.get_tags.return_value = [] mock_git.get_remote_tags.return_value = [] - mock_git.status.return_value = "" mock_git.branch_exists.return_value = False mock_git.tag_exists.return_value = False + return mock_git + + +def _mock_git_and_gh(test_case): + _mock_git(test_case) + mock_gh = MagicMock() + test_case.mock_gh = mock_gh + + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError + mock_gh.NoTrackingIssueError = NoTrackingIssueError + mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") mock_gh.get_open_pr.return_value = None @@ -44,3 +50,27 @@ def setUp(self): self.addCleanup(shutil.rmtree, self.tmpdir) os.chdir(self.tmpdir) self.addCleanup(os.chdir, self.original_cwd) + + +DEFAULT_RELEASE_TEMPLATE_CONTENT = ( + "template content\n" + "- [ ] Prepare Release\n" + "- [ ] Tag RC0\n" + "- [ ] Tag Final\n" + "\n" + "## Backports\n" +) + + +class ReleaseToolTestCase(TempDirTestCase): + def setUp(self): + super().setUp() + self.gh = MockGitHub() + self.setUpReleaseTemplate() + + def setUpReleaseTemplate(self): + template_dir = self.tmpdir / ".github/ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + self.template_file = template_dir / "release_tracking_template.md" + self.template_content = DEFAULT_RELEASE_TEMPLATE_CONTENT + self.template_file.write_text(self.template_content, encoding="utf-8") diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index a63d2111f7..5f80d1130a 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -11,7 +11,10 @@ py_library( name = "release_lib", srcs = glob( ["*.py"], - exclude = ["changelog_news.py"], + exclude = [ + "changelog_news.py", + "mock_gh.py", + ], ), deps = [ ":changelog_news", @@ -19,6 +22,14 @@ py_library( ], ) +py_library( + name = "mock_gh", + srcs = ["mock_gh.py"], + deps = [ + ":release_lib", + ], +) + py_binary( name = "release", srcs = ["release.py"], diff --git a/tools/private/release/backport_create_releases.py b/tools/private/release/backport_create_releases.py new file mode 100644 index 0000000000..662ef96eb1 --- /dev/null +++ b/tools/private/release/backport_create_releases.py @@ -0,0 +1,237 @@ +"""Subcommand to initiate releases for verified backports.""" + +import argparse +import pathlib +import re +from dataclasses import dataclass + +from tools.private.release.backport_prepare import parse_backport_metadata +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import ( + add_backports_to_body, + add_sync_changelog_task_to_body, + parse_metadata_line, + update_task_in_body, +) + + +@dataclass +class BackportTasksState: + # Map of minor version (e.g. "1.7") to verification status (e.g. "success", "failed-conflict") + verify: dict[str, str] + # Map of full version (e.g. "1.7.1") to tuple of (status, release_issue_number_or_None) + release: dict[str, tuple[str, str | None]] + + +def parse_backport_tasks(body) -> BackportTasksState: + """Parses tasks from backport issue body.""" + verify_tasks = {} + release_tasks = {} + + lines = body.splitlines() + for line in lines: + parsed = parse_metadata_line(line) + if not parsed: + continue + name = parsed["name"] + meta = parsed["metadata"] + + verify_match = re.match(r"Verify apply (\d+\.\d+)", name, re.IGNORECASE) + if verify_match: + minor = verify_match.group(1) + verify_tasks[minor] = meta.get("status", "pending") + continue + + release_match = re.match(r"Track Release (\d+\.\d+\.\d+)", name, re.IGNORECASE) + if release_match: + version = release_match.group(1) + status = meta.get("status", "pending") + issue_num = meta.get("release_issue") + release_tasks[version] = (status, issue_num) + + return BackportTasksState(verify=verify_tasks, release=release_tasks) + + +def is_release_eligible(version, target_minors, verify_statuses): + """Checks if a release version is eligible based on verification statuses.""" + version_minor = ".".join(version.split(".")[:2]) + v_minor_parsed = [int(x) for x in version_minor.split(".")] + + for minor in target_minors: + minor_parsed = [int(x) for x in minor.split(".")] + if minor_parsed >= v_minor_parsed: + status = verify_statuses.get(minor) + if status != "success": + return ( + False, + f"Blocked by verification failure on {minor} (status: {status})", + ) + + return True, "Eligible" + + +def _load_release_template() -> str: + """Loads the release tracking issue template.""" + template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md") + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + return template_path.read_text(encoding="utf-8") + + +class BackportCreateReleases: + """Class to initiate releases for verified backports.""" + + def __init__(self, args, gh: GitHub): + self._args = args + self._gh = gh + + def run(self) -> int: + """Executes the backport-create-releases subcommand.""" + args = self._args + issue_num = args.issue + if not issue_num: + raise ValueError("--issue is required.") + + print(f"Reading backport issue #{issue_num}...") + body = self._gh.get_issue_body(issue_num) + try: + metadata = parse_backport_metadata(body) + except ValueError as e: + e.add_note(f"Failed to parse backport metadata from issue #{issue_num}") + raise + pr_ref = metadata.pr + + pr_num = self._gh.resolve_pr_number(pr_ref) + + # Parse tasks + tasks_state = parse_backport_tasks(body) + verify_statuses = tasks_state.verify + release_tasks = tasks_state.release + + target_minors = sorted( + list(verify_statuses.keys()), key=lambda m: [int(x) for x in m.split(".")] + ) + + # We need the templates for release issues + template_content = _load_release_template() + + updated_body = body + changes_made = False + + for version, (status, release_issue) in release_tasks.items(): + # If status is success, we already created the issue. + # We check the actual checklist item status, but the helper returns it. + # The status in metadata is 'success' when done. + if status == "success": + print(f"Release for {version} already initiated: {release_issue}") + continue + + eligible, reason = is_release_eligible( + version, target_minors, verify_statuses + ) + + task_name = f"Track Release {version}" + + if eligible: + print(f"Initiating release for {version}...") + + # Check if release issue already exists + existing_issues = self._gh.get_open_tracking_issues(version) + if existing_issues: + new_issue_num = existing_issues[0]["number"] + print( + f"Release issue for {version} already exists: #{new_issue_num}. Reusing it." + ) + else: + # Create the issue + is_first_release = version.endswith(".0") + if is_first_release: + issue_template = template_content + else: + lines = template_content.splitlines() + lines = [ + line for line in lines if not re.search(r"Tag RC\d+", line) + ] + issue_template = "\n".join(lines) + if template_content.endswith("\n"): + issue_template += "\n" + + if args.dry_run: + print( + f"[DRY RUN] Would create release tracking issue for {version} (without RC tasks)" + ) + new_issue_num = "" + else: + new_issue_num = self._gh.create_tracking_issue( + version, issue_template + ) + print( + f"Created release tracking issue #{new_issue_num} for {version}" + ) + + # Add the backport PR to the new release issue + print( + f"Adding PR #{pr_num} to release issue #{new_issue_num} checklist..." + ) + rel_body = self._gh.get_issue_body(new_issue_num) + rel_body = add_backports_to_body( + rel_body, [{"ref": f"#{pr_num}"}] + ) + rel_body = add_sync_changelog_task_to_body(rel_body, pr_num) + self._gh.update_issue_body(new_issue_num, rel_body) + + # Update task in backport issue + metadata = {"status": "success", "release_issue": f"#{new_issue_num}"} + updated_body = update_task_in_body( + updated_body, task_name, checked=True, metadata=metadata + ) + changes_made = True + else: + print(f"Release for {version} is not eligible: {reason}") + metadata = {"status": "error-later-release-did-not-apply"} + + if status != "error-later-release-did-not-apply": + updated_body = update_task_in_body( + updated_body, task_name, checked=False, metadata=metadata + ) + changes_made = True + + if changes_made: + if args.dry_run: + print( + f"[DRY RUN] Would update backport issue #{issue_num} body:\n{updated_body}" + ) + else: + self._gh.update_issue_body(issue_num, updated_body) + print(f"Updated backport issue #{issue_num}") + else: + print("No changes needed for backport issue.") + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for backport-create-releases subcommand.""" + parser = subparsers.add_parser( + "backport-create-releases", + help="Initiate releases for verified backports.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The backport tracking issue number (required).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/backport_prepare.py b/tools/private/release/backport_prepare.py new file mode 100644 index 0000000000..459e91f703 --- /dev/null +++ b/tools/private/release/backport_prepare.py @@ -0,0 +1,305 @@ +"""Subcommand to prepare backport tracking issue and verify cherry-picks.""" + +import argparse +import datetime +import re +from dataclasses import dataclass + +from tools.private.release import changelog_news +from tools.private.release.gh import BACKPORT_LABEL, GitHub +from tools.private.release.git import Git +from tools.private.release.utils import determine_next_version + + +@dataclass +class BackportMetadata: + pr: str + from_minor: str + to_minor: str + + +def parse_backport_metadata(body) -> BackportMetadata: + """Parses backport metadata from issue body.""" + pr_match = re.search(r"^\s*\*\s*PR:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE) + from_match = re.search( + r"^\s*\*\s*From version:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE + ) + to_match = re.search( + r"^\s*\*\s*To version:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE + ) + + if not pr_match or not from_match or not to_match: + raise ValueError( + "Missing metadata in issue body. Need PR, From version, and To version." + ) + + return BackportMetadata( + pr=pr_match.group(1), + from_minor=from_match.group(1), + to_minor=to_match.group(1), + ) + + +def get_target_branches(git, remote, from_minor, to_minor): + """Identifies target release branches in the given range.""" + branches = git.get_remote_branches(remote) + target_branches = [] + + # Parse version strings to compare (only major and minor) + from_v = [int(x) for x in from_minor.split(".")][:2] + to_v = [int(x) for x in to_minor.split(".")][:2] + + for branch in branches: + match = re.match(r"^release/(\d+)\.(\d+)$", branch) + if match: + major = int(match.group(1)) + minor = int(match.group(2)) + v = [major, minor] + if v >= from_v and v <= to_v: + target_branches.append(branch) + + # Sort branches by version + target_branches.sort(key=lambda b: [int(x) for x in b.split("/")[1].split(".")]) + return target_branches + + +def get_latest_release_branch(git, remote): + """Determines the latest release branch.""" + branches = git.get_remote_branches(remote) + release_branches = [] + for branch in branches: + match = re.match(r"^release/(\d+)\.(\d+)$", branch) + if match: + release_branches.append(branch) + if not release_branches: + return None + release_branches.sort(key=lambda b: [int(x) for x in b.split("/")[1].split(".")]) + return release_branches[-1] + + +class BackportPrepare: + """Class to prepare backport tracking issue and verify cherry-picks.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the backport-prepare subcommand.""" + args = self.args + print("Fetching remote to verify release branches...") + self.git.fetch(args.remote, tags=True, force=True) + + issue_num = args.issue + + if issue_num: + # Triggered by comment, read from issue + print(f"Reading metadata from issue #{issue_num}...") + body = self.gh.get_issue_body(issue_num) + try: + metadata = parse_backport_metadata(body) + except ValueError as e: + e.add_note(f"Failed to parse backport metadata from issue #{issue_num}") + raise + pr_ref = metadata.pr + from_minor = metadata.from_minor + to_minor = metadata.to_minor + else: + # Triggered manually, use args + pr_ref = args.pr + from_minor = args.from_minor + to_minor = args.to_minor + + if not pr_ref or not from_minor: + raise ValueError( + "PR and From version are required if not running from an issue." + ) + + if not to_minor: + latest_branch = get_latest_release_branch(self.git, args.remote) + if not latest_branch: + raise RuntimeError( + "Could not determine latest release branch and --to-minor was not provided." + ) + to_minor = latest_branch.split("/")[1] + print(f"Auto-determined latest minor version: {to_minor}") + + # Resolve PR to merge commit + try: + pr_num = self.gh.resolve_pr_number(pr_ref) + print(f"Resolving PR #{pr_num} info...") + pr_info = self.gh.get_pr_info(pr_num) + except Exception as e: + e.add_note(f"Failed to resolve PR info for {pr_ref}") + raise + + if pr_info.get("state") != "MERGED": + raise ValueError( + f"PR #{pr_num} is not merged (state: {pr_info.get('state')})." + ) + merge_commit = pr_info.get("mergeCommit") + if not merge_commit or "oid" not in merge_commit: + raise ValueError(f"PR #{pr_num} has no merge commit SHA.") + pr_sha = merge_commit["oid"] + print(f"Resolved PR #{pr_num} to merge commit {pr_sha[:8]}") + + target_branches = get_target_branches( + self.git, args.remote, from_minor, to_minor + ) + if not target_branches: + raise ValueError( + f"No release branches found in range {from_minor} to {to_minor}" + ) + + print(f"Identified target branches: {target_branches}") + + # Verify workspace is clean + if self.git.status(): + raise RuntimeError("Workspace is dirty. Aborting.") + + current_branch = self.git.get_current_branch() + verify_results = {} # branch -> (success, reason) + version_map = {} # branch -> next_version + + try: + for branch in target_branches: + minor_ver = branch.split("/")[1] + print(f"Verifying application on {branch}...") + self.git.checkout(branch, track_remote=args.remote) + + # Determine next version + next_version = determine_next_version(branch) + version_map[branch] = next_version + + # Verify apply (cherry-pick + news) + try: + self.git.cherry_pick(pr_sha) + + # Try news update + try: + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(next_version, release_date) + verify_results[branch] = (True, "Success") + print(f"Verification successful for {branch}") + except Exception as e: + verify_results[branch] = ( + False, + f"Changelog update failed: {e}", + ) + print( + f"Verification failed for {branch} (changelog update): {e}" + ) + except Exception as e: + verify_results[branch] = (False, f"Cherry-pick failed: {e}") + print(f"Verification failed for {branch} (cherry-pick): {e}") + finally: + # Always abort/reset + try: + self.git.cherry_pick_abort() + except Exception: + pass + self.git.reset_hard(reset_to=f"{args.remote}/{branch}") + finally: + # Restore original branch + if current_branch: + self.git.checkout(current_branch) + + # Generate issue content + body_lines = [ + f"* PR: #{pr_num}", + f"* From version: {from_minor}", + f"* To version: {to_minor}", + "", + "## Tasks", + "", + ] + + # Add Verify tasks + for branch in target_branches: + minor_ver = branch.split("/")[1] + success, reason = verify_results[branch] + if success: + body_lines.append(f"- [x] Verify apply {minor_ver} | status=success") + else: + status = "failed-conflict" + if "Changelog" in reason: + status = "failed-changelog" + body_lines.append(f"- [ ] Verify apply {minor_ver} | status={status}") + + # Add Release tasks + for branch in target_branches: + next_version = version_map[branch] + body_lines.append(f"- [ ] Track Release {next_version}") + + new_body = "\n".join(body_lines) + + if issue_num: + if args.dry_run: + print( + f"[DRY RUN] Would update issue #{issue_num} with body:\n{new_body}" + ) + else: + self.gh.update_issue_body(issue_num, new_body) + print(f"Successfully updated issue #{issue_num}") + else: + title = f"Backport: #{pr_num}" + if args.dry_run: + print( + f"[DRY RUN] Would create issue with title '{title}' and body:\n{new_body}" + ) + else: + new_issue_num = self.gh.create_issue( + title, new_body, labels=[BACKPORT_LABEL] + ) + print(f"Created backport tracking issue #{new_issue_num}") + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for backport-prepare subcommand.""" + parser = subparsers.add_parser( + "backport-prepare", + help="Prepare backport tracking issue and verify cherry-picks.", + ) + parser.add_argument( + "--issue", + type=int, + help="The backport tracking issue number (if running from an existing issue).", + ) + parser.add_argument( + "--pr", + type=str, + help="PR reference to backport (required if not running from issue).", + ) + parser.add_argument( + "--from-minor", + type=str, + help="Oldest minor version to target (inclusive) (e.g. 1.7) (required if not running from issue).", + ) + parser.add_argument( + "--to-minor", + type=str, + help="Newest minor version to target (inclusive) (optional, defaults to latest).", + ) + parser.add_argument( + "--remote", + type=str, + default="origin", + help="The git remote (default: origin).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py index 2d00d7369a..1c28e86c7d 100644 --- a/tools/private/release/create_release_issue.py +++ b/tools/private/release/create_release_issue.py @@ -1,6 +1,7 @@ """Subcommand to create a release tracking issue.""" import pathlib +import re from tools.private.release.gh import GitHub from tools.private.release.utils import determine_next_version, semver_type @@ -34,6 +35,13 @@ def run(self) -> int: raise FileNotFoundError(f"Template file not found at {template_path}") template_content = template_path.read_text(encoding="utf-8") + is_first_release = version.endswith(".0") + if not is_first_release: + # Patch release: remove RC tasks + lines = template_content.splitlines() + lines = [line for line in lines if not re.search(r"Tag RC\d+", line)] + template_content = "\n".join(lines) + issue_num = self.gh.create_tracking_issue(version, template_content) print(f"Created tracking issue #{issue_num} for v{version}") return 0 diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 92cc3ca5c4..27206f1ffe 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -8,6 +8,10 @@ from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd +# GitHub label types +RELEASE_LABEL = "type: release" +BACKPORT_LABEL = "type: backport-pr" + # GitHub reaction types # See: https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions GH_REACTION_THUMBS_UP = "+1" @@ -42,7 +46,6 @@ def __init__(self, repo: str = "bazel-contrib/rules_python"): repo: The GitHub repository to operate on. """ self.repo = repo - self.label = "type: release" def _run_gh( self, *args: str, check: bool = True, capture_output: bool = True @@ -125,7 +128,7 @@ def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: """ search = f'"Release {version}" in:title' if version else None return self.list_issues( - label=self.label, + label=RELEASE_LABEL, state="open", search=search, fields="number,title,url", @@ -158,13 +161,13 @@ def get_release_tracking_issue(self, version: str) -> int: if not exact_matches: raise NoTrackingIssueError( f"No open tracking issue found matching 'Release {version}' " - f"in repo {self.repo} with label '{self.label}'" + f"in repo {self.repo} with label '{RELEASE_LABEL}'" ) if len(exact_matches) > 1: urls = [issue["url"] for issue in exact_matches] raise MultipleTrackingIssuesError( f"Multiple open tracking issues found for version {version} " - f"in repo {self.repo} with label '{self.label}':\n" + "\n".join(urls) + f"in repo {self.repo} with label '{RELEASE_LABEL}':\n" + "\n".join(urls) ) return exact_matches[0]["number"] @@ -188,16 +191,15 @@ def create_tracking_issue(self, version: str, template_content: str) -> int: if len(parts) >= 3: issue_body = parts[2].strip() - # Write body to a secure temporary file to pass to the CLI - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".md") as f: f.write(issue_body) + f.flush() temp_path = f.name - try: output = self._gh_issue( "create", f"--title=Release {version}", - f"--label={self.label}", + f"--label={RELEASE_LABEL}", f"--body-file={temp_path}", ) if not output: @@ -205,9 +207,40 @@ def create_tracking_issue(self, version: str, template_content: str) -> int: issue_url = output.strip() issue_num = int(issue_url.split("/")[-1]) return issue_num - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) + + def create_issue( + self, title: str, body: str, labels: list[str] | None = None + ) -> int: + """Creates a generic issue. + + Args: + title: The title of the issue. + body: The body of the issue. + labels: Optional list of labels to add. + + Returns: + The created issue number. + """ + with tempfile.NamedTemporaryFile(mode="w", suffix=".md") as f: + f.write(body) + f.flush() + temp_path = f.name + + cmd = [ + "create", + f"--title={title}", + f"--body-file={temp_path}", + ] + if labels: + for label in labels: + cmd.append(f"--label={label}") + + output = self._gh_issue(*cmd) + if not output: + raise RuntimeError("Failed to get issue URL from gh issue create") + issue_url = output.strip() + issue_num = int(issue_url.split("/")[-1]) + return issue_num def get_issue_body(self, issue_num: int) -> str: """Fetches the body of a specific issue. diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 4305de855f..3e7e3cb893 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -397,3 +397,25 @@ def apply_check(self, patch_file: str) -> bool: return True except subprocess.CalledProcessError: return False + + def get_remote_branches(self, remote: str = "origin") -> list[str]: + """Returns a list of remote branches. + + Args: + remote: The name of the remote. + + Returns: + A list of branch names (without the remote prefix). + """ + output = self._run_git("branch", "-r") + branches = [] + if not output: + return branches + for line in output.splitlines(): + line = line.strip() + if "->" in line: + continue + parts = line.split("/") + if len(parts) >= 2 and parts[0] == remote: + branches.append("/".join(parts[1:])) + return branches diff --git a/tools/private/release/mock_gh.py b/tools/private/release/mock_gh.py new file mode 100644 index 0000000000..3a5a8dde48 --- /dev/null +++ b/tools/private/release/mock_gh.py @@ -0,0 +1,84 @@ +"""In-memory fake for GitHub API.""" + +import re + +from tools.private.release.gh import RELEASE_LABEL + + +class MockGitHub: + def __init__(self, repo: str = "bazel-contrib/rules_python"): + self.repo = repo + self.issues = {} + self.next_issue_num = 1001 + self.prs = {} # num -> pr_info + + def create_issue( + self, title: str, body: str, labels: list[str] | None = None + ) -> int: + issue_num = self.next_issue_num + self.next_issue_num += 1 + self.issues[issue_num] = { + "title": title, + "body": body, + "labels": labels or [], + "number": issue_num, + "url": f"https://github.com/{self.repo}/issues/{issue_num}", + } + return issue_num + + def create_tracking_issue(self, version: str, template_content: str) -> int: + # Strip YAML frontmatter if present (simplified copy from gh.py) + issue_body = template_content + if template_content.startswith("---"): + parts = template_content.split("---", 2) + if len(parts) >= 3: + issue_body = parts[2].strip() + + return self.create_issue( + title=f"Release {version}", body=issue_body, labels=[RELEASE_LABEL] + ) + + def get_issue_body(self, issue_num: int) -> str: + if issue_num not in self.issues: + raise ValueError(f"Issue #{issue_num} not found in MockGitHub") + return self.issues[issue_num]["body"] + + def update_issue_body(self, issue_num: int, body: str): + if issue_num not in self.issues: + raise ValueError(f"Issue #{issue_num} not found in MockGitHub") + self.issues[issue_num]["body"] = body + + def resolve_pr_number(self, pr_ref: str) -> int: + # Real algorithm copy (doesn't require RPCs) + clean_ref = pr_ref.lstrip("#") + if clean_ref.isdigit(): + return int(clean_ref) + + if pr_ref.startswith("http"): + pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)" + match = re.search(pattern, pr_ref, re.IGNORECASE) + if match: + return int(match.group(1)) + raise ValueError( + f"URL is not for the configured repository ({self.repo}): {pr_ref}" + ) + raise ValueError(f"Could not resolve PR ref: {pr_ref}") + + def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: + results = [] + for issue in self.issues.values(): + if RELEASE_LABEL in issue["labels"]: + if version: + if issue["title"] == f"Release {version}": + results.append(issue) + else: + results.append(issue) + return results + + def get_pr_info(self, pr_num: int) -> dict: + if pr_num in self.prs: + return self.prs[pr_num] + return { + "state": "MERGED", + "mergeCommit": {"oid": f"mock_merge_sha_{pr_num}"}, + } diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote.py similarity index 75% rename from tools/private/release/promote_rc.py rename to tools/private/release/promote.py index 34c8107af4..66d5b74324 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote.py @@ -18,7 +18,7 @@ ) -class PromoteRc: +class Promote: """Class to promote a release candidate to final release.""" def __init__(self, args, git: Git, gh: GitHub): @@ -52,16 +52,13 @@ def run(self) -> int: else: version = determine_next_version() - latest_rc = get_latest_rc_tag(version, remote=args.remote) - if not latest_rc: - print(f"Error: No release candidate tags found matching {version}-rc*") - return 1 - # Verify final tag doesn't already exist if self.git.tag_exists(version): print(f"Error: Final tag {version} already exists.") return 1 + is_first_release = version.endswith(".0") + # Verify issue can be found issue_num = args.issue if not issue_num: @@ -74,14 +71,20 @@ def run(self) -> int: print(f"Error: Unexpected error finding tracking issue: {e}") return 1 - # Get commit SHA of the RC tag (which will be the same for the final tag) - commit_sha = self.git.get_commit_sha(latest_rc) + if is_first_release: + latest_rc = get_latest_rc_tag(version, remote=args.remote) + if not latest_rc: + print(f"Error: No release candidate tags found matching {version}-rc*") + return 1 + commit_sha = self.git.get_commit_sha(latest_rc) + else: + latest_rc = None # Verify issue can be found and read it early print(f"Verifying tracking issue #{issue_num} format...") body = self.gh.get_issue_body(issue_num) - # Determine release branch name and verify it matches the RC tag commit + # Determine release branch name branch_version = ".".join(version.split(".")[:2]) branch_name = f"release/{branch_version}" remote_branch = f"{args.remote}/{branch_name}" @@ -97,34 +100,39 @@ def run(self) -> int: ) return 1 - if commit_sha != branch_sha: - print( - f"Error: The latest RC tag {latest_rc} ({commit_sha[:8]}) is not at" - f" the head of release branch {remote_branch} ({branch_sha[:8]})." - ) - metadata = { - "status": "error-rc-tag-not-branch-head", - "rc": latest_rc, - "branch_commit": branch_sha[:8], - "tag_commit": commit_sha[:8], - } - try: - updated_body = update_task_in_body( - body, "Tag Final", checked=False, metadata=metadata - ) - except ValueError as e: - print(f"Error: Tracking issue #{issue_num} is malformed: {e}") - return 1 - - if not args.dry_run: - self.gh.update_issue_body(issue_num, updated_body) - print(f"Updated tracking issue #{issue_num} with error status.") - else: + if is_first_release: + if commit_sha != branch_sha: print( - f"[DRY RUN] Would update tracking issue #{issue_num} with" - f" error status." + f"Error: The latest RC tag {latest_rc} ({commit_sha[:8]}) is not at" + f" the head of release branch {remote_branch} ({branch_sha[:8]})." ) - return 1 + metadata = { + "status": "error-rc-tag-not-branch-head", + "rc": latest_rc, + "branch_commit": branch_sha[:8], + "tag_commit": commit_sha[:8], + } + try: + updated_body = update_task_in_body( + body, "Tag Final", checked=False, metadata=metadata + ) + except ValueError as e: + print(f"Error: Tracking issue #{issue_num} is malformed: {e}") + return 1 + + if not args.dry_run: + self.gh.update_issue_body(issue_num, updated_body) + print(f"Updated tracking issue #{issue_num} with error status.") + else: + print( + f"[DRY RUN] Would update tracking issue #{issue_num} with" + f" error status." + ) + return 1 + else: + # Patch release: tag branch head directly + commit_sha = branch_sha + latest_rc = None # Verify issue is in the right format by trying to prepare the update (for success case) metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} @@ -137,10 +145,15 @@ def run(self) -> int: return 1 # All pre-conditions met, perform modifications + promote_source = ( + latest_rc + if is_first_release + else f"head of {branch_name} ({commit_sha[:8]})" + ) if args.dry_run: print( f"[DRY RUN] Pre-conditions passed successfully for promoting" - f" {latest_rc} to {version}." + f" {promote_source} to {version}." ) print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") print(f"[DRY RUN] Would push tag {version} to {args.remote}") @@ -149,7 +162,7 @@ def run(self) -> int: return 0 print( - f"Promoting {latest_rc} to final release {version} (commit" + f"Promoting {promote_source} to final release {version} (commit" f" {commit_sha[:8]}) using tracking issue #{issue_num}..." ) @@ -177,9 +190,7 @@ def run(self) -> int: if run_id := os.environ.get("GITHUB_RUN_ID"): release_workflow_url = f"{REPO_URL}/actions/runs/{run_id}" else: - release_workflow_url = ( - f"{REPO_URL}/actions/workflows/release_promote_rc.yaml" - ) + release_workflow_url = f"{REPO_URL}/actions/workflows/release_promote.yaml" comment_body = f"""**New Release Tagged!** 🐍🌿 @@ -197,7 +208,7 @@ def run(self) -> int: def add_parser(cls, subparsers): """Adds parser for promote-rc subcommand.""" parser = subparsers.add_parser( - "promote-rc", + "promote", help="Promote the latest RC to final release.", ) parser.add_argument( diff --git a/tools/private/release/release.py b/tools/private/release/release.py index f008c093b1..72c427a392 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -5,6 +5,8 @@ import sys from tools.private.release.add_backports import AddBackports +from tools.private.release.backport_create_releases import BackportCreateReleases +from tools.private.release.backport_prepare import BackportPrepare from tools.private.release.complete_prepare import CompletePrepare from tools.private.release.complete_sync_changelog import CompleteSyncChangelog from tools.private.release.create_rc import CreateRc @@ -14,7 +16,7 @@ from tools.private.release.on_pr_merged import OnPrMerged from tools.private.release.prepare import Prepare from tools.private.release.process_backports import ProcessBackports -from tools.private.release.promote_rc import PromoteRc +from tools.private.release.promote import Promote cmds = [ DetermineNextVersion, @@ -27,7 +29,9 @@ ProcessBackports, OnPrMerged, CreateRc, - PromoteRc, + Promote, + BackportPrepare, + BackportCreateReleases, ] From 5fd7d8f17773c906b62fa6027823cb265eff6c25 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 18 Jul 2026 10:25:00 -0700 Subject: [PATCH 089/179] tests: implement pytest_test rule for internal test usage (#3931) This implements a pytest_test rule based upon the pytest_bazel library. The core implementation of this is fairly simple: a file is generated that calls `pytest.main()` with the `srcs` to test. This generated file is the file that is run by `py_test`. For now, this is kept under tests/support soas to use it for our own tests while some design decisions are figured out. Work towards https://github.com/bazel-contrib/rules_python/issues/3594 --- .bazelversion | 2 +- AGENTS.md | 1 - BUILD.bazel | 1 + MODULE.bazel | 2 +- docs/BUILD.bazel | 1 + docs/pyproject.toml | 2 + docs/requirements.txt | 278 +++++++++--------- docs/uv.lock | 112 +++++++ tests/pytest_test/BUILD.bazel | 22 ++ tests/pytest_test/basic_test.py | 2 + tests/support/pytest_test/BUILD.bazel | 30 ++ .../pytest_test/pytest_bootstrap_template.py | 8 + tests/support/pytest_test/pytest_test.bzl | 74 +++++ 13 files changed, 394 insertions(+), 141 deletions(-) create mode 100644 tests/pytest_test/BUILD.bazel create mode 100644 tests/pytest_test/basic_test.py create mode 100644 tests/support/pytest_test/BUILD.bazel create mode 100644 tests/support/pytest_test/pytest_bootstrap_template.py create mode 100644 tests/support/pytest_test/pytest_test.bzl diff --git a/.bazelversion b/.bazelversion index 512e4c889e..44931da266 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -9.x +9.1.1 diff --git a/AGENTS.md b/AGENTS.md index e6e1733c1d..6f6e6fe76a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,6 @@ The `repository_ctx` API docs are at: https://bazel.build/rules/lib/builtins/rep e.g. given `load("//foo:bar.bzl", ...)`, the target is `//foo:bar_bzl`. * For files outside rules_python: remove the `.bzl` suffix. e.g. given `load("@foo//foo:bar.bzl", ...)`, the target is `@foo//foo:bar`. -* `bzl_library()` targets should be kept in alphabetical order by name. Example: diff --git a/BUILD.bazel b/BUILD.bazel index a25863708c..f978126da7 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -38,6 +38,7 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") # Exclude directories that are separate packages/workspaces or only for testing/examples # gazelle:exclude tests # gazelle:exclude examples +# gazelle:exclude gazelle # Exclude internal development tools and dependencies that users don't need # gazelle:exclude internal_dev_setup.bzl diff --git a/MODULE.bazel b/MODULE.bazel index c4afa60640..e4c4e08e89 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -214,7 +214,7 @@ dev_pip.parse( python_version = "3.11", requirements_lock = "//tests/multi_pypi/beta:requirements.txt", ) -use_repo(dev_pip, "dev_pip", "pypi_alpha", "pypi_beta", "pypiserver") +use_repo(dev_pip, "dev_pip", "pypi", "pypi_alpha", "pypi_beta", "pypiserver") # Bazel integration test setup below diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 2e13da1296..9a9327aadf 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -198,6 +198,7 @@ sphinx_build_binary( target_compatible_with = _TARGET_COMPATIBLE_WITH, deps = [ "@dev_pip//myst_parser", + "@dev_pip//pytest", "@dev_pip//readthedocs_sphinx_ext", "@dev_pip//sphinx", "@dev_pip//sphinx_autodoc2", diff --git a/docs/pyproject.toml b/docs/pyproject.toml index f0ca3928ff..ea8a544241 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -17,4 +17,6 @@ dependencies = [ "pyelftools", "macholib", "markupsafe", + "pytest", + "pytest-bazel", ] diff --git a/docs/requirements.txt b/docs/requirements.txt index 24782bac8f..a0224b9283 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,9 +6,9 @@ absl-py==2.3.1 ; python_full_version < '3.10' \ --hash=sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9 \ --hash=sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d # via rules-python-docs (docs/pyproject.toml) -absl-py==2.4.0 ; python_full_version >= '3.10' \ - --hash=sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d \ - --hash=sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4 +absl-py==2.5.0 ; python_full_version >= '3.10' \ + --hash=sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f \ + --hash=sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba # via rules-python-docs (docs/pyproject.toml) alabaster==0.7.16 ; python_full_version < '3.10' \ --hash=sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65 \ @@ -34,141 +34,107 @@ certifi==2026.6.17 \ --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db # via requests -charset-normalizer==3.4.7 \ - --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ - --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ - --hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \ - --hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \ - --hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \ - --hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \ - --hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \ - --hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \ - --hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \ - --hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \ - --hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \ - --hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \ - --hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \ - --hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \ - --hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \ - --hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \ - --hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \ - --hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \ - --hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \ - --hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \ - --hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \ - --hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \ - --hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \ - --hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \ - --hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \ - --hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \ - --hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \ - --hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \ - --hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \ - --hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \ - --hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \ - --hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \ - --hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \ - --hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \ - --hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \ - --hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \ - --hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \ - --hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \ - --hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \ - --hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \ - --hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \ - --hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \ - --hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \ - --hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \ - --hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \ - --hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \ - --hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \ - --hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \ - --hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \ - --hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \ - --hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \ - --hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \ - --hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \ - --hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \ - --hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \ - --hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \ - --hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \ - --hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \ - --hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \ - --hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \ - --hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \ - --hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \ - --hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \ - --hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \ - --hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \ - --hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \ - --hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \ - --hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \ - --hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \ - --hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \ - --hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \ - --hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \ - --hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \ - --hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \ - --hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \ - --hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \ - --hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \ - --hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \ - --hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \ - --hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \ - --hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \ - --hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \ - --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \ - --hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \ - --hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \ - --hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \ - --hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \ - --hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \ - --hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \ - --hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \ - --hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \ - --hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \ - --hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \ - --hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \ - --hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \ - --hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \ - --hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \ - --hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \ - --hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \ - --hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \ - --hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \ - --hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \ - --hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \ - --hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \ - --hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \ - --hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \ - --hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \ - --hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \ - --hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \ - --hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \ - --hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \ - --hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \ - --hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \ - --hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \ - --hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \ - --hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \ - --hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \ - --hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \ - --hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \ - --hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \ - --hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \ - --hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \ - --hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \ - --hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \ - --hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \ - --hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \ - --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ - --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ - --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests colorama==0.4.6 ; sys_platform == 'win32' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 - # via sphinx + # via + # pytest + # sphinx docutils==0.21.2 ; python_full_version < '3.11' \ --hash=sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f \ --hash=sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2 @@ -183,6 +149,10 @@ docutils==0.22.4 ; python_full_version >= '3.11' \ # myst-parser # sphinx # sphinx-rtd-theme +exceptiongroup==1.3.1 ; python_full_version < '3.11' \ + --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 + # via pytest idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 @@ -199,6 +169,14 @@ importlib-metadata==8.7.1 ; python_full_version < '3.10' \ --hash=sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb \ --hash=sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151 # via sphinx +iniconfig==2.1.0 ; python_full_version < '3.10' \ + --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ + --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + # via pytest +iniconfig==2.3.0 ; python_full_version >= '3.10' \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest jinja2==3.1.6 \ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 @@ -343,12 +321,17 @@ packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via + # pytest # readthedocs-sphinx-ext # sphinx pefile==2024.8.26 \ --hash=sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632 \ --hash=sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f # via rules-python-docs (docs/pyproject.toml) +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest pyelftools==0.32 ; python_full_version < '3.10' \ --hash=sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738 \ --hash=sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5 @@ -360,7 +343,24 @@ pyelftools==0.33 ; python_full_version >= '3.10' \ pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - # via sphinx + # via + # pytest + # sphinx +pytest==8.4.2 ; python_full_version < '3.10' \ + --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \ + --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79 + # via + # rules-python-docs (docs/pyproject.toml) + # pytest-bazel +pytest==9.1.1 ; python_full_version >= '3.10' \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via + # rules-python-docs (docs/pyproject.toml) + # pytest-bazel +pytest-bazel==0.1.6 \ + --hash=sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21 + # via rules-python-docs (docs/pyproject.toml) pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ @@ -589,14 +589,16 @@ tomli==2.4.1 ; python_full_version < '3.11' \ --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 # via + # pytest # sphinx # sphinx-autodoc2 -typing-extensions==4.15.0 \ - --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ - --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via # rules-python-docs (docs/pyproject.toml) # astroid + # exceptiongroup # sphinx-autodoc2 urllib3==2.6.3 ; python_full_version < '3.10' \ --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ diff --git a/docs/uv.lock b/docs/uv.lock index 72e8f307a9..c86b4cd18e 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -263,6 +263,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -312,6 +324,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -592,6 +632,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pyelftools" version = "0.32" @@ -610,6 +659,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-bazel" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/90/4726a39728fb5a5f77a773c5e9d7bfd3a0120aa2029963a88fd06e2c5fb2/pytest_bazel-0.1.6-py3-none-any.whl", hash = "sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21", size = 10126, upload-time = "2025-10-31T08:41:17.165Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -761,6 +868,9 @@ dependencies = [ { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pefile" }, { name = "pyelftools" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-bazel" }, { name = "readthedocs-sphinx-ext" }, { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, @@ -781,6 +891,8 @@ requires-dist = [ { name = "myst-parser" }, { name = "pefile" }, { name = "pyelftools" }, + { name = "pytest" }, + { name = "pytest-bazel" }, { name = "readthedocs-sphinx-ext" }, { name = "sphinx" }, { name = "sphinx-autodoc2" }, diff --git a/tests/pytest_test/BUILD.bazel b/tests/pytest_test/BUILD.bazel new file mode 100644 index 0000000000..b15094a615 --- /dev/null +++ b/tests/pytest_test/BUILD.bazel @@ -0,0 +1,22 @@ +load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") +load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") + +pytest_test( + name = "pytest_script_venv_test", + srcs = [ + "basic_test.py", + ], + config_settings = { + "@rules_python//python/config_settings:bootstrap_impl": "script", + "@rules_python//python/config_settings:venvs_site_packages": "yes", + }, + target_compatible_with = SUPPORTS_BZLMOD, +) + +pytest_test( + name = "pytest_default_test", + srcs = [ + "basic_test.py", + ], + target_compatible_with = SUPPORTS_BZLMOD, +) diff --git a/tests/pytest_test/basic_test.py b/tests/pytest_test/basic_test.py new file mode 100644 index 0000000000..30e2c41ab9 --- /dev/null +++ b/tests/pytest_test/basic_test.py @@ -0,0 +1,2 @@ +def test_foo(): + assert True diff --git a/tests/support/pytest_test/BUILD.bazel b/tests/support/pytest_test/BUILD.bazel new file mode 100644 index 0000000000..50ad59bf0e --- /dev/null +++ b/tests/support/pytest_test/BUILD.bazel @@ -0,0 +1,30 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility + +package(default_visibility = ["//:__subpackages__"]) + +filegroup( + name = "bootstrap_template", + srcs = ["pytest_bootstrap_template.py"], +) + +# keep +bzl_library( + name = "pytest_test", + srcs = ["pytest_test.bzl"], + deps = [ + "//python:py_test", + ], +) + +# These aliases are used to avoid duplicate targets in the deps list +alias( + name = "default_pytest", + actual = "@pypi//pytest" if BZLMOD_ENABLED else "//python/private:empty", +) + +# These aliases are used to avoid duplicate targets in the deps list +alias( + name = "default_pytest_bazel", + actual = "@pypi//pytest_bazel" if BZLMOD_ENABLED else "//python/private:empty", +) diff --git a/tests/support/pytest_test/pytest_bootstrap_template.py b/tests/support/pytest_test/pytest_bootstrap_template.py new file mode 100644 index 0000000000..9769531f47 --- /dev/null +++ b/tests/support/pytest_test/pytest_bootstrap_template.py @@ -0,0 +1,8 @@ +import sys + +import pytest_bazel + +TEST_FILES = """%TEST_FILES%""".splitlines() + +args = sys.argv[1:] + TEST_FILES +sys.exit(pytest_bazel.main(args)) diff --git a/tests/support/pytest_test/pytest_test.bzl b/tests/support/pytest_test/pytest_test.bzl new file mode 100644 index 0000000000..893b22e5ce --- /dev/null +++ b/tests/support/pytest_test/pytest_test.bzl @@ -0,0 +1,74 @@ +"""pytest_test rule implementation.""" + +load("//python:py_test.bzl", "py_test") + +_DEFAULT_PYTEST = Label("//tests/support/pytest_test:default_pytest") +_DEFAULT_PYTEST_BAZEL = Label("//tests/support/pytest_test:default_pytest_bazel") + +def pytest_test( + *, + name, + srcs, + pytest = None, + pytest_bazel = None, + **kwargs): + """Run pytest tests. + + Args: + name: A unique name for this target. + srcs: List of source files (test files). These are the files that + pytest will run as tests. + pytest: The pytest target to use. Defaults to @pypi//pytest. + pytest_bazel: The pytest-bazel target to use. Defaults to + @pypi//pytest_bazel. + **kwargs: Additional arguments passed to py_test. Note that `main` is + not a supported argument. + """ + if pytest == None: + pytest = _DEFAULT_PYTEST + if pytest_bazel == None: + pytest_bazel = _DEFAULT_PYTEST_BAZEL + + bootstrap_target = name + "_bootstrap" + main_file = name + "_boot.py" + _write_pytest_bootstrap( + name = bootstrap_target, + srcs = srcs, + output_name = main_file, + ) + + py_test( + name = name, + main = main_file, + srcs = [bootstrap_target] + srcs, + deps = kwargs.pop("deps", []) + [ + pytest, + pytest_bazel, + ], + **kwargs + ) + +def _write_pytest_bootstrap_impl(ctx): + output = ctx.actions.declare_file(ctx.attr.output_name) + test_files = "\n".join([f.short_path for f in ctx.files.srcs]) + + ctx.actions.expand_template( + output = output, + template = ctx.file._bootstrap_template, + substitutions = { + "%TEST_FILES%": test_files, + }, + ) + return [DefaultInfo(files = depset([output]))] + +_write_pytest_bootstrap = rule( + implementation = _write_pytest_bootstrap_impl, + attrs = { + "output_name": attr.string(mandatory = True), + "srcs": attr.label_list(allow_files = True), + "_bootstrap_template": attr.label( + default = "//tests/support/pytest_test:bootstrap_template", + allow_single_file = True, + ), + }, +) From f0badb1295e81da64c388f1c76ce0354424dad42 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 18 Jul 2026 22:24:15 -0400 Subject: [PATCH 090/179] feat: add flag aliases to Starlark-defined flags (#3932) This was breaking rules_python CI with Bazel 9.0.0. @aranguyen and I made some changes that we thought would resolve in 9.1.0 or 9.2.0. The repro I tried on 9.0.0 now passes: Tested: ``` USE_BAZEL_VERSION=9.0.0 bazelisk build --nobuild //tests/py_zipapp:system_python_zipapp ERROR: Analysis of target '//tests/py_zipapp:system_python_zipapp' failed; build aborted: no such package '@@rules_python//python/config_settings': The repository '@@rules_python' could not be resolved: Repository '@@rules_python' is not defined ``` ``` USE_BAZEL_VERSION=9.2.0 bazelisk build --nobuild //tests/py_zipapp:system_python_zipapp INFO: Build completed successfully, 0 total actions ``` References: - https://github.com/bazel-contrib/rules_python/pull/3450#issuecomment-3974932245 - https://github.com/bazel-contrib/rules_python/pull/3450#issuecomment-3986798612 --------- Co-authored-by: Richard Levasseur --- MODULE.bazel | 35 ++++++++++++++++------------------- news/3932.added.md | 3 +++ 2 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 news/3932.added.md diff --git a/MODULE.bazel b/MODULE.bazel index e4c4e08e89..2df354fe61 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -326,25 +326,22 @@ uv_dev.configure( version = "0.11.2", ) -# Temporarily comment out these flag aliases because they break Bazel 9 -# when transitions are also used with a target. -# -# flag_alias( -# name = "build_python_zip", -# starlark_flag = "//python/config_settings:build_python_zip", -# ) +flag_alias( + name = "build_python_zip", + starlark_flag = "//python/config_settings:build_python_zip", +) -# flag_alias( -# name = "incompatible_default_to_explicit_init_py", -# starlark_flag = "//python/config_settings:incompatible_default_to_explicit_init_py", -# ) +flag_alias( + name = "incompatible_default_to_explicit_init_py", + starlark_flag = "//python/config_settings:incompatible_default_to_explicit_init_py", +) -# flag_alias( -# name = "python_path", -# starlark_flag = "//python/config_settings:python_path", -# ) +flag_alias( + name = "python_path", + starlark_flag = "//python/config_settings:python_path", +) -# flag_alias( -# name = "experimental_python_import_all_repositories", -# starlark_flag = "//python/config_settings:experimental_python_import_all_repositories", -# ) +flag_alias( + name = "experimental_python_import_all_repositories", + starlark_flag = "//python/config_settings:experimental_python_import_all_repositories", +) diff --git a/news/3932.added.md b/news/3932.added.md new file mode 100644 index 0000000000..579d1b5f59 --- /dev/null +++ b/news/3932.added.md @@ -0,0 +1,3 @@ +(bzlmod) Added MODULE.bazel flag aliases for Starlark-defined flags: +`build_python_zip`, `incompatible_default_to_explicit_init_py`, +`python_path`, and `experimental_python_import_all_repositories`. From c271d3f93d255e10970efdc9a7496b2768c1ff72 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 19 Jul 2026 22:40:30 -0700 Subject: [PATCH 091/179] refactor(tools): migrate private release tool tests to pytest and pytest-mock (#3937) Migrates release tool unit tests under `tests/tools/private/release/` to Pytest and Pytest-mock (`mocker` fixture). - Added `pytest-mock` dependency to `docs/pyproject.toml`, updated `docs/uv.lock` & `docs/requirements.txt`, and added `@pypi//pytest_mock` to release test helper `deps`. - Converted release test targets from standard unittest to pytest fixtures. - Refactored tests to use the standard `mocker` fixture from `pytest-mock`. --- AGENTS.md | 10 +- docs/pyproject.toml | 1 + docs/requirements.txt | 12 +- docs/uv.lock | 15 + tests/tools/private/release/BUILD.bazel | 111 +- .../private/release/add_backports_test.py | 142 +-- .../release/backport_create_releases_test.py | 182 ++- .../private/release/backport_prepare_test.py | 469 ++++--- .../private/release/changelog_news_test.py | 631 +++++----- .../release/complete_sync_changelog_test.py | 177 ++- tests/tools/private/release/create_rc_test.py | 702 +++++------ .../release/create_release_branch_test.py | 260 ++-- tests/tools/private/release/gh_test.py | 114 +- tests/tools/private/release/git_test.py | 335 +++-- .../private/release/on_pr_merged_test.py | 154 +-- tests/tools/private/release/prepare_test.py | 453 ++++--- .../private/release/process_backports_test.py | 1092 +++++++---------- tests/tools/private/release/promote_test.py | 582 +++++---- .../private/release/release_issue_test.py | 207 ++-- tests/tools/private/release/release_test.py | 30 +- .../private/release/release_test_helper.py | 94 +- tests/tools/private/release/utils_test.py | 412 ++++--- .../release/backport_create_releases.py | 2 +- tools/private/release/create_release_issue.py | 2 +- tools/private/release/gh.py | 506 ++++---- tools/private/release/mock_gh.py | 89 +- tools/private/release/prepare.py | 4 +- 27 files changed, 3260 insertions(+), 3528 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6f6e6fe76a..170aa49c8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,14 @@ because it interferes with code review comments. Follow the advice in `CONTRIBUTING.md` for PR descriptions. PR descriptions become the commit message upon merge. +### Python pytest conventions + +* When registering pytest fixtures from helper modules in test files, use + `pytest_plugins = [""]`. +* Name fixture functions with a `fixture_` prefix (e.g. `def fixture_foo():`), + and pass the public fixture name using the `name` parameter in + `@pytest.fixture(name="foo")`. + ### Starlark style For doc strings, using triple quoted strings when the doc string is more than @@ -98,7 +106,6 @@ def foo_test_suite(name): test_suite(name=name, tests=_tests) ``` - #### Repository rules The function argument `rctx` is a hint that the function is a repository rule, @@ -157,7 +164,6 @@ This repository contains 3 Bazel bzlmod modules. `tests/support/` contains utility code and helpers for testing. - `python/config_settings/BUILD.bazel` contains build flags that are part of the public API. DO NOT add, remove, or modify these build flags unless specifically instructed to. diff --git a/docs/pyproject.toml b/docs/pyproject.toml index ea8a544241..e54fd53504 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -19,4 +19,5 @@ dependencies = [ "markupsafe", "pytest", "pytest-bazel", + "pytest-mock", ] diff --git a/docs/requirements.txt b/docs/requirements.txt index a0224b9283..5ac2d2b323 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -309,9 +309,9 @@ myst-parser==3.0.1 ; python_full_version < '3.10' \ --hash=sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1 \ --hash=sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87 # via rules-python-docs (docs/pyproject.toml) -myst-parser==5.1.0 ; python_full_version == '3.10.*' \ - --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ - --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 +myst-parser==4.0.1 ; python_full_version == '3.10.*' \ + --hash=sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4 \ + --hash=sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d # via rules-python-docs (docs/pyproject.toml) myst-parser==5.1.0 ; python_full_version >= '3.11' \ --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ @@ -352,15 +352,21 @@ pytest==8.4.2 ; python_full_version < '3.10' \ # via # rules-python-docs (docs/pyproject.toml) # pytest-bazel + # pytest-mock pytest==9.1.1 ; python_full_version >= '3.10' \ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c # via # rules-python-docs (docs/pyproject.toml) # pytest-bazel + # pytest-mock pytest-bazel==0.1.6 \ --hash=sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21 # via rules-python-docs (docs/pyproject.toml) +pytest-mock==3.15.1 \ + --hash=sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d \ + --hash=sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f + # via rules-python-docs (docs/pyproject.toml) pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ diff --git a/docs/uv.lock b/docs/uv.lock index c86b4cd18e..d01b79b784 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -717,6 +717,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/90/4726a39728fb5a5f77a773c5e9d7bfd3a0120aa2029963a88fd06e2c5fb2/pytest_bazel-0.1.6-py3-none-any.whl", hash = "sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21", size = 10126, upload-time = "2025-10-31T08:41:17.165Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -871,6 +884,7 @@ dependencies = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-bazel" }, + { name = "pytest-mock" }, { name = "readthedocs-sphinx-ext" }, { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, @@ -893,6 +907,7 @@ requires-dist = [ { name = "pyelftools" }, { name = "pytest" }, { name = "pytest-bazel" }, + { name = "pytest-mock" }, { name = "readthedocs-sphinx-ext" }, { name = "sphinx" }, { name = "sphinx-autodoc2" }, diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index d49d8fd221..5ac912a955 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -1,149 +1,172 @@ -load("@rules_python//python:defs.bzl", "py_library", "py_test") +load("//python:py_library.bzl", "py_library") +load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") +load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") py_library( name = "release_test_helper", srcs = ["release_test_helper.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ "//tools/private/release:mock_gh", "//tools/private/release:release_lib", + "@pypi//pytest_mock", ], ) -py_test( +pytest_test( name = "add_backports_test", srcs = ["add_backports_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( +pytest_test( name = "changelog_news_test", srcs = ["changelog_news_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( +pytest_test( name = "complete_sync_changelog_test", srcs = ["complete_sync_changelog_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "create_rc_test", - srcs = ["create_rc_test.py"], +pytest_test( + name = "create_release_branch_test", + srcs = ["create_release_branch_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "create_release_branch_test", - srcs = ["create_release_branch_test.py"], +pytest_test( + name = "git_test", + srcs = ["git_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "gh_test", - srcs = ["gh_test.py"], +pytest_test( + name = "on_pr_merged_test", + srcs = ["on_pr_merged_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ + ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "git_test", - srcs = ["git_test.py"], +pytest_test( + name = "promote_test", + srcs = ["promote_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ + ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "on_pr_merged_test", - srcs = ["on_pr_merged_test.py"], +pytest_test( + name = "release_issue_test", + srcs = ["release_issue_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "prepare_test", - srcs = ["prepare_test.py"], +pytest_test( + name = "release_test", + srcs = ["release_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "process_backports_test", - srcs = ["process_backports_test.py"], +pytest_test( + name = "backport_create_releases_test", + srcs = ["backport_create_releases_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "promote_test", - srcs = ["promote_test.py"], +pytest_test( + name = "prepare_test", + srcs = ["prepare_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "release_issue_test", - srcs = ["release_issue_test.py"], +pytest_test( + name = "gh_test", + srcs = ["gh_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ + ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "release_test", - srcs = ["release_test.py"], +pytest_test( + name = "backport_prepare_test", + srcs = ["backport_prepare_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ + ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "utils_test", - srcs = ["utils_test.py"], +pytest_test( + name = "process_backports_test", + srcs = ["process_backports_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", - "@dev_pip//packaging", ], ) -py_test( - name = "backport_prepare_test", - srcs = ["backport_prepare_test.py"], +pytest_test( + name = "create_rc_test", + srcs = ["create_rc_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", ], ) -py_test( - name = "backport_create_releases_test", - srcs = ["backport_create_releases_test.py"], +pytest_test( + name = "utils_test", + srcs = ["utils_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":release_test_helper", "//tools/private/release:release_lib", diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py index 75729e87fe..c1c7b5cc0a 100644 --- a/tests/tools/private/release/add_backports_test.py +++ b/tests/tools/private/release/add_backports_test.py @@ -1,89 +1,85 @@ import argparse -import unittest -from unittest.mock import patch -from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.add_backports import AddBackports +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class CmdAddBackportsTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - self.addCleanup(patch.stopall) - self.mock_gh.resolve_pr_number.side_effect = lambda x: int( - x.lstrip("#").split("/")[-1] - ) - def test_add_backports_explicit_issue(self): - args = argparse.Namespace(issue=123, prs=["124", "125"]) - self.mock_gh.get_issue_body.return_value = """ +def test_add_backports_explicit_issue(mock_gh): + args = argparse.Namespace(issue=123, prs=["124", "125"]) + mock_gh.issues[123] = { + "title": "Release 2.1.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch - [ ] Tag Final ## Backports -""" - result = AddBackports(args, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.get_issue_body.assert_called_once_with(123) - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("- [ ] #124", call_args[1]) - self.assertIn("- [ ] #125", call_args[1]) - # Should also auto-add Tag RC0 - self.assertIn("- [ ] Tag RC0", call_args[1]) - self.assertIn("- [ ] Sync Changelog #124", call_args[1]) - self.assertIn("- [ ] Sync Changelog #125", call_args[1]) - - def test_add_backports_auto_discover_success(self): - args = argparse.Namespace(issue=None, prs=["124"]) - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."} - ] - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + "number": 123, + "url": "https://github.com/bazel-contrib/rules_python/issues/123", + } + result = AddBackports(args, mock_gh).run() + + assert result == 0 + updated_body = mock_gh.get_issue_body(123) + assert "- [ ] #124" in updated_body + assert "- [ ] #125" in updated_body + assert "- [ ] Tag RC0" in updated_body + assert "- [ ] Sync Changelog #124" in updated_body + assert "- [ ] Sync Changelog #125" in updated_body + + +def test_add_backports_auto_discover_success(mock_gh): + args = argparse.Namespace(issue=None, prs=["124"]) + issue_num = mock_gh.create_issue( + title="Release 2.1.0", + body=""" ## Checklist - [ ] Prepare Release - [ ] Create Release branch - [ ] Tag Final ## Backports -""" - result = AddBackports(args, self.mock_gh).run() +""", + labels=["type: release"], + ) + result = AddBackports(args, mock_gh).run() - self.assertEqual(result, 0) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_called_once_with(456) - self.mock_gh.update_issue_body.assert_called_once_with(456, unittest.mock.ANY) + assert result == 0 + updated_body = mock_gh.get_issue_body(issue_num) + assert "- [ ] #124" in updated_body - def test_add_backports_auto_discover_no_issues(self): - args = argparse.Namespace(issue=None, prs=["124"]) - self.mock_gh.get_open_tracking_issues.return_value = [] - result = AddBackports(args, self.mock_gh).run() +def test_add_backports_auto_discover_no_issues(mock_gh): + args = argparse.Namespace(issue=None, prs=["124"]) - self.assertEqual(result, 1) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_not_called() + result = AddBackports(args, mock_gh).run() - def test_add_backports_auto_discover_multiple_issues(self): - args = argparse.Namespace(issue=None, prs=["124"]) - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."}, - {"number": 789, "title": "Release 2.2.0", "url": "http://..."}, - ] + assert result == 1 - result = AddBackports(args, self.mock_gh).run() - self.assertEqual(result, 1) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_not_called() +def test_add_backports_auto_discover_multiple_issues(mock_gh): + args = argparse.Namespace(issue=None, prs=["124"]) + mock_gh.create_issue( + title="Release 2.1.0", body="## Backports\n", labels=["type: release"] + ) + mock_gh.create_issue( + title="Release 2.2.0", body="## Backports\n", labels=["type: release"] + ) - def test_add_backports_no_auto_add_rc_if_pending(self): - args = argparse.Namespace(issue=123, prs=["124"]) - self.mock_gh.get_issue_body.return_value = """ + result = AddBackports(args, mock_gh).run() + + assert result == 1 + + +def test_add_backports_no_auto_add_rc_if_pending(mock_gh): + args = argparse.Namespace(issue=123, prs=["124"]) + mock_gh.issues[123] = { + "title": "Release 2.1.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -91,17 +87,15 @@ def test_add_backports_no_auto_add_rc_if_pending(self): - [ ] Tag Final ## Backports -""" - result = AddBackports(args, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertNotIn("Tag RC1", call_args[1]) - # Tag RC0 should still be there - self.assertIn("- [ ] Tag RC0", call_args[1]) - self.assertIn("- [ ] Sync Changelog #124", call_args[1]) - - -if __name__ == "__main__": - unittest.main() +""", + "labels": ["type: release"], + "number": 123, + "url": "https://github.com/bazel-contrib/rules_python/issues/123", + } + result = AddBackports(args, mock_gh).run() + + assert result == 0 + updated_body = mock_gh.get_issue_body(123) + assert "Tag RC1" not in updated_body + assert "- [ ] Tag RC0" in updated_body + assert "- [ ] Sync Changelog #124" in updated_body diff --git a/tests/tools/private/release/backport_create_releases_test.py b/tests/tools/private/release/backport_create_releases_test.py index f1bdfe477f..81dc7e00ce 100644 --- a/tests/tools/private/release/backport_create_releases_test.py +++ b/tests/tools/private/release/backport_create_releases_test.py @@ -1,18 +1,18 @@ import argparse -import unittest -from tests.tools.private.release.release_test_helper import ReleaseToolTestCase from tools.private.release.backport_create_releases import BackportCreateReleases +# Register pytest fixtures (such as release_tool_env) from release_test_helper +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class CmdBackportCreateReleasesTest(ReleaseToolTestCase): - def test_create_releases_all_success(self): - # Arrange - args = argparse.Namespace(issue=123, dry_run=False) - gh = self.gh - # Setup backport issue in mock GH - backport_body = """* PR: #456 +def test_create_releases_all_success(release_tool_env, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = mock_gh + + # Setup backport issue in mock GH + backport_body = """* PR: #456 * From version: 1.7 * To version: 1.9 @@ -25,36 +25,36 @@ def test_create_releases_all_success(self): - [ ] Track Release 1.8.1 - [ ] Track Release 1.9.0""" - gh.issues[123] = { - "title": "Backport: #456", - "body": backport_body, - "labels": ["type:backport-pr"], - "number": 123, - "url": "https://github.com/.../issues/123", - } + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } - # Act - result = BackportCreateReleases(args, gh).run() + # Act + result = BackportCreateReleases(args, gh).run() - # Assert - self.assertEqual(result, 0) + # Assert + assert result == 0 - # Verify release issues created (IDs 1001, 1002, 1003) - self.assertIn(1001, gh.issues) - self.assertIn(1002, gh.issues) - self.assertIn(1003, gh.issues) + # Verify release issues created (IDs 1001, 1002, 1003) + assert 1001 in gh.issues + assert 1002 in gh.issues + assert 1003 in gh.issues - # 1.7.2 (patch) should not have Tag RC0 - self.assertEqual(gh.issues[1001]["title"], "Release 1.7.2") - self.assertNotIn("Tag RC0", gh.issues[1001]["body"]) - self.assertIn("## Backports\n- [ ] #456", gh.issues[1001]["body"]) + # 1.7.2 (patch) should not have Tag RC0 + assert gh.issues[1001]["title"] == "Release 1.7.2" + assert "Tag RC0" not in gh.issues[1001]["body"] + assert "## Backports\n- [ ] #456" in gh.issues[1001]["body"] - # 1.9.0 (minor) should have Tag RC0 - self.assertEqual(gh.issues[1003]["title"], "Release 1.9.0") - self.assertIn("Tag RC0", gh.issues[1003]["body"]) + # 1.9.0 (minor) should have Tag RC0 + assert gh.issues[1003]["title"] == "Release 1.9.0" + assert "Tag RC0" in gh.issues[1003]["body"] - # Verify backport issue updated - expected_updated_backport_body = """* PR: #456 + # Verify backport issue updated + expected_updated_backport_body = """* PR: #456 * From version: 1.7 * To version: 1.9 @@ -67,15 +67,16 @@ def test_create_releases_all_success(self): - [x] Track Release 1.8.1 | status=success release_issue=#1002 - [x] Track Release 1.9.0 | status=success release_issue=#1003""" - self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + assert gh.issues[123]["body"] == expected_updated_backport_body + - def test_create_releases_dependency_blocking(self): - # Arrange - args = argparse.Namespace(issue=123, dry_run=False) - gh = self.gh +def test_create_releases_dependency_blocking(release_tool_env, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = mock_gh - # 1.8 failed, 1.7 and 1.9 succeeded - backport_body = """* PR: #456 + # 1.8 failed, 1.7 and 1.9 succeeded + backport_body = """* PR: #456 * From version: 1.7 * To version: 1.9 @@ -88,27 +89,27 @@ def test_create_releases_dependency_blocking(self): - [ ] Track Release 1.8.1 - [ ] Track Release 1.9.0""" - gh.issues[123] = { - "title": "Backport: #456", - "body": backport_body, - "labels": ["type:backport-pr"], - "number": 123, - "url": "https://github.com/.../issues/123", - } + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } - # Act - result = BackportCreateReleases(args, gh).run() + # Act + result = BackportCreateReleases(args, gh).run() - # Assert - self.assertEqual(result, 0) + # Assert + assert result == 0 - # Only 1.9.0 (ID 1001) should be created. 1.7.2 and 1.8.1 are blocked by 1.8 failure. - self.assertEqual(len(gh.issues), 2) # Backport issue + 1 release issue - self.assertIn(1001, gh.issues) - self.assertEqual(gh.issues[1001]["title"], "Release 1.9.0") + # Only 1.9.0 (ID 1001) should be created. 1.7.2 and 1.8.1 are blocked by 1.8 failure. + assert len(gh.issues) == 2 # Backport issue + 1 release issue + assert 1001 in gh.issues + assert gh.issues[1001]["title"] == "Release 1.9.0" - # Verify backport issue updated with block statuses - expected_updated_backport_body = """* PR: #456 + # Verify backport issue updated with block statuses + expected_updated_backport_body = """* PR: #456 * From version: 1.7 * To version: 1.9 @@ -121,14 +122,15 @@ def test_create_releases_dependency_blocking(self): - [ ] Track Release 1.8.1 | status=error-later-release-did-not-apply - [x] Track Release 1.9.0 | status=success release_issue=#1001""" - self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + assert gh.issues[123]["body"] == expected_updated_backport_body + - def test_create_releases_already_exists(self): - # Arrange - args = argparse.Namespace(issue=123, dry_run=False) - gh = self.gh +def test_create_releases_already_exists(release_tool_env, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = mock_gh - backport_body = """* PR: #456 + backport_body = """* PR: #456 * From version: 1.7 * To version: 1.7 @@ -137,30 +139,30 @@ def test_create_releases_already_exists(self): - [x] Verify apply 1.7 | status=success - [ ] Track Release 1.7.2""" - gh.issues[123] = { - "title": "Backport: #456", - "body": backport_body, - "labels": ["type:backport-pr"], - "number": 123, - "url": "https://github.com/.../issues/123", - } - - # Pre-create the release issue to simulate it already existing - gh.create_issue( - title="Release 1.7.2", - body="existing body\n## Backports\n", - labels=["type: release"], - ) # Will get ID 1001 - - # Act - result = BackportCreateReleases(args, gh).run() - - # Assert - self.assertEqual(result, 0) - self.assertEqual(len(gh.issues), 2) - - # Should link existing issue 1001 - expected_updated_backport_body = """* PR: #456 + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Pre-create the release issue to simulate it already existing + gh.create_issue( + title="Release 1.7.2", + body="existing body\n## Backports\n", + labels=["type: release"], + ) # Will get ID 1001 + + # Act + result = BackportCreateReleases(args, gh).run() + + # Assert + assert result == 0 + assert len(gh.issues) == 2 + + # Should link existing issue 1001 + expected_updated_backport_body = """* PR: #456 * From version: 1.7 * To version: 1.7 @@ -169,8 +171,4 @@ def test_create_releases_already_exists(self): - [x] Verify apply 1.7 | status=success - [x] Track Release 1.7.2 | status=success release_issue=#1001""" - self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) - - -if __name__ == "__main__": - unittest.main() + assert gh.issues[123]["body"] == expected_updated_backport_body diff --git a/tests/tools/private/release/backport_prepare_test.py b/tests/tools/private/release/backport_prepare_test.py index 26883b78b3..ddef4885b9 100644 --- a/tests/tools/private/release/backport_prepare_test.py +++ b/tests/tools/private/release/backport_prepare_test.py @@ -1,247 +1,238 @@ import argparse -import unittest -from unittest.mock import call, patch +from unittest.mock import ANY, call -from tests.tools.private.release.release_test_helper import ( - ReleaseToolTestCase, - _mock_git, -) from tools.private.release.backport_prepare import BackportPrepare from tools.private.release.gh import BACKPORT_LABEL - -class CmdBackportPrepareTest(ReleaseToolTestCase): - def setUp(self): - super().setUp() - _mock_git(self) - # Mock changelog_news and determine_next_version - self.patcher_news = patch( - "tools.private.release.backport_prepare.changelog_news" - ) - self.mock_news = self.patcher_news.start() - - self.patcher_det = patch( - "tools.private.release.backport_prepare.determine_next_version" - ) - self.mock_det = self.patcher_det.start() - - self.addCleanup(self.patcher_news.stop) - self.addCleanup(self.patcher_det.stop) - - def test_prepare_from_issue_success(self): - # Arrange - args = argparse.Namespace( - issue=123, - pr=None, - from_minor=None, - to_minor=None, - remote="my-remote", - dry_run=False, - ) - - # Setup backport issue in mock GH - backport_body = "* PR: #456\n* From version: 1.7\n* To version: 1.9\n" - self.gh.issues[123] = { - "title": "Backport: #456", - "body": backport_body, - "labels": ["type: backport-pr"], - "number": 123, - "url": "https://github.com/.../issues/123", - } - - # Setup PR info in mock GH - self.gh.prs[456] = { - "state": "MERGED", - "mergeCommit": {"oid": "pr_merge_sha_12345"}, - } - - # Mock remote branches - self.mock_git.get_remote_branches.return_value = [ - "main", - "release/1.6", - "release/1.7", - "release/1.8", - "release/1.9", - "release/2.0", - ] - self.mock_git.get_current_branch.return_value = "work-branch" - - # Mock next versions - self.mock_det.side_effect = ["1.7.2", "1.8.1", "1.9.0"] - - # Act - result = BackportPrepare(args, self.mock_git, self.gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) - - # Verify checkouts and cherry-picks - self.mock_git.checkout.assert_has_calls( - [ - call("release/1.7", track_remote="my-remote"), - call("release/1.8", track_remote="my-remote"), - call("release/1.9", track_remote="my-remote"), - call("work-branch"), # Restored branch - ] - ) - - self.mock_git.cherry_pick.assert_has_calls( - [ - call("pr_merge_sha_12345"), - call("pr_merge_sha_12345"), - call("pr_merge_sha_12345"), - ] - ) - - # Verify changelog updates - self.mock_news.update_changelog.assert_has_calls( - [ - call("1.7.2", unittest.mock.ANY), - call("1.8.1", unittest.mock.ANY), - call("1.9.0", unittest.mock.ANY), - ] - ) - - # Verify issue body update - expected_body = ( - "* PR: #456\n" - "* From version: 1.7\n" - "* To version: 1.9\n" - "\n" - "## Tasks\n" - "\n" - "- [x] Verify apply 1.7 | status=success\n" - "- [x] Verify apply 1.8 | status=success\n" - "- [x] Verify apply 1.9 | status=success\n" - "- [ ] Track Release 1.7.2\n" - "- [ ] Track Release 1.8.1\n" - "- [ ] Track Release 1.9.0" - ) - self.assertEqual(self.gh.issues[123]["body"], expected_body) - - def test_prepare_manual_success(self): - # Arrange - args = argparse.Namespace( - issue=None, - pr="#456", - from_minor="1.7", - to_minor="1.8", - remote="my-remote", - dry_run=False, - ) - self.gh.prs[456] = { - "state": "MERGED", - "mergeCommit": {"oid": "pr_merge_sha_12345"}, - } - self.mock_git.get_remote_branches.return_value = [ - "release/1.7", - "release/1.8", +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +def test_prepare_from_issue_success(mocker, mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, + pr=None, + from_minor=None, + to_minor=None, + remote="my-remote", + dry_run=False, + ) + + # Setup backport issue in mock GH + backport_body = "* PR: #456\n* From version: 1.7\n* To version: 1.9\n" + mock_gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type: backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Setup PR info in mock GH + mock_gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + + # Mock remote branches + mock_git.get_remote_branches.return_value = [ + "main", + "release/1.6", + "release/1.7", + "release/1.8", + "release/1.9", + "release/2.0", + ] + mock_git.get_current_branch.return_value = "work-branch" + + mock_news = mocker.patch("tools.private.release.backport_prepare.changelog_news") + mock_det = mocker.patch( + "tools.private.release.backport_prepare.determine_next_version" + ) + mock_det.side_effect = ["1.7.2", "1.8.1", "1.9.0"] + + # Act + result = BackportPrepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + + # Verify checkouts and cherry-picks + mock_git.checkout.assert_has_calls( + [ + call("release/1.7", track_remote="my-remote"), + call("release/1.8", track_remote="my-remote"), + call("release/1.9", track_remote="my-remote"), + call("work-branch"), # Restored branch ] - self.mock_git.get_current_branch.return_value = "work-branch" - self.mock_det.side_effect = ["1.7.2", "1.8.1"] - - # Act - result = BackportPrepare(args, self.mock_git, self.gh).run() - - # Assert - self.assertEqual(result, 0) - self.assertIn(1001, self.gh.issues) - issue = self.gh.issues[1001] - self.assertEqual(issue["title"], "Backport: #456") - self.assertEqual(issue["labels"], [BACKPORT_LABEL]) - body = issue["body"] - self.assertIn("- [x] Verify apply 1.7 | status=success", body) - self.assertIn("- [x] Verify apply 1.8 | status=success", body) - self.assertIn("- [ ] Track Release 1.7.2", body) - self.assertIn("- [ ] Track Release 1.8.1", body) - - def test_prepare_manual_with_patch_versions(self): - # Arrange - args = argparse.Namespace( - issue=None, - pr="#456", - from_minor="1.7.0", - to_minor="1.8.0", - remote="my-remote", - dry_run=False, - ) - self.gh.prs[456] = { - "state": "MERGED", - "mergeCommit": {"oid": "pr_merge_sha_12345"}, - } - self.mock_git.get_remote_branches.return_value = [ - "release/1.7", - "release/1.8", + ) + + mock_git.cherry_pick.assert_has_calls( + [ + call("pr_merge_sha_12345"), + call("pr_merge_sha_12345"), + call("pr_merge_sha_12345"), ] - self.mock_git.get_current_branch.return_value = "work-branch" - self.mock_det.side_effect = ["1.7.2", "1.8.1"] - - # Act - result = BackportPrepare(args, self.mock_git, self.gh).run() - - # Assert - self.assertEqual(result, 0) - self.assertIn(1001, self.gh.issues) - body = self.gh.issues[1001]["body"] - self.assertIn("- [x] Verify apply 1.7 | status=success", body) - self.assertIn("- [x] Verify apply 1.8 | status=success", body) - - def test_prepare_verify_failed(self): - # Arrange - args = argparse.Namespace( - issue=123, - pr=None, - from_minor=None, - to_minor=None, - remote="my-remote", - dry_run=False, - ) - issue_body = "* PR: #456\n* From version: 1.7\n* To version: 1.8\n" - self.gh.issues[123] = { - "title": "Backport: #456", - "body": issue_body, - "labels": ["type: backport-pr"], - "number": 123, - "url": "https://github.com/.../issues/123", - } - self.gh.prs[456] = { - "state": "MERGED", - "mergeCommit": {"oid": "pr_merge_sha_12345"}, - } - self.mock_git.get_remote_branches.return_value = [ - "release/1.7", - "release/1.8", + ) + + # Verify changelog updates + mock_news.update_changelog.assert_has_calls( + [ + call("1.7.2", ANY), + call("1.8.1", ANY), + call("1.9.0", ANY), ] - self.mock_git.get_current_branch.return_value = "work-branch" - self.mock_det.side_effect = ["1.7.2", "1.8.1"] - - # Mock cherry-pick failure on 1.7 and changelog failure on 1.8 - self.mock_git.cherry_pick.side_effect = [Exception("Conflict"), None] - self.mock_news.update_changelog.side_effect = [Exception("Changelog error")] - - # Act - result = BackportPrepare(args, self.mock_git, self.gh).run() - - # Assert - self.assertEqual( - result, 0 - ) # Returns 0 even if verification fails, it just updates tasks - - expected_body = ( - "* PR: #456\n" - "* From version: 1.7\n" - "* To version: 1.8\n" - "\n" - "## Tasks\n" - "\n" - "- [ ] Verify apply 1.7 | status=failed-conflict\n" - "- [ ] Verify apply 1.8 | status=failed-changelog\n" - "- [ ] Track Release 1.7.2\n" - "- [ ] Track Release 1.8.1" - ) - self.assertEqual(self.gh.issues[123]["body"], expected_body) - - -if __name__ == "__main__": - unittest.main() + ) + + # Verify issue body update + expected_body = ( + "* PR: #456\n" + "* From version: 1.7\n" + "* To version: 1.9\n" + "\n" + "## Tasks\n" + "\n" + "- [x] Verify apply 1.7 | status=success\n" + "- [x] Verify apply 1.8 | status=success\n" + "- [x] Verify apply 1.9 | status=success\n" + "- [ ] Track Release 1.7.2\n" + "- [ ] Track Release 1.8.1\n" + "- [ ] Track Release 1.9.0" + ) + assert mock_gh.issues[123]["body"] == expected_body + + +def test_prepare_manual_success(mocker, mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=None, + pr="#456", + from_minor="1.7", + to_minor="1.8", + remote="my-remote", + dry_run=False, + ) + mock_gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + mock_git.get_current_branch.return_value = "work-branch" + + mocker.patch("tools.private.release.backport_prepare.changelog_news") + mock_det = mocker.patch( + "tools.private.release.backport_prepare.determine_next_version" + ) + mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Act + result = BackportPrepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert 1001 in mock_gh.issues + issue = mock_gh.issues[1001] + assert issue["title"] == "Backport: #456" + assert issue["labels"] == [BACKPORT_LABEL] + body = issue["body"] + assert "- [x] Verify apply 1.7 | status=success" in body + assert "- [x] Verify apply 1.8 | status=success" in body + assert "- [ ] Track Release 1.7.2" in body + assert "- [ ] Track Release 1.8.1" in body + + +def test_prepare_manual_with_patch_versions(mocker, mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=None, + pr="#456", + from_minor="1.7.0", + to_minor="1.8.0", + remote="my-remote", + dry_run=False, + ) + mock_gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + mock_git.get_current_branch.return_value = "work-branch" + + mocker.patch("tools.private.release.backport_prepare.changelog_news") + mock_det = mocker.patch( + "tools.private.release.backport_prepare.determine_next_version" + ) + mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Act + result = BackportPrepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert 1001 in mock_gh.issues + body = mock_gh.issues[1001]["body"] + assert "- [x] Verify apply 1.7 | status=success" in body + assert "- [x] Verify apply 1.8 | status=success" in body + + +def test_prepare_verify_failed(mocker, mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, + pr=None, + from_minor=None, + to_minor=None, + remote="my-remote", + dry_run=False, + ) + issue_body = "* PR: #456\n* From version: 1.7\n* To version: 1.8\n" + mock_gh.issues[123] = { + "title": "Backport: #456", + "body": issue_body, + "labels": ["type: backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + mock_gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + mock_git.get_current_branch.return_value = "work-branch" + + mock_news = mocker.patch("tools.private.release.backport_prepare.changelog_news") + mock_det = mocker.patch( + "tools.private.release.backport_prepare.determine_next_version" + ) + mock_det.side_effect = ["1.7.2", "1.8.1"] + mock_git.cherry_pick.side_effect = [Exception("Conflict"), None] + mock_news.update_changelog.side_effect = [Exception("Changelog error")] + + # Act + result = BackportPrepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + expected_body = ( + "* PR: #456\n" + "* From version: 1.7\n" + "* To version: 1.8\n" + "\n" + "## Tasks\n" + "\n" + "- [ ] Verify apply 1.7 | status=failed-conflict\n" + "- [ ] Verify apply 1.8 | status=failed-changelog\n" + "- [ ] Track Release 1.7.2\n" + "- [ ] Track Release 1.8.1" + ) + assert mock_gh.issues[123]["body"] == expected_body diff --git a/tests/tools/private/release/changelog_news_test.py b/tests/tools/private/release/changelog_news_test.py index 83ab2cc959..38bba1eecf 100644 --- a/tests/tools/private/release/changelog_news_test.py +++ b/tests/tools/private/release/changelog_news_test.py @@ -1,15 +1,13 @@ import pathlib -import unittest -from unittest.mock import patch -from tests.tools.private.release.release_test_helper import TempDirTestCase +import pytest + from tools.private.release import changelog_news -class ChangelogNewsTest(TempDirTestCase): - def test_update_changelog_with_news(self): - # Arrange - changelog = """# Changelog +def test_update_changelog_with_news(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -29,78 +27,76 @@ def test_update_changelog_with_news(self): ### Added * (toolchains) Some older change. """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Create news files - (news_dir / "123.fixed.md").write_text("Fixed a bug in the compiler") - # Test that it handles prefixing "* " if not present - (news_dir / "456.added.md").write_text("* Added a new feature for Python 3.13") - # Empty file should be ignored - (news_dir / "789.changed.md").write_text("") - # Invalid name should be ignored - (news_dir / "invalid_name.md").write_text("Should be ignored") - - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Assert - # 1. News files matching the pattern should be deleted (even empty ones) - self.assertFalse((news_dir / "123.fixed.md").exists()) - self.assertFalse((news_dir / "456.added.md").exists()) - self.assertFalse((news_dir / "789.changed.md").exists()) - # Invalid name does not match pattern -> NOT deleted - self.assertTrue((news_dir / "invalid_name.md").exists()) - - new_content = changelog_path.read_text() - - # 2. A fresh active Unreleased section should be present - self.assertIn("{#unreleased}", new_content) - self.assertIn("## Unreleased", new_content) - self.assertIn( - "Unreleased changes are tracked as individual files in the [news/](./news)\n" - "directory, or view the [latest generated\n" - "changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).", - new_content, - ) - - # 3. The new release section should be present - self.assertIn("{#v3-0-0}", new_content) - self.assertIn("## [3.0.0] - 2026-06-16", new_content) - self.assertIn( - "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", - new_content, - ) - - # 4. Correct categories and content - self.assertIn( - "{#v3-0-0-fixed}\n### Fixed\n* Fixed a bug in the compiler", - new_content, - ) - self.assertIn( - "{#v3-0-0-added}\n### Added\n* Added a new feature for Python 3.13", - new_content, - ) - - # 5. Omitted categories should NOT be present in the new release - self.assertNotIn("{#v3-0-0-removed}", new_content) - self.assertNotIn("{#v3-0-0-changed}", new_content) - - # 6. Old release should still be there - self.assertIn("{#v2-0-2}", new_content) - self.assertIn("## [2.0.2] - 2026-05-14", new_content) - - def test_update_changelog_sorting(self): - # Arrange - changelog = """# Changelog + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = tmp_path / "news" + news_dir.mkdir() + + # Create news files + (news_dir / "123.fixed.md").write_text("Fixed a bug in the compiler") + # Test that it handles prefixing "* " if not present + (news_dir / "456.added.md").write_text("* Added a new feature for Python 3.13") + # Empty file should be ignored + (news_dir / "789.changed.md").write_text("") + # Invalid name should be ignored + (news_dir / "invalid_name.md").write_text("Should be ignored") + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + # 1. News files matching the pattern should be deleted (even empty ones) + assert not (news_dir / "123.fixed.md").exists() + assert not (news_dir / "456.added.md").exists() + assert not (news_dir / "789.changed.md").exists() + # Invalid name does not match pattern -> NOT deleted + assert (news_dir / "invalid_name.md").exists() + + new_content = changelog_path.read_text() + + # 2. A fresh active Unreleased section should be present + assert "{#unreleased}" in new_content + assert "## Unreleased" in new_content + assert ( + "Unreleased changes are tracked as individual files in the [news/](./news)\n" + "directory, or view the [latest generated\n" + "changelog](https://rules-python.readthedocs.io/en/latest/changelog.html)." + in new_content + ) + + # 3. The new release section should be present + assert "{#v3-0-0}" in new_content + assert "## [3.0.0] - 2026-06-16" in new_content + assert ( + "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0" + in new_content + ) + + # 4. Correct categories and content + assert "{#v3-0-0-fixed}\n### Fixed\n* Fixed a bug in the compiler" in new_content + assert ( + "{#v3-0-0-added}\n### Added\n* Added a new feature for Python 3.13" + in new_content + ) + + # 5. Omitted categories should NOT be present in the new release + assert "{#v3-0-0-removed}" not in new_content + assert "{#v3-0-0-changed}" not in new_content + + # 6. Old release should still be there + assert "{#v2-0-2}" in new_content + assert "## [2.0.2] - 2026-05-14" in new_content + + +def test_update_changelog_sorting(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -120,62 +116,59 @@ def test_update_changelog_sorting(self): ### Added * (toolchains) Some older change. """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) - news_dir = self.tmpdir / "news" - news_dir.mkdir() + news_dir = tmp_path / "news" + news_dir.mkdir() - # Create news files with different sub-categories and some without - (news_dir / "1.fixed.md").write_text("* (zebra) Zebra fix") - (news_dir / "2.fixed.md").write_text("* (apple) Apple fix") - (news_dir / "3.fixed.md").write_text("No subcategory B") - (news_dir / "4.fixed.md").write_text("* (apple) Another apple fix") - (news_dir / "5.fixed.md").write_text("No subcategory A") + # Create news files with different sub-categories and some without + (news_dir / "1.fixed.md").write_text("* (zebra) Zebra fix") + (news_dir / "2.fixed.md").write_text("* (apple) Apple fix") + (news_dir / "3.fixed.md").write_text("No subcategory B") + (news_dir / "4.fixed.md").write_text("* (apple) Another apple fix") + (news_dir / "5.fixed.md").write_text("No subcategory A") - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + expected_fixed_section = ( + "### Fixed\n" + "* No subcategory A\n" + "* No subcategory B\n" + "* (apple) Another apple fix\n" + "* (apple) Apple fix\n" + "* (zebra) Zebra fix\n" + ) + + assert expected_fixed_section in new_content - # Assert - new_content = changelog_path.read_text() - - # Expected order in Fixed section: - # 1. No subcategory A - # 2. No subcategory B - # 3. (apple) Another apple fix - # 4. (apple) Apple fix - # 5. (zebra) Zebra fix - - expected_fixed_section = ( - "### Fixed\n" - "* No subcategory A\n" - "* No subcategory B\n" - "* (apple) Another apple fix\n" - "* (apple) Apple fix\n" - "* (zebra) Zebra fix\n" - ) - self.assertIn(expected_fixed_section, new_content) +pytest_plugins = ["tests.tools.private.release.release_test_helper"] - def test_update_changelog_read_failure(self): - # Arrange - original_read_text = pathlib.Path.read_text - with patch("pathlib.Path.read_text", autospec=True) as mock_read_text: +def test_update_changelog_read_failure(mocker, tmp_path): + # Arrange + original_read_text = pathlib.Path.read_text - def side_effect(path_self, *args, **kwargs): - if "bad_file.fixed.md" in str(path_self): - raise IOError("Simulated read error") - return original_read_text(path_self, *args, **kwargs) + mock_read_text = mocker.patch.object(pathlib.Path, "read_text", autospec=True) - mock_read_text.side_effect = side_effect + def side_effect(path_self, *args, **kwargs): + if "bad_file.fixed.md" in str(path_self): + raise IOError("Simulated read error") + return original_read_text(path_self, *args, **kwargs) - changelog = """# Changelog + mock_read_text.side_effect = side_effect + + changelog = """# Changelog {#unreleased} ## Unreleased @@ -195,41 +188,41 @@ def side_effect(path_self, *args, **kwargs): ### Added * (toolchains) Some older change. """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Create the bad file (must exist so it is found by iterdir) - bad_file = news_dir / "bad_file.fixed.md" - bad_file.write_text("some content that won't be read") - - # Create a good file too - good_file = news_dir / "good_file.fixed.md" - good_file.write_text("* (sub) Good fix") - - # Act & Assert - # It should raise IOError - with self.assertRaises(IOError): - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Both files should still exist (no deletion on failure!) - self.assertTrue(bad_file.exists()) - self.assertTrue(good_file.exists()) - - # Changelog should not be modified - new_content = changelog_path.read_text() - self.assertEqual(changelog, new_content) - - def test_update_changelog_merge_existing(self): - # Arrange - changelog = """# Changelog + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = tmp_path / "news" + news_dir.mkdir() + + # Create the bad file (must exist so it is found by iterdir) + bad_file = news_dir / "bad_file.fixed.md" + bad_file.write_text("some content that won't be read") + + # Create a good file too + good_file = news_dir / "good_file.fixed.md" + good_file.write_text("* (sub) Good fix") + + # Act & Assert + with pytest.raises(IOError): + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Both files should still exist (no deletion on failure!) + assert bad_file.exists() + assert good_file.exists() + + # Changelog should not be modified + new_content = changelog_path.read_text() + assert changelog == new_content + + +def test_update_changelog_merge_existing(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -252,57 +245,48 @@ def test_update_changelog_merge_existing(self): * nested bullet item * (pypi) Z old fix """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) - news_dir = self.tmpdir / "news" - news_dir.mkdir() + news_dir = tmp_path / "news" + news_dir.mkdir() - # Create news files to merge - # 1. New fix in same category (should merge and sort) - (news_dir / "1.fixed.md").write_text("(pypi) New fix") - # 2. New entry in new category (should create category) - (news_dir / "2.added.md").write_text("(toolchains) New feature") + # Create news files to merge + (news_dir / "1.fixed.md").write_text("(pypi) New fix") + (news_dir / "2.added.md").write_text("(toolchains) New feature") - # Act - changelog_news.update_changelog( - "2.0.3", - "2026-06-15", - changelog_path=changelog_path, - news_dir=news_dir, - ) + # Act + changelog_news.update_changelog( + "2.0.3", + "2026-06-15", + changelog_path=changelog_path, + news_dir=news_dir, + ) - # Assert - # News files should be deleted - self.assertFalse((news_dir / "1.fixed.md").exists()) - self.assertFalse((news_dir / "2.added.md").exists()) - - new_content = changelog_path.read_text() - - # Expected merged and sorted Fixed section: - # 1. (pypi) New fix (New < Old) - # 2. (pypi) Old fix (with its multi-line detail!) - # 3. (pypi) Z old fix - expected_fixed_section = ( - "### Fixed\n" - "* (pypi) New fix\n" - "* (pypi) Old fix\n" - " multi-line detail\n" - " * nested bullet item\n" - "* (pypi) Z old fix\n" - ) - self.assertIn(expected_fixed_section, new_content) + # Assert + assert not (news_dir / "1.fixed.md").exists() + assert not (news_dir / "2.added.md").exists() + + new_content = changelog_path.read_text() - # Expected created Added section: - expected_added_section = "### Added\n* (toolchains) New feature\n" - self.assertIn(expected_added_section, new_content) + expected_fixed_section = ( + "### Fixed\n" + "* (pypi) New fix\n" + "* (pypi) Old fix\n" + " multi-line detail\n" + " * nested bullet item\n" + "* (pypi) Z old fix\n" + ) + assert expected_fixed_section in new_content - # Active Unreleased section should NOT be touched (should still be empty/pointing to news) - self.assertIn("Unreleased changes are tracked as individual files", new_content) + expected_added_section = "### Added\n* (toolchains) New feature\n" + assert expected_added_section in new_content + assert "Unreleased changes are tracked as individual files" in new_content - def test_update_changelog_does_not_leak(self): - # Arrange - changelog = """# Changelog + +def test_update_changelog_does_not_leak(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -320,33 +304,32 @@ def test_update_changelog_does_not_leak(self): This release body mentions the word unreleased and {#unreleased} anchor to test leaks. """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) - news_dir = self.tmpdir / "news" - news_dir.mkdir() - (news_dir / "1.fixed.md").write_text("Some fix") + news_dir = tmp_path / "news" + news_dir.mkdir() + (news_dir / "1.fixed.md").write_text("Some fix") - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) - # Assert - new_content = changelog_path.read_text() + # Assert + new_content = changelog_path.read_text() + assert ( + "This release body mentions the word unreleased and {#unreleased} anchor to test leaks." + in new_content + ) - # The 2.0.2 body should NOT be modified - self.assertIn( - "This release body mentions the word unreleased and {#unreleased} anchor to test leaks.", - new_content, - ) - def test_update_changelog_empty_news(self): - # Arrange - changelog = """# Changelog +def test_update_changelog_empty_news(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -366,39 +349,37 @@ def test_update_changelog_empty_news(self): ### Added * (toolchains) Some older change. """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - - # Act - changelog_news.update_changelog( - "3.0.0", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - ) - - # Assert - new_content = changelog_path.read_text() - - # The new release section should be present and contain "No notable changes." - self.assertIn("{#v3-0-0}", new_content) - self.assertIn("## [3.0.0] - 2026-06-16", new_content) - self.assertIn( - "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", - new_content, - ) - self.assertIn("No notable changes.", new_content) - - # Verify that we didn't accidentally create any categories - self.assertNotIn("{#v3-0-0-fixed}", new_content) - self.assertNotIn("{#v3-0-0-added}", new_content) - - def test_update_changelog_selective_news_files(self): - # Arrange - changelog = """# Changelog + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = tmp_path / "news" + news_dir.mkdir() + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + assert "{#v3-0-0}" in new_content + assert "## [3.0.0] - 2026-06-16" in new_content + assert ( + "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0" + in new_content + ) + assert "No notable changes." in new_content + assert "{#v3-0-0-fixed}" not in new_content + assert "{#v3-0-0-added}" not in new_content + + +def test_update_changelog_selective_news_files(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -410,39 +391,37 @@ def test_update_changelog_selective_news_files(self): [2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) - news_dir = self.tmpdir / "news" - news_dir.mkdir() + news_dir = tmp_path / "news" + news_dir.mkdir() - # Create news files - (news_dir / "123.fixed.md").write_text("Fix A") - (news_dir / "456.fixed.md").write_text("Fix B") + # Create news files + (news_dir / "123.fixed.md").write_text("Fix A") + (news_dir / "456.fixed.md").write_text("Fix B") - # Act: Only process 123.fixed.md - changelog_news.update_changelog( - "2.0.3", - "2026-06-16", - changelog_path=changelog_path, - news_dir=news_dir, - news_files=[news_dir / "123.fixed.md"], - ) + # Act: Only process 123.fixed.md + changelog_news.update_changelog( + "2.0.3", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + news_files=[news_dir / "123.fixed.md"], + ) - # Assert - # 1. Only 123.fixed.md should be deleted - self.assertFalse((news_dir / "123.fixed.md").exists()) - self.assertTrue((news_dir / "456.fixed.md").exists()) + # Assert + assert not (news_dir / "123.fixed.md").exists() + assert (news_dir / "456.fixed.md").exists() - new_content = changelog_path.read_text() + new_content = changelog_path.read_text() + assert "Fix A" in new_content + assert "Fix B" not in new_content - # 2. Only Fix A should be in the changelog - self.assertIn("Fix A", new_content) - self.assertNotIn("Fix B", new_content) - def test_update_changelog_insertion_point(self): - # Arrange - changelog = """# Changelog +def test_update_changelog_insertion_point(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -459,35 +438,35 @@ def test_update_changelog_insertion_point(self): [2.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0 """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) - news_dir = self.tmpdir / "news" - news_dir.mkdir() - (news_dir / "123.fixed.md").write_text("Fix in 2.1.0") + news_dir = tmp_path / "news" + news_dir.mkdir() + (news_dir / "123.fixed.md").write_text("Fix in 2.1.0") - # Act: Insert 2.1.0 - changelog_news.update_changelog( - "2.1.0", - "2026-06-17", - changelog_path=changelog_path, - news_dir=news_dir, - ) + # Act: Insert 2.1.0 + changelog_news.update_changelog( + "2.1.0", + "2026-06-17", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() - # Assert - new_content = changelog_path.read_text() + idx_2_2_0 = new_content.index("{#v2-2-0}") + idx_2_1_0 = new_content.index("{#v2-1-0}") + idx_2_0_0 = new_content.index("{#v2-0-0}") - # Verify 2.1.0 is inserted BEFORE 2.0.0 but AFTER 2.2.0 - idx_2_2_0 = new_content.index("{#v2-2-0}") - idx_2_1_0 = new_content.index("{#v2-1-0}") - idx_2_0_0 = new_content.index("{#v2-0-0}") + assert idx_2_2_0 < idx_2_1_0 < idx_2_0_0 + assert "Fix in 2.1.0" in new_content - self.assertTrue(idx_2_2_0 < idx_2_1_0 < idx_2_0_0) - self.assertIn("Fix in 2.1.0", new_content) - def test_update_changelog_insertion_point_too_small(self): - # Arrange - changelog = """# Changelog +def test_update_changelog_insertion_point_too_small(tmp_path): + # Arrange + changelog = """# Changelog {#unreleased} ## Unreleased @@ -499,26 +478,20 @@ def test_update_changelog_insertion_point_too_small(self): [2.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0 """ - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - news_dir = self.tmpdir / "news" - news_dir.mkdir() - (news_dir / "123.fixed.md").write_text("Fix in 1.0.0") - - # Act & Assert - with self.assertRaises(ValueError) as ctx: - changelog_news.update_changelog( - "1.0.0", - "2026-01-01", - changelog_path=changelog_path, - news_dir=news_dir, - ) - self.assertIn( - "Could not find a version in CHANGELOG.md smaller than 1.0.0", - str(ctx.exception), - ) + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) + news_dir = tmp_path / "news" + news_dir.mkdir() + (news_dir / "123.fixed.md").write_text("Fix in 1.0.0") -if __name__ == "__main__": - unittest.main() + # Act & Assert + with pytest.raises( + ValueError, match="Could not find a version in CHANGELOG.md smaller than 1.0.0" + ): + changelog_news.update_changelog( + "1.0.0", + "2026-01-01", + changelog_path=changelog_path, + news_dir=news_dir, + ) diff --git a/tests/tools/private/release/complete_sync_changelog_test.py b/tests/tools/private/release/complete_sync_changelog_test.py index 66c40c01d3..d0eb148b57 100644 --- a/tests/tools/private/release/complete_sync_changelog_test.py +++ b/tests/tools/private/release/complete_sync_changelog_test.py @@ -1,36 +1,18 @@ import argparse -import unittest -from unittest.mock import patch -from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.complete_sync_changelog import CompleteSyncChangelog +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class CompleteSyncChangelogTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - self.addCleanup(patch.stopall) - # Dynamic mock for issue body - self.issue_body = "" - - def mock_get_body(issue_num): - return self.issue_body - - def mock_update_body(issue_num, body): - self.issue_body = body - - self.mock_gh.get_issue_body.side_effect = mock_get_body - self.mock_gh.update_issue_body.side_effect = mock_update_body - - def test_complete_sync_changelog_success(self): - args = argparse.Namespace(pr=999) - self.mock_gh.get_pr_info.return_value = { - "state": "MERGED", - "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", - "mergeCommit": {"oid": "abcdef1234567890"}, - } - self.issue_body = """ +def test_complete_sync_changelog_success(mock_gh): + args = argparse.Namespace(pr=999) + mock_gh.prs[999] = { + "state": "MERGED", + "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + issue_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -41,64 +23,66 @@ def test_complete_sync_changelog_success(self): ## Backports """ - result = CompleteSyncChangelog(args, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.get_pr_info.assert_called_once_with(999) - self.mock_gh.get_issue_body.assert_called_once_with(123) - self.mock_gh.update_issue_body.assert_called_once() - - # Check that only tasks pointing to #999 were marked checked=True and status=done - self.assertIn( - "- [x] Sync Changelog #124 | status=done pr=#999 commit= abcdef12", - self.issue_body, - ) - self.assertIn( - "- [x] Sync Changelog #125 | status=done pr=#999 commit= abcdef12", - self.issue_body, - ) - # Task pointing to #888 should remain unchanged - self.assertIn( - "- [ ] Sync Changelog #126 | status=pending pr=#888", - self.issue_body, - ) - - def test_complete_sync_changelog_not_merged(self): - args = argparse.Namespace(pr=999) - self.mock_gh.get_pr_info.return_value = { - "state": "OPEN", - "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", - } - - result = CompleteSyncChangelog(args, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.get_pr_info.assert_called_once_with(999) - self.mock_gh.update_issue_body.assert_not_called() - - def test_complete_sync_changelog_missing_tracking_issue_link(self): - args = argparse.Namespace(pr=999) - self.mock_gh.get_pr_info.return_value = { - "state": "MERGED", - "body": "Updates CHANGELOG.md without tracking issue link", - "mergeCommit": {"oid": "abcdef1234567890"}, - } - - result = CompleteSyncChangelog(args, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.get_pr_info.assert_called_once_with(999) - self.mock_gh.update_issue_body.assert_not_called() - - def test_complete_sync_changelog_no_matching_tasks(self): - args = argparse.Namespace(pr=999) - self.mock_gh.get_pr_info.return_value = { - "state": "MERGED", - "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", - "mergeCommit": {"oid": "abcdef1234567890"}, - } - # Checklist has no tasks pointing to #999 - self.issue_body = """ + mock_gh.issues[123] = { + "title": "Release 2.1.0", + "body": issue_body, + "labels": ["type: release"], + "number": 123, + "url": "https://github.com/bazel-contrib/rules_python/issues/123", + } + + result = CompleteSyncChangelog(args, mock_gh).run() + + assert result == 0 + updated_body = mock_gh.get_issue_body(123) + + # Check that only tasks pointing to #999 were marked checked=True and status=done + assert ( + "- [x] Sync Changelog #124 | status=done pr=#999 commit= abcdef12" + in updated_body + ) + assert ( + "- [x] Sync Changelog #125 | status=done pr=#999 commit= abcdef12" + in updated_body + ) + # Task pointing to #888 should remain unchanged + assert "- [ ] Sync Changelog #126 | status=pending pr=#888" in updated_body + + +def test_complete_sync_changelog_not_merged(mock_gh): + args = argparse.Namespace(pr=999) + mock_gh.prs[999] = { + "state": "OPEN", + "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", + } + + result = CompleteSyncChangelog(args, mock_gh).run() + + assert result == 1 + + +def test_complete_sync_changelog_missing_tracking_issue_link(mock_gh): + args = argparse.Namespace(pr=999) + mock_gh.prs[999] = { + "state": "MERGED", + "body": "Updates CHANGELOG.md without tracking issue link", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + + result = CompleteSyncChangelog(args, mock_gh).run() + + assert result == 1 + + +def test_complete_sync_changelog_no_matching_tasks(mock_gh): + args = argparse.Namespace(pr=999) + mock_gh.prs[999] = { + "state": "MERGED", + "body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + # Checklist has no tasks pointing to #999 + issue_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -107,14 +91,15 @@ def test_complete_sync_changelog_no_matching_tasks(self): ## Backports """ - result = CompleteSyncChangelog(args, self.mock_gh).run() - - # Should log warning but return 0 (success/noop) - self.assertEqual(result, 0) - self.mock_gh.get_pr_info.assert_called_once_with(999) - self.mock_gh.get_issue_body.assert_called_once_with(123) - self.mock_gh.update_issue_body.assert_not_called() - - -if __name__ == "__main__": - unittest.main() + mock_gh.issues[123] = { + "title": "Release 2.1.0", + "body": issue_body, + "labels": ["type: release"], + "number": 123, + "url": "https://github.com/bazel-contrib/rules_python/issues/123", + } + + result = CompleteSyncChangelog(args, mock_gh).run() + + assert result == 0 + assert mock_gh.get_issue_body(123) == issue_body diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 8e4ed101e4..6aee746dbb 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -2,177 +2,163 @@ import os import pathlib import tempfile -import unittest -from unittest.mock import MagicMock, call, patch +from unittest.mock import call -from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.create_rc import CreateRc +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class CmdCreateRcTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - def test_create_rc_success_first_rc(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +def test_create_rc_success_first_rc(mocker, mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [ ] Tag RC0 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - with tempfile.TemporaryDirectory() as tmpdir: - github_output_file = pathlib.Path(tmpdir) / "github_output" - with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output_file)}): - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.assertTrue(github_output_file.exists()) - self.assertEqual(github_output_file.read_text(), "tag_name=2.0.0-rc0\n") - self.mock_git.fetch.assert_has_calls( - [call("my-remote"), call("my-remote", tags=True, force=True)] - ) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") - self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("tag=2.0.0-rc0", call_args[1]) - self.assertIn("commit= 12345678", call_args[1]) - - self.mock_gh.post_issue_comment.assert_called_once() - comment_call_args = self.mock_gh.post_issue_comment.call_args[0] - self.assertEqual(comment_call_args[0], 123) - self.assertIn( - "**New Release Candidate Tagged!** 🐍🌿", - comment_call_args[1], - ) - self.assertIn( - "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)", - comment_call_args[1], - ) - self.assertIn( - "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)", - comment_call_args[1], - ) - self.assertIn( - "- [BCR Entry 2.0.0-rc0](https://registry.bazel.build/modules/rules_python/2.0.0-rc0)", - comment_call_args[1], - ) - self.assertIn( - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0-rc0)", - comment_call_args[1], - ) - self.assertIn( - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml)", - comment_call_args[1], - ) - - def test_create_rc_success_with_run_id(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + with tempfile.TemporaryDirectory() as tmpdir: + github_output_file = pathlib.Path(tmpdir) / "github_output" + mocker.patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output_file)}) + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert github_output_file.exists() + assert github_output_file.read_text() == "tag_name=2.0.0-rc0\n" + + mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + mock_git.checkout.assert_not_called() + mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") + + updated_body = mock_gh.get_issue_body(123) + assert "tag=2.0.0-rc0" in updated_body + assert "commit= 12345678" in updated_body + + assert 123 in mock_gh.issue_comments + comment_text = mock_gh.issue_comments[123][0] + assert "**New Release Candidate Tagged!** 🐍🌿" in comment_text + assert ( + "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)" + in comment_text + ) + assert ( + "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)" + in comment_text + ) + assert ( + "- [BCR Entry 2.0.0-rc0](https://registry.bazel.build/modules/rules_python/2.0.0-rc0)" + in comment_text + ) + assert ( + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0-rc0)" + in comment_text + ) + assert ( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml)" + in comment_text + ) + + +def test_create_rc_success_with_run_id(mocker, mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [ ] Tag RC0 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - with patch.dict(os.environ, {"GITHUB_RUN_ID": "987654321"}): - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_gh.post_issue_comment.assert_called_once() - comment_call_args = self.mock_gh.post_issue_comment.call_args[0] - self.assertIn( - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/runs/987654321)", - comment_call_args[1], - ) - self.assertIn( - "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)", - comment_call_args[1], - ) - - def test_create_rc_success_next_rc(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + mocker.patch.dict(os.environ, {"GITHUB_RUN_ID": "987654321"}) + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert 123 in mock_gh.issue_comments + comment_text = mock_gh.issue_comments[123][0] + assert ( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/runs/987654321)" + in comment_text + ) + assert ( + "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)" + in comment_text + ) + + +def test_create_rc_success_next_rc(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 - [ ] Tag RC1 | status=pending -""" - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [call("my-remote"), call("my-remote", tags=True, force=True)] - ) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") - self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("tag=2.0.0-rc1", call_args[1]) - - self.mock_gh.post_issue_comment.assert_called_once() - comment_call_args = self.mock_gh.post_issue_comment.call_args[0] - self.assertEqual(comment_call_args[0], 123) - self.assertIn( - "**New Release Candidate Tagged!** 🐍🌿", - comment_call_args[1], - ) - self.assertIn( - "tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0)", - comment_call_args[1], - ) - self.assertIn( - "- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)", - comment_call_args[1], - ) - self.assertIn( - "- [BCR Entry 2.0.0-rc1](https://registry.bazel.build/modules/rules_python/2.0.0-rc1)", - comment_call_args[1], - ) - self.assertIn( - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0-rc1)", - comment_call_args[1], - ) - self.assertIn( - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml)", - comment_call_args[1], - ) - - def test_create_rc_gating_on_backports(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + mock_git.checkout.assert_not_called() + mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") + mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") + mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") + + updated_body = mock_gh.get_issue_body(123) + assert "tag=2.0.0-rc1" in updated_body + + assert 123 in mock_gh.issue_comments + comment_text = mock_gh.issue_comments[123][0] + assert "**New Release Candidate Tagged!** 🐍🌿" in comment_text + + +def test_create_rc_gating_on_backports(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 @@ -180,20 +166,26 @@ def test_create_rc_gating_on_backports(self): ## Backports - [ ] #124 | status=pending -""" - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - - def test_create_rc_not_blocked_by_ignored_backports(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.tag.assert_not_called() + mock_git.push.assert_not_called() + + +def test_create_rc_not_blocked_by_ignored_backports(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 @@ -201,23 +193,29 @@ def test_create_rc_not_blocked_by_ignored_backports(self): ## Backports - [ ] #124 | status=ignore -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") - - def test_create_rc_with_finished_backports(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + + +def test_create_rc_with_finished_backports(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 @@ -225,182 +223,198 @@ def test_create_rc_with_finished_backports(self): ## Backports - [x] #124 | status=done rc=rc0 commit=abcdef12 -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") - - def test_create_rc_auto_add_task(self): - # Arrange - args = argparse.Namespace(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + + +def test_create_rc_auto_add_task(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 - [ ] Tag Final -""" - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") - - self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) - call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] - call2_args = self.mock_gh.update_issue_body.call_args_list[1][0] - - self.assertEqual(call1_args[0], 123) - self.assertIn("- [ ] Tag RC1", call1_args[1]) - self.assertIn( - "- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]" - " Tag RC1\n- [ ] Tag Final", - call1_args[1].strip(), - ) - - self.assertEqual(call2_args[0], 123) - self.assertIn( - "- [x] Tag RC1 | status=done tag=2.0.0-rc1 commit= 12345678", - call2_args[1], - ) - - @patch("tools.private.release.create_rc.ProcessBackports") - def test_create_rc_calls_process_backports(self, mock_pb_class): - # Arrange - mock_pb = mock_pb_class.return_value - mock_pb.run.return_value = 0 - - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") + mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") + + updated_body = mock_gh.get_issue_body(123) + assert "- [x] Tag RC1 | status=done tag=2.0.0-rc1 commit= 12345678" in updated_body + + +def test_create_rc_calls_process_backports(mocker, mock_git, mock_gh): + # Arrange + mock_pb_class = mocker.patch("tools.private.release.create_rc.ProcessBackports") + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 + + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [ ] Tag RC0 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - mock_pb_class.assert_called_once() - called_args = mock_pb_class.call_args[0][0] - self.assertEqual(called_args.issue, 123) - self.assertEqual(called_args.remote, "my-remote") - self.assertFalse(called_args.dry_run) - self.assertIsNone(called_args.add) - self.assertIsNone(called_args.triggering_comment) - mock_pb.run.assert_called_once() - - @patch("tools.private.release.create_rc.ProcessBackports") - def test_create_rc_aborts_on_process_backports_failure(self, mock_pb_class): - # Arrange - mock_pb = mock_pb_class.return_value - mock_pb.run.return_value = 1 - - args = MagicMock(issue=123, remote="my-remote") - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - mock_pb_class.assert_called_once() - mock_pb.run.assert_called_once() - self.mock_gh.get_issue_body.assert_not_called() - self.mock_git.tag.assert_not_called() - - @patch("tools.private.release.create_rc.ProcessBackports") - def test_create_rc_failure_reacts_to_comment(self, mock_pb_class): - # Arrange - mock_pb = mock_pb_class.return_value - mock_pb.run.return_value = 1 # Simulate failure - - args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.add_comment_reaction.assert_called_once_with(456, "-1") - - @patch("tools.private.release.create_rc.ProcessBackports") - def test_create_rc_failure_no_comment_no_reaction(self, mock_pb_class): - # Arrange - mock_pb = mock_pb_class.return_value - mock_pb.run.return_value = 1 # Simulate failure - - args = MagicMock(issue=123, remote="my-remote", triggering_comment=None) - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.add_comment_reaction.assert_not_called() - - @patch("tools.private.release.create_rc.ProcessBackports") - def test_create_rc_success_with_comment_no_reaction(self, mock_pb_class): - # Arrange - mock_pb = mock_pb_class.return_value - mock_pb.run.return_value = 0 - - args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_pb_class.assert_called_once() + called_args = mock_pb_class.call_args[0][0] + assert called_args.issue == 123 + assert called_args.remote == "my-remote" + assert not called_args.dry_run + assert called_args.add is None + assert called_args.triggering_comment is None + mock_pb.run.assert_called_once() + + +def test_create_rc_aborts_on_process_backports_failure(mocker, mock_git, mock_gh): + # Arrange + mock_pb_class = mocker.patch("tools.private.release.create_rc.ProcessBackports") + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 + + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_pb_class.assert_called_once() + mock_pb.run.assert_called_once() + mock_git.tag.assert_not_called() + + +def test_create_rc_failure_reacts_to_comment(mocker, mock_git, mock_gh): + # Arrange + mock_pb_class = mocker.patch("tools.private.release.create_rc.ProcessBackports") + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 # Simulate failure + + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=456, dry_run=False + ) + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + assert mock_gh.reactions.get(456) == ["-1"] + + +def test_create_rc_failure_no_comment_no_reaction(mocker, mock_git, mock_gh): + # Arrange + mock_pb_class = mocker.patch("tools.private.release.create_rc.ProcessBackports") + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 # Simulate failure + + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=None, dry_run=False + ) + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + assert 456 not in mock_gh.reactions + + +def test_create_rc_success_with_comment_no_reaction(mocker, mock_git, mock_gh): + # Arrange + mock_pb_class = mocker.patch("tools.private.release.create_rc.ProcessBackports") + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 + + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=456, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [ ] Tag RC0 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_git.get_commit_sha.return_value = "1234567890" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_gh.add_comment_reaction.assert_not_called() - - @patch("tools.private.release.create_rc.ProcessBackports") - def test_create_rc_precondition_failure_reacts_to_comment(self, mock_pb_class): - # Arrange - mock_pb = mock_pb_class.return_value - mock_pb.run.return_value = 0 # Backports succeed - - args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert 456 not in mock_gh.reactions + + +def test_create_rc_precondition_failure_reacts_to_comment(mocker, mock_git, mock_gh): + # Arrange + mock_pb_class = mocker.patch("tools.private.release.create_rc.ProcessBackports") + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 # Backports succeed + + args = argparse.Namespace( + issue=123, remote="my-remote", triggering_comment=456, dry_run=False + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release | status=pending - [ ] Create Release branch | status=pending - [ ] Tag RC0 | status=pending -""" - - # Act - result = CreateRc(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.add_comment_reaction.assert_called_once_with(456, "-1") +""", + "labels": ["type: release"], + } + # Act + result = CreateRc(args, mock_git, mock_gh).run() -if __name__ == "__main__": - unittest.main() + # Assert + assert result == 1 + assert mock_gh.reactions.get(456) == ["-1"] diff --git a/tests/tools/private/release/create_release_branch_test.py b/tests/tools/private/release/create_release_branch_test.py index 07e3652c85..332ffec851 100644 --- a/tests/tools/private/release/create_release_branch_test.py +++ b/tests/tools/private/release/create_release_branch_test.py @@ -1,149 +1,157 @@ -import unittest -from unittest.mock import MagicMock +import argparse -from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.create_release_branch import CreateReleaseBranch +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class CmdCreateReleaseBranchTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - def test_create_release_branch_success(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +def test_create_release_branch_success(mock_git, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [ ] Create Release branch | status=pending -""" - self.mock_git.branch_exists.return_value = False - self.mock_git.remote_branch_exists.return_value = False - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.checkout.assert_not_called() - self.mock_git.push.assert_called_once_with( - "my-remote", "abcdef12:refs/heads/release/2.0" - ) - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn( - "branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0", - call_args[1], - ) - self.assertIn("commit= abcdef12", call_args[1]) - - def test_create_release_branch_prepare_not_done(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.branch_exists.return_value = False + mock_git.remote_branch_exists.return_value = False + + # Act + result = CreateReleaseBranch(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_called_once_with("my-remote") + mock_git.checkout.assert_not_called() + mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) + + updated_body = mock_gh.get_issue_body(123) + assert ( + "branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0" + in updated_body + ) + assert "commit= abcdef12" in updated_body + + +def test_create_release_branch_prepare_not_done(mock_git, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release | status=pending - [ ] Create Release branch | status=pending -""" - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.fetch.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_create_release_branch_already_checked(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + # Act + result = CreateReleaseBranch(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.fetch.assert_not_called() + mock_git.push.assert_not_called() + + +def test_create_release_branch_already_checked(mock_git, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -""" - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_create_release_branch_already_exists_same_commit(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + # Act + result = CreateReleaseBranch(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_not_called() + mock_git.push.assert_not_called() + + +def test_create_release_branch_already_exists_same_commit(mock_git, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [ ] Create Release branch | status=pending -""" - self.mock_git.remote_branch_exists.return_value = True - self.mock_git.get_commit_sha.return_value = "abcdef12" - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist - - def test_create_release_branch_already_exists_fast_forward(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.remote_branch_exists.return_value = True + mock_git.get_commit_sha.return_value = "abcdef12" + + # Act + result = CreateReleaseBranch(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_called_once_with("my-remote") + mock_git.push.assert_not_called() + + +def test_create_release_branch_already_exists_fast_forward(mock_git, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [ ] Create Release branch | status=pending -""" - self.mock_git.remote_branch_exists.return_value = True - self.mock_git.get_commit_sha.return_value = "oldcommit" - self.mock_git.is_ancestor.return_value = True - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.push.assert_called_once_with( - "my-remote", "abcdef12:refs/heads/release/2.0" - ) - self.mock_gh.update_issue_body.assert_called_once() - - def test_create_release_branch_already_exists_non_ff(self): - # Arrange - args = MagicMock(issue=123, remote="my-remote") - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ +""", + "labels": ["type: release"], + } + mock_git.remote_branch_exists.return_value = True + mock_git.get_commit_sha.return_value = "oldcommit" + mock_git.is_ancestor.return_value = True + + # Act + result = CreateReleaseBranch(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_called_once_with("my-remote") + mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) + + +def test_create_release_branch_already_exists_non_ff(mock_git, mock_gh): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [ ] Create Release branch | status=pending -""" - self.mock_git.remote_branch_exists.return_value = True - self.mock_git.get_commit_sha.return_value = "othercommit" - self.mock_git.is_ancestor.return_value = False - - # Act - result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - -if __name__ == "__main__": - unittest.main() +""", + "labels": ["type: release"], + } + mock_git.remote_branch_exists.return_value = True + mock_git.get_commit_sha.return_value = "othercommit" + mock_git.is_ancestor.return_value = False + + # Act + result = CreateReleaseBranch(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.fetch.assert_called_once_with("my-remote") + mock_git.push.assert_not_called() diff --git a/tests/tools/private/release/gh_test.py b/tests/tools/private/release/gh_test.py index 4010d2dca5..ccc61e7f21 100644 --- a/tests/tools/private/release/gh_test.py +++ b/tests/tools/private/release/gh_test.py @@ -1,61 +1,61 @@ -import unittest -from unittest.mock import patch +import pytest from tools.private.release.gh import GitHub +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class GitHubTest(unittest.TestCase): - def setUp(self): - self.gh = GitHub("my-owner/my-repo") - - @patch("tools.private.release.gh.run_cmd") - def test_resolve_pr_number_digit(self, mock_run_cmd): - # 124 and #125 should resolve immediately without running command - self.assertEqual(self.gh.resolve_pr_number("124"), 124) - self.assertEqual(self.gh.resolve_pr_number("#125"), 125) - mock_run_cmd.assert_not_called() - - @patch("tools.private.release.gh.run_cmd") - def test_resolve_pr_number_url_simple(self, mock_run_cmd): - url = "https://github.com/my-owner/my-repo/pull/126" - # Should resolve via regex without calling gh - result = self.gh.resolve_pr_number(url) - self.assertEqual(result, 126) - mock_run_cmd.assert_not_called() - - @patch("tools.private.release.gh.run_cmd") - def test_resolve_pr_number_url_with_subpath(self, mock_run_cmd): - url = "https://github.com/my-owner/my-repo/pull/126/files" - # Should resolve via regex without calling gh - result = self.gh.resolve_pr_number(url) - self.assertEqual(result, 126) - mock_run_cmd.assert_not_called() - - @patch("tools.private.release.gh.run_cmd") - def test_resolve_pr_number_url_with_query(self, mock_run_cmd): - url = "https://github.com/my-owner/my-repo/pull/126/files?w=1" - # Should resolve via regex without calling gh - result = self.gh.resolve_pr_number(url) - self.assertEqual(result, 126) - mock_run_cmd.assert_not_called() - - @patch("tools.private.release.gh.run_cmd") - def test_resolve_pr_number_url_other_repo(self, mock_run_cmd): - # URL for a different repo should fail immediately without calling gh - url = "https://github.com/other-owner/other-repo/pull/126" - with self.assertRaises(ValueError) as ctx: - self.gh.resolve_pr_number(url) - self.assertIn("URL is not for the configured repository", str(ctx.exception)) - mock_run_cmd.assert_not_called() - - @patch("tools.private.release.gh.run_cmd") - def test_resolve_pr_number_invalid_ref(self, mock_run_cmd): - # Invalid reference (not number, not URL) should fail - with self.assertRaises(ValueError) as ctx: - self.gh.resolve_pr_number("invalid-ref") - self.assertIn("Could not resolve PR reference", str(ctx.exception)) - mock_run_cmd.assert_not_called() - - -if __name__ == "__main__": - unittest.main() + +@pytest.fixture(name="gh") +def fixture_gh(): + return GitHub("my-owner/my-repo") + + +def test_resolve_pr_number_digit(mocker, gh): + mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") + # 124 and #125 should resolve immediately without running command + assert gh.resolve_pr_number("124") == 124 + assert gh.resolve_pr_number("#125") == 125 + mock_run_cmd.assert_not_called() + + +def test_resolve_pr_number_url_simple(mocker, gh): + mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") + url = "https://github.com/my-owner/my-repo/pull/126" + # Should resolve via regex without calling gh + result = gh.resolve_pr_number(url) + assert result == 126 + mock_run_cmd.assert_not_called() + + +def test_resolve_pr_number_url_with_subpath(mocker, gh): + mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") + url = "https://github.com/my-owner/my-repo/pull/126/files" + # Should resolve via regex without calling gh + result = gh.resolve_pr_number(url) + assert result == 126 + mock_run_cmd.assert_not_called() + + +def test_resolve_pr_number_url_with_query(mocker, gh): + mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") + url = "https://github.com/my-owner/my-repo/pull/126/files?w=1" + # Should resolve via regex without calling gh + result = gh.resolve_pr_number(url) + assert result == 126 + mock_run_cmd.assert_not_called() + + +def test_resolve_pr_number_url_other_repo(mocker, gh): + mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") + # URL for a different repo should fail immediately without calling gh + url = "https://github.com/other-owner/other-repo/pull/126" + with pytest.raises(ValueError, match="URL is not for the configured repository"): + gh.resolve_pr_number(url) + mock_run_cmd.assert_not_called() + + +def test_resolve_pr_number_invalid(mocker, gh): + mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") + with pytest.raises(ValueError, match="Could not resolve PR reference"): + gh.resolve_pr_number("invalid-ref") + mock_run_cmd.assert_not_called() diff --git a/tests/tools/private/release/git_test.py b/tests/tools/private/release/git_test.py index 34643fc016..4a8cada4cc 100644 --- a/tests/tools/private/release/git_test.py +++ b/tests/tools/private/release/git_test.py @@ -1,189 +1,154 @@ -import unittest -from unittest.mock import patch +import subprocess + +import pytest from tools.private.release.git import Git +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +@pytest.fixture(name="git_obj") +def fixture_git_obj(mocker): + git = Git(".") + git.mock_run_git = mocker.patch.object(git, "_run_git") + return git + + +def test_checkout_simple(git_obj): + git_obj.checkout("my-branch") + git_obj.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False + ) + + +def test_checkout_track_remote_new_branch(mocker, git_obj): + mock_branch_exists = mocker.patch( + "tools.private.release.git.Git.branch_exists", return_value=False + ) + + git_obj.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + git_obj.mock_run_git.assert_called_once_with( + "checkout", "--track", "origin/my-branch", capture_output=False + ) + + +def test_checkout_track_remote_existing_branch(mocker, git_obj): + mock_branch_exists = mocker.patch( + "tools.private.release.git.Git.branch_exists", return_value=True + ) + mock_reset_hard = mocker.patch("tools.private.release.git.Git.reset_hard") + + git_obj.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + git_obj.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False + ) + mock_reset_hard.assert_called_once_with(reset_to="origin/my-branch") + + +def test_fetch_default(git_obj): + git_obj.fetch() + git_obj.mock_run_git.assert_called_once_with( + "fetch", "origin", capture_output=False + ) + + +def test_fetch_custom_remote(git_obj): + git_obj.fetch("upstream") + git_obj.mock_run_git.assert_called_once_with( + "fetch", "upstream", capture_output=False + ) + + +def test_fetch_with_refspec(git_obj): + git_obj.fetch("origin", refspec="my-branch") + git_obj.mock_run_git.assert_called_once_with( + "fetch", "origin", "my-branch", capture_output=False + ) + + +def test_fetch_with_tags_and_force(git_obj): + git_obj.fetch("origin", tags=True, force=True) + git_obj.mock_run_git.assert_called_once_with( + "fetch", "origin", "--tags", "--force", capture_output=False + ) + + +def test_fetch_all_options(git_obj): + git_obj.fetch("origin", refspec="my-branch", tags=True, force=True) + git_obj.mock_run_git.assert_called_once_with( + "fetch", "origin", "my-branch", "--tags", "--force", capture_output=False + ) + + +def test_get_modified_files(git_obj): + git_obj.mock_run_git.return_value = "file1.txt\nfile2.py\n\n" + files = git_obj.get_modified_files("HEAD") + git_obj.mock_run_git.assert_called_once_with( + "show", "--name-only", "--format=", "HEAD" + ) + assert files == ["file1.txt", "file2.py"] + + +def test_get_modified_files_empty(git_obj): + git_obj.mock_run_git.return_value = "" + files = git_obj.get_modified_files("HEAD") + assert files == [] + + +def test_diff_has_changes(git_obj): + git_obj.mock_run_git.return_value = "some diff output" + output = git_obj.diff() + git_obj.mock_run_git.assert_called_once_with("diff") + assert output == "some diff output" + + +def test_diff_empty(git_obj): + git_obj.mock_run_git.return_value = "" + output = git_obj.diff() + git_obj.mock_run_git.assert_called_once_with("diff") + assert output == "" + + +def test_apply(git_obj): + git_obj.apply("patch.patch") + git_obj.mock_run_git.assert_called_once_with( + "apply", "patch.patch", capture_output=False + ) + + +def test_apply_check_clean(git_obj): + git_obj.mock_run_git.return_value = "" + result = git_obj.apply_check("patch.patch") + git_obj.mock_run_git.assert_called_once_with( + "apply", "--check", "patch.patch", capture_output=False + ) + assert result is True + + +def test_apply_check_conflict(git_obj): + git_obj.mock_run_git.side_effect = subprocess.CalledProcessError( + 1, ["git", "apply", "--check", "patch.patch"] + ) + result = git_obj.apply_check("patch.patch") + git_obj.mock_run_git.assert_called_once_with( + "apply", "--check", "patch.patch", capture_output=False + ) + assert result is False + + +def test_reset_hard_default(git_obj): + git_obj.reset_hard() + git_obj.mock_run_git.assert_called_once_with( + "reset", "--hard", "HEAD", capture_output=False + ) + -class GitCheckoutTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_checkout_simple(self): - self.git.checkout("my-branch") - self.mock_run_git.assert_called_once_with( - "checkout", "my-branch", capture_output=False - ) - - @patch("tools.private.release.git.Git.branch_exists") - def test_checkout_track_remote_new_branch(self, mock_branch_exists): - mock_branch_exists.return_value = False - - self.git.checkout("my-branch", track_remote="origin") - - mock_branch_exists.assert_called_once_with("my-branch") - self.mock_run_git.assert_called_once_with( - "checkout", "--track", "origin/my-branch", capture_output=False - ) - - @patch("tools.private.release.git.Git.reset_hard") - @patch("tools.private.release.git.Git.branch_exists") - def test_checkout_track_remote_existing_branch( - self, mock_branch_exists, mock_reset_hard - ): - mock_branch_exists.return_value = True - - self.git.checkout("my-branch", track_remote="origin") - - mock_branch_exists.assert_called_once_with("my-branch") - self.mock_run_git.assert_called_once_with( - "checkout", "my-branch", capture_output=False - ) - mock_reset_hard.assert_called_once_with(reset_to="origin/my-branch") - - -class GitFetchTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_fetch_default(self): - self.git.fetch() - self.mock_run_git.assert_called_once_with( - "fetch", "origin", capture_output=False - ) - - def test_fetch_custom_remote(self): - self.git.fetch("upstream") - self.mock_run_git.assert_called_once_with( - "fetch", "upstream", capture_output=False - ) - - def test_fetch_with_refspec(self): - self.git.fetch("origin", refspec="my-branch") - self.mock_run_git.assert_called_once_with( - "fetch", "origin", "my-branch", capture_output=False - ) - - def test_fetch_with_tags_and_force(self): - self.git.fetch("origin", tags=True, force=True) - self.mock_run_git.assert_called_once_with( - "fetch", "origin", "--tags", "--force", capture_output=False - ) - - def test_fetch_all_options(self): - self.git.fetch("origin", refspec="my-branch", tags=True, force=True) - self.mock_run_git.assert_called_once_with( - "fetch", "origin", "my-branch", "--tags", "--force", capture_output=False - ) - - -class GitGetModifiedFilesTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_get_modified_files(self): - self.mock_run_git.return_value = "file1.txt\nfile2.py\n\n" - files = self.git.get_modified_files("HEAD") - self.mock_run_git.assert_called_once_with( - "show", "--name-only", "--format=", "HEAD" - ) - self.assertEqual(files, ["file1.txt", "file2.py"]) - - def test_get_modified_files_empty(self): - self.mock_run_git.return_value = "" - files = self.git.get_modified_files("HEAD") - self.assertEqual(files, []) - - -class GitDiffTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_diff_has_changes(self): - self.mock_run_git.return_value = "some diff output" - output = self.git.diff() - self.mock_run_git.assert_called_once_with("diff") - self.assertEqual(output, "some diff output") - - def test_diff_empty(self): - self.mock_run_git.return_value = "" - output = self.git.diff() - self.mock_run_git.assert_called_once_with("diff") - self.assertEqual(output, "") - - -class GitApplyTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_apply(self): - self.git.apply("patch.patch") - self.mock_run_git.assert_called_once_with( - "apply", "patch.patch", capture_output=False - ) - - -class GitApplyCheckTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_apply_check_clean(self): - self.mock_run_git.return_value = "" - result = self.git.apply_check("patch.patch") - self.mock_run_git.assert_called_once_with( - "apply", "--check", "patch.patch", capture_output=False - ) - self.assertTrue(result) - - def test_apply_check_conflict(self): - import subprocess - - self.mock_run_git.side_effect = subprocess.CalledProcessError( - 1, ["git", "apply", "--check", "patch.patch"] - ) - result = self.git.apply_check("patch.patch") - self.mock_run_git.assert_called_once_with( - "apply", "--check", "patch.patch", capture_output=False - ) - self.assertFalse(result) - - -class GitResetHardTest(unittest.TestCase): - def setUp(self): - self.git = Git(".") - self.patcher = patch.object(self.git, "_run_git") - self.mock_run_git = self.patcher.start() - self.addCleanup(self.patcher.stop) - - def test_reset_hard_default(self): - self.git.reset_hard() - self.mock_run_git.assert_called_once_with( - "reset", "--hard", "HEAD", capture_output=False - ) - - def test_reset_hard_custom(self): - self.git.reset_hard(reset_to="my-commit") - self.mock_run_git.assert_called_once_with( - "reset", "--hard", "my-commit", capture_output=False - ) - - -if __name__ == "__main__": - unittest.main() +def test_reset_hard_custom(git_obj): + git_obj.reset_hard(reset_to="my-commit") + git_obj.mock_run_git.assert_called_once_with( + "reset", "--hard", "my-commit", capture_output=False + ) diff --git a/tests/tools/private/release/on_pr_merged_test.py b/tests/tools/private/release/on_pr_merged_test.py index a5644c5705..df3698d1dd 100644 --- a/tests/tools/private/release/on_pr_merged_test.py +++ b/tests/tools/private/release/on_pr_merged_test.py @@ -1,112 +1,92 @@ import argparse -import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.on_pr_merged import OnPrMerged +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class CmdOnPrMergedTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - self.addCleanup(patch.stopall) - # Mock ProcessBackports - self.mock_process_patcher = patch( - "tools.private.release.on_pr_merged.ProcessBackports" - ) - self.mock_process_class = self.mock_process_patcher.start() - self.mock_process_instance = MagicMock() - self.mock_process_class.return_value = self.mock_process_instance - - def test_on_pr_merged_no_comment(self): - args = argparse.Namespace(pr=124, remote="origin", dry_run=True) - self.mock_gh.get_pr_comments.return_value = [ +def test_on_pr_merged_no_comment(mocker, mock_git, mock_gh): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + mock_gh.pr_comments = { + 124: [ {"body": "Some comment"}, {"body": "Another comment /backport_wrong"}, ] + } - result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + mock_pb = mocker.patch("tools.private.release.on_pr_merged.ProcessBackports") + result = OnPrMerged(args, mock_git, mock_gh).run() - self.assertEqual(result, 1) - self.mock_gh.get_pr_comments.assert_called_once_with(124) - self.mock_gh.get_open_tracking_issues.assert_not_called() - self.mock_process_class.assert_not_called() + assert result == 1 + mock_pb.assert_not_called() - def test_on_pr_merged_has_comment_no_active_release(self): - args = argparse.Namespace(pr=124, remote="origin", dry_run=True) - self.mock_gh.get_pr_comments.return_value = [ - {"body": "/backport"}, - ] - self.mock_gh.get_open_tracking_issues.return_value = [] - result = OnPrMerged(args, self.mock_git, self.mock_gh).run() +def test_on_pr_merged_has_comment_no_active_release(mocker, mock_git, mock_gh): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + mock_gh.pr_comments = {124: [{"body": "/backport"}]} - self.assertEqual(result, 1) - self.mock_gh.get_pr_comments.assert_called_once_with(124) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_not_called() - self.mock_process_class.assert_not_called() + mock_pb = mocker.patch("tools.private.release.on_pr_merged.ProcessBackports") + result = OnPrMerged(args, mock_git, mock_gh).run() - def test_on_pr_merged_has_comment_not_in_backports(self): - args = argparse.Namespace(pr=124, remote="origin", dry_run=True) - self.mock_gh.get_pr_comments.return_value = [ - {"body": " /backport "}, - ] - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."} - ] - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release + assert result == 1 + mock_pb.assert_not_called() -## Backports -- [ ] #125 | status=pending -""" - result = OnPrMerged(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.get_pr_comments.assert_called_once_with(124) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_called_once_with(456) - self.mock_process_class.assert_not_called() - - def test_on_pr_merged_success(self): - args = argparse.Namespace(pr=124, remote="origin", dry_run=True) - self.mock_gh.get_pr_comments.return_value = [ - {"body": "/backport"}, - ] - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."} - ] - self.mock_gh.get_issue_body.return_value = """ + +def test_on_pr_merged_has_comment_not_in_backports(mocker, mock_git, mock_gh): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + mock_gh.pr_comments = {124: [{"body": " /backport "}]} + mock_gh.create_issue( + title="Release 2.1.0", + body=""" ## Checklist - [ ] Prepare Release ## Backports -- [ ] #124 | status=pending -""" - self.mock_process_instance.run.return_value = 0 - - result = OnPrMerged(args, self.mock_git, self.mock_gh).run() +- [ ] #125 | status=pending +""", + labels=["type: release"], + ) - self.assertEqual(result, 0) - self.mock_gh.get_pr_comments.assert_called_once_with(124) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_called_once_with(456) + mock_pb = mocker.patch("tools.private.release.on_pr_merged.ProcessBackports") + result = OnPrMerged(args, mock_git, mock_gh).run() - # Verify ProcessBackports was instantiated with correct args - self.mock_process_class.assert_called_once() - called_args = self.mock_process_class.call_args[0][0] - self.assertEqual(called_args.issue, 456) - self.assertEqual(called_args.remote, "origin") - self.assertEqual(called_args.dry_run, True) - self.assertIsNone(called_args.add) - self.assertIsNone(called_args.triggering_comment) + assert result == 1 + mock_pb.assert_not_called() - # Verify ProcessBackports.run() was called - self.mock_process_instance.run.assert_called_once() +def test_on_pr_merged_success(mocker, mock_git, mock_gh): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + mock_gh.pr_comments = {124: [{"body": "/backport"}]} + issue_num = mock_gh.create_issue( + title="Release 2.1.0", + body=""" +## Checklist +- [ ] Prepare Release -if __name__ == "__main__": - unittest.main() +## Backports +- [ ] #124 | status=pending +""", + labels=["type: release"], + ) + + mock_pb_class = mocker.patch("tools.private.release.on_pr_merged.ProcessBackports") + mock_pb_instance = MagicMock() + mock_pb_instance.run.return_value = 0 + mock_pb_class.return_value = mock_pb_instance + + result = OnPrMerged(args, mock_git, mock_gh).run() + + assert result == 0 + + # Verify ProcessBackports was instantiated with correct args + mock_pb_class.assert_called_once() + called_args = mock_pb_class.call_args[0][0] + assert called_args.issue == issue_num + assert called_args.remote == "origin" + assert called_args.dry_run is True + assert called_args.add is None + assert called_args.triggering_comment is None + + # Verify ProcessBackports.run() was called + mock_pb_instance.run.assert_called_once() diff --git a/tests/tools/private/release/prepare_test.py b/tests/tools/private/release/prepare_test.py index 45b92a636f..3a3e886f45 100644 --- a/tests/tools/private/release/prepare_test.py +++ b/tests/tools/private/release/prepare_test.py @@ -1,250 +1,207 @@ -import unittest -from unittest.mock import MagicMock, patch - -from tests.tools.private.release.release_test_helper import ( - TempDirTestCase, - _mock_git_and_gh, -) -from tools.private.release.gh import ( - MultipleTrackingIssuesError, - NoTrackingIssueError, -) -from tools.private.release.prepare import Prepare +import argparse +from tools.private.release.prepare import Prepare -class CmdPrepareTest(TempDirTestCase): - def setUp(self): - super().setUp() - _mock_git_and_gh(self) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", "M foo"] - self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_called_once_with( - title="Prepare release v2.0.0", - body="Work towards #123", - base="main", - ) - self.mock_git.add_modified_and_deleted.assert_called_once() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_success_create_issue(self, mock_replace, mock_changelog): - # Arrange - template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" - template_dir.mkdir(parents=True, exist_ok=True) - template_file = template_dir / "release_tracking_template.md" - template_file.write_text("dummy template content") - - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", "M foo"] - self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( - "Not found" - ) - self.mock_gh.create_tracking_issue.return_value = 123 - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_gh.create_tracking_issue.assert_called_once_with( - "2.0.0", "dummy template content" - ) - self.mock_gh.create_pr.assert_called_once_with( - title="Prepare release v2.0.0", - body="Work towards #123", - base="main", - ) - self.mock_git.add_modified_and_deleted.assert_called_once() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", "M foo"] - self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ( - MultipleTrackingIssuesError("Multiple open tracking issues") - ) - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_not_called() - self.mock_git.add_modified_and_deleted.assert_not_called() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_dry_run(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=True) - self.mock_git.status.side_effect = [""] - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_not_called() - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.create_pr.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - self.mock_git.fetch.assert_called_once() - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_git.add_modified_and_deleted.assert_not_called() - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_use_associated_pr_from_tracking_issue( - self, mock_replace, mock_changelog - ): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", ""] - self.mock_git.branch_exists.return_value = True - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.get_open_pr.return_value = None - # PR #456 is already associated in the tracking issue - self.mock_gh.get_issue_body.return_value = ( - "- [ ] Prepare Release | status=pending pr=#456" - ) - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True, force=True - ) - self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") - self.mock_gh.create_pr.assert_not_called() # Should NOT create a new PR - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertIn("pr=#456", call_args[1]) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", ""] - self.mock_git.branch_exists.return_value = True - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.get_open_pr.return_value = None - # No PR associated in the tracking issue - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/789" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True, force=True - ) - self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") - self.mock_gh.create_pr.assert_called_once_with( - title="Prepare release v2.0.0", - body="Work towards #123", - base="main", - ) - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertIn("pr=#789", call_args[1]) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): - # Arrange - args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.status.side_effect = ["", ""] - self.mock_git.branch_exists.return_value = True - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_gh.get_open_pr.return_value = { - "number": 456, - "url": "https://github.com/foo/bar/pull/456", - } - self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_called_once_with("prepare-2.0.0") - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_called_once_with( - "origin", "prepare-2.0.0", set_upstream=True, force=True - ) - self.mock_gh.get_open_pr.assert_called_once_with("prepare-2.0.0") - self.mock_gh.create_pr.assert_not_called() - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertIn("pr=#456", call_args[1]) - - @patch("tools.private.release.prepare.changelog_news") - @patch("tools.private.release.prepare.replace_version_next") - def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): - # Arrange - template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" - template_dir.mkdir(parents=True, exist_ok=True) - template_file = template_dir / "release_tracking_template.md" - template_file.write_text("dummy template content") - - args = MagicMock(version="2.0.0", issue=None, dry_run=True) - self.mock_git.status.side_effect = [""] - self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( - "Not found" - ) - - # Act - result = Prepare(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.checkout.assert_not_called() - self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_not_called() - self.mock_git.add_modified_and_deleted.assert_not_called() - - -if __name__ == "__main__": - unittest.main() +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +def test_prepare_success_existing_issue(mocker, release_tool_env, mock_git, mock_gh): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=False) + mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Prepare Release", + labels=["type: release"], + ) # Assigns issue 1001 + mock_git.status.side_effect = ["", "M foo"] + mock_git.branch_exists.return_value = False + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert 1002 in mock_gh.prs + assert mock_gh.prs[1002]["title"] == "Prepare release v2.0.0" + assert mock_gh.prs[1002]["body"] == "Work towards #1001" + mock_git.add_modified_and_deleted.assert_called_once() + + +def test_prepare_success_create_issue(mocker, release_tool_env, mock_git, mock_gh): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange: release_tool_env sets up template_file automatically + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=False) + mock_git.status.side_effect = ["", "M foo"] + mock_git.branch_exists.return_value = False + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert 1001 in mock_gh.issues + assert mock_gh.issues[1001]["title"] == "Release 2.0.0" + assert 1002 in mock_gh.prs + assert mock_gh.prs[1002]["title"] == "Prepare release v2.0.0" + assert mock_gh.prs[1002]["body"] == "Work towards #1001" + mock_git.add_modified_and_deleted.assert_called_once() + + +def test_prepare_ambiguous_issue(mocker, release_tool_env, mock_git, mock_gh): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=False) + mock_gh.create_issue( + title="Release 2.0.0", body="issue 1", labels=["type: release"] + ) + mock_gh.create_issue( + title="Release 2.0.0", body="issue 2", labels=["type: release"] + ) + mock_git.status.side_effect = ["", "M foo"] + mock_git.branch_exists.return_value = False + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.add_modified_and_deleted.assert_not_called() + + +def test_prepare_dry_run(mocker, release_tool_env, mock_git, mock_gh): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=True) + mock_gh.create_issue(title="Release 2.0.0", body="body", labels=["type: release"]) + mock_git.status.side_effect = [""] + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.checkout.assert_not_called() + mock_git.commit.assert_not_called() + mock_git.push.assert_not_called() + mock_git.fetch.assert_called_once() + mock_git.add_modified_and_deleted.assert_not_called() + + +def test_prepare_use_associated_pr_from_tracking_issue( + mocker, release_tool_env, mock_git, mock_gh +): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=False) + mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Prepare Release | status=pending pr=#456", + labels=["type: release"], + ) + mock_git.status.side_effect = ["", ""] + mock_git.branch_exists.return_value = True + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.checkout.assert_called_once_with("prepare-2.0.0") + mock_git.commit.assert_not_called() + mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True, force=True + ) + updated_body = mock_gh.get_issue_body(1001) + assert "pr=#456" in updated_body + + +def test_prepare_create_pr_when_none_associated( + mocker, release_tool_env, mock_git, mock_gh +): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=False) + mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Prepare Release", + labels=["type: release"], + ) + mock_git.status.side_effect = ["", ""] + mock_git.branch_exists.return_value = True + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.checkout.assert_called_once_with("prepare-2.0.0") + mock_git.commit.assert_not_called() + mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True, force=True + ) + updated_body = mock_gh.get_issue_body(1001) + assert "pr=#1002" in updated_body + + +def test_prepare_reuse_existing_pr(mocker, release_tool_env, mock_git, mock_gh): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=False) + mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Prepare Release", + labels=["type: release"], + ) + mock_gh.prs[456] = { + "number": 456, + "head": "prepare-2.0.0", + "state": "OPEN", + "url": "https://github.com/foo/bar/pull/456", + } + mock_git.status.side_effect = ["", ""] + mock_git.branch_exists.return_value = True + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.checkout.assert_called_once_with("prepare-2.0.0") + mock_git.commit.assert_not_called() + mock_git.push.assert_called_once_with( + "origin", "prepare-2.0.0", set_upstream=True, force=True + ) + updated_body = mock_gh.get_issue_body(1001) + assert "pr=#456" in updated_body + + +def test_prepare_dry_run_no_issue(mocker, release_tool_env, mock_git, mock_gh): + mocker.patch("tools.private.release.prepare.replace_version_next") + mocker.patch("tools.private.release.prepare.changelog_news") + + # Arrange + args = argparse.Namespace(version="2.0.0", issue=None, dry_run=True) + mock_git.status.side_effect = [""] + + # Act + result = Prepare(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.checkout.assert_not_called() + mock_git.add_modified_and_deleted.assert_not_called() diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py index d5df43ae46..5c37e63a97 100644 --- a/tests/tools/private/release/process_backports_test.py +++ b/tests/tools/private/release/process_backports_test.py @@ -1,60 +1,44 @@ import argparse import datetime -import unittest -from unittest.mock import call, patch +from unittest.mock import ANY, call -from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.process_backports import ProcessBackports - -class CmdProcessBackportsTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - self.mock_changelog_news = patch( - "tools.private.release.process_backports.changelog_news" - ).start() - self.mock_replace_version_next = patch( - "tools.private.release.process_backports.replace_version_next" - ).start() - self.addCleanup(patch.stopall) - self.mock_gh.resolve_pr_number.side_effect = lambda x: int( - x.lstrip("#").split("/")[-1] - ) - self.mock_git.diff.return_value = "" - self.mock_git.apply_check.return_value = True - - # Dynamic mock for issue body - self.issue_body = "" - - def mock_get_body(issue_num): - return self.issue_body - - def mock_update_body(issue_num, body): - self.issue_body = body - - self.mock_gh.get_issue_body.side_effect = mock_get_body - self.mock_gh.update_issue_body.side_effect = mock_update_body - - def test_process_backports_no_pending(self): - args = argparse.Namespace( - issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None - ) - self.issue_body = "No backports here" - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.get_issue_body.assert_called_once_with(123) - self.mock_git.fetch.assert_not_called() - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_success(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = argparse.Namespace( - issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +def test_process_backports_no_pending(mock_git, mock_gh): + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": "No backports here", + "labels": ["type: release"], + } + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + mock_git.fetch.assert_not_called() + + +def test_process_backports_success(mocker, mock_git, mock_gh): + mock_changelog = mocker.patch( + "tools.private.release.process_backports.changelog_news" + ) + mock_replace = mocker.patch( + "tools.private.release.process_backports.replace_version_next" + ) + mock_datetime = mocker.patch("tools.private.release.process_backports.datetime") + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -63,107 +47,80 @@ def test_process_backports_success(self, mock_datetime): ## Backports - [ ] #124 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.get_commit_sha.side_effect = ["12345678", "12345678", "main_sha"] - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - self.mock_git.get_modified_files.return_value = ["news/124.fixed.md"] - self.mock_git.diff.return_value = "version diff for 124" - self.mock_git.apply_check.return_value = True - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("origin", tags=True, force=True), - call("origin"), - call("origin", refspec="main"), - ] - ) - self.mock_git.checkout.assert_has_calls( - [ - call("release/2.0", track_remote="origin"), - call("main", track_remote="origin"), - call("prepare-2.0.0-backports-6affdae", create_branch=True), - call("release/2.0"), - ] - ) - self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_git.diff.assert_called_once() - self.mock_git.apply_check.assert_called_once_with(unittest.mock.ANY) - self.mock_git.apply.assert_called_once_with(unittest.mock.ANY) - self.mock_changelog_news.update_changelog.assert_has_calls( - [ - call("2.0.0", "2026-07-01"), - call( - "2.0.0", - "2026-07-01", - news_files=["news/124.fixed.md"], - delete_news=True, - ), - ] - ) - self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 2) - self.mock_replace_version_next.assert_called_once_with("2.0.0") - self.mock_git.commit.assert_has_calls( - [ - call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), - call("chore(release): sync changelog for v2.0.0 backports"), - ] - ) - self.mock_git.push.assert_has_calls( - [ - call("origin", "release/2.0"), - call( - "origin", - "prepare-2.0.0-backports-6affdae", - set_upstream=True, - force=True, - ), - ] - ) - - self.mock_gh.create_pr.assert_called_once_with( - title="chore(release): sync changelog for v2.0.0 backports", - body="Updates CHANGELOG.md and removes news files for backports:\n- #124\n\nWork towards #123\nRelease-Tracking-Issue: #123", - base="main", - labels=["type: sync-changelog"], - ) - self.mock_gh.enable_auto_merge.assert_called_once_with(999) - - self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) - call_args_list = self.mock_gh.update_issue_body.call_args_list - self.assertEqual(call_args_list[0][0][0], 123) - self.assertIn( - "- [x] #124 | status=done rc=rc0 commit= 12345678", call_args_list[0][0][1] - ) - self.assertEqual(call_args_list[1][0][0], 123) - self.assertIn( - "- [ ] Sync Changelog #124 | status=pending pr=#999", - call_args_list[1][0][1], - ) - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_sync_branch_exists(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = argparse.Namespace( - issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_gh.prs[124] = { + "state": "MERGED", + "mergeCommit": {"oid": "abcdef12"}, + } + mock_git.get_remote_tags.return_value = [] + mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + mock_git.get_commit_sha.side_effect = ["12345678", "12345678", "main_sha"] + mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + mock_git.get_modified_files.return_value = ["news/124.fixed.md"] + mock_git.diff.return_value = "version diff for 124" + mock_git.apply_check.return_value = True + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + mock_git.fetch.assert_has_calls( + [ + call("origin", tags=True, force=True), + call("origin"), + call("origin", refspec="main"), + ] + ) + mock_git.checkout.assert_has_calls( + [ + call("release/2.0", track_remote="origin"), + call("main", track_remote="origin"), + call("prepare-2.0.0-backports-6affdae", create_branch=True), + call("release/2.0"), + ] + ) + mock_git.cherry_pick.assert_called_once_with("abcdef12") + mock_git.diff.assert_called_once() + mock_git.apply_check.assert_called_once_with(ANY) + mock_git.apply.assert_called_once_with(ANY) + mock_changelog.update_changelog.assert_has_calls( + [ + call("2.0.0", "2026-07-01"), + call( + "2.0.0", + "2026-07-01", + news_files=["news/124.fixed.md"], + delete_news=True, + ), + ] + ) + assert mock_git.add_modified_and_deleted.call_count == 2 + mock_replace.assert_called_once_with("2.0.0") + mock_git.commit.assert_has_calls( + [ + call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), + call("chore(release): sync changelog for v2.0.0 backports"), + ] + ) + + updated_body = mock_gh.get_issue_body(123) + assert "- [x] #124 | status=done rc=rc0 commit= 12345678" in updated_body + assert "- [ ] Sync Changelog #124 | status=pending pr=#1001" in updated_body + + +def test_process_backports_sync_branch_exists(mocker, mock_git, mock_gh): + mocker.patch("tools.private.release.process_backports.changelog_news") + mocker.patch("tools.private.release.process_backports.replace_version_next") + mock_datetime = mocker.patch("tools.private.release.process_backports.datetime") + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -172,117 +129,48 @@ def test_process_backports_sync_branch_exists(self, mock_datetime): ## Backports - [ ] #124 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.get_commit_sha.side_effect = ["12345678", "12345678", "main_sha"] - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - self.mock_git.get_modified_files.return_value = ["news/124.fixed.md"] - self.mock_git.diff.return_value = "version diff for 124" - self.mock_git.apply_check.return_value = True - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" - - # Configure branch to exist - self.mock_git.branch_exists.return_value = True - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("origin", tags=True, force=True), - call("origin"), - call("origin", refspec="main"), - ] - ) - self.mock_git.checkout.assert_has_calls( - [ - call("release/2.0", track_remote="origin"), - call("main", track_remote="origin"), - # Called without create_branch=True - call("prepare-2.0.0-backports-6affdae"), - call("release/2.0"), - ] - ) - # Verify reset_hard was called to reset the existing branch to main - self.mock_git.reset_hard.assert_has_calls( - [ - call(reset_to="main"), - ] - ) - self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_git.diff.assert_called_once() - self.mock_git.apply_check.assert_called_once_with(unittest.mock.ANY) - self.mock_git.apply.assert_called_once_with(unittest.mock.ANY) - self.mock_changelog_news.update_changelog.assert_has_calls( - [ - call("2.0.0", "2026-07-01"), - call( - "2.0.0", - "2026-07-01", - news_files=["news/124.fixed.md"], - delete_news=True, - ), - ] - ) - self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 2) - self.mock_replace_version_next.assert_called_once_with("2.0.0") - self.mock_git.commit.assert_has_calls( - [ - call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), - call("chore(release): sync changelog for v2.0.0 backports"), - ] - ) - self.mock_git.push.assert_has_calls( - [ - call("origin", "release/2.0"), - call( - "origin", - "prepare-2.0.0-backports-6affdae", - set_upstream=True, - force=True, - ), - ] - ) - - self.mock_gh.create_pr.assert_called_once_with( - title="chore(release): sync changelog for v2.0.0 backports", - body="Updates CHANGELOG.md and removes news files for backports:\n- #124\n\nWork towards #123\nRelease-Tracking-Issue: #123", - base="main", - labels=["type: sync-changelog"], - ) - self.mock_gh.enable_auto_merge.assert_called_once_with(999) - - self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) - call_args_list = self.mock_gh.update_issue_body.call_args_list - self.assertEqual(call_args_list[0][0][0], 123) - self.assertIn( - "- [x] #124 | status=done rc=rc0 commit= 12345678", call_args_list[0][0][1] - ) - self.assertEqual(call_args_list[1][0][0], 123) - self.assertIn( - "- [ ] Sync Changelog #124 | status=pending pr=#999", - call_args_list[1][0][1], - ) - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_dry_run(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = argparse.Namespace( - issue=123, remote="origin", dry_run=True, add=None, triggering_comment=None - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_gh.prs[124] = { + "state": "MERGED", + "mergeCommit": {"oid": "abcdef12"}, + } + mock_git.get_remote_tags.return_value = [] + mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + mock_git.get_commit_sha.side_effect = ["12345678", "12345678", "main_sha"] + mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + mock_git.get_modified_files.return_value = ["news/124.fixed.md"] + mock_git.diff.return_value = "version diff for 124" + mock_git.apply_check.return_value = True + mock_git.branch_exists.return_value = True + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + mock_git.checkout.assert_has_calls( + [ + call("release/2.0", track_remote="origin"), + call("main", track_remote="origin"), + call("prepare-2.0.0-backports-6affdae"), + call("release/2.0"), + ] + ) + mock_git.reset_hard.assert_has_calls([call(reset_to="main")]) + + +def test_process_backports_dry_run(mocker, mock_git, mock_gh): + mocker.patch("tools.private.release.process_backports.changelog_news") + mocker.patch("tools.private.release.process_backports.replace_version_next") + mock_datetime = mocker.patch("tools.private.release.process_backports.datetime") + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + + args = argparse.Namespace( + issue=123, remote="origin", dry_run=True, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -291,77 +179,35 @@ def test_process_backports_dry_run(self, mock_datetime): ## Backports - [ ] #124 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.get_commit_sha.side_effect = ["12345678", "main_sha"] - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - self.mock_git.get_modified_files.return_value = ["news/124.fixed.md"] - self.mock_git.diff.return_value = "version diff for 124" - self.mock_git.apply_check.return_value = True - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("origin", tags=True, force=True), - call("origin"), - call("origin", refspec="main"), - ] - ) - self.mock_git.checkout.assert_has_calls( - [ - call("release/2.0", track_remote="origin"), - call("main", track_remote="origin"), - call("release/2.0"), - ] - ) - self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_git.diff.assert_called_once() - self.mock_git.apply_check.assert_called_once_with(unittest.mock.ANY) - self.mock_git.apply.assert_not_called() - self.mock_changelog_news.update_changelog.assert_has_calls( - [ - call("2.0.0", "2026-07-01"), - call( - "2.0.0", - "2026-07-01", - news_files=["news/124.fixed.md"], - delete_news=True, - ), - ] - ) - self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 1) - self.mock_replace_version_next.assert_called_once_with("2.0.0") - self.mock_git.commit.assert_called_once_with( - 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True - ) - self.mock_git.reset_hard.assert_has_calls( - [ - call(reset_to="12345678"), - call(reset_to="main_sha"), - ] - ) - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_process_backports_ignored_and_failed_states(self): - args = argparse.Namespace( - issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_gh.prs[124] = { + "state": "MERGED", + "mergeCommit": {"oid": "abcdef12"}, + } + mock_git.get_remote_tags.return_value = [] + mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + mock_git.get_commit_sha.side_effect = ["12345678", "main_sha"] + mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + mock_git.get_modified_files.return_value = ["news/124.fixed.md"] + mock_git.diff.return_value = "version diff for 124" + mock_git.apply_check.return_value = True + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + mock_git.apply.assert_not_called() + mock_git.push.assert_not_called() + + +def test_process_backports_ignored_and_failed_states(mock_git, mock_gh): + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -370,39 +216,31 @@ def test_process_backports_ignored_and_failed_states(self): - [ ] #124 | status=pending - [ ] #125 | status=pending - [ ] #126 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.status = "open-pr" - elif item.pr_ref == "#125": - item.status = "draft-pr" - elif item.pr_ref == "#126": - item.status = "error-closed-pr" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("- [ ] #126 | status=error-closed-pr", call_args[1]) - self.assertNotIn("status=open-pr", call_args[1]) - self.assertNotIn("status=draft-pr", call_args[1]) - self.mock_git.checkout.assert_not_called() - self.mock_git.cherry_pick.assert_not_called() - - def test_process_backports_ignored_error_status(self): - args = argparse.Namespace( - issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + + mock_gh.prs[124] = {"state": "OPEN"} + mock_gh.prs[125] = {"state": "OPEN", "isDraft": True} + mock_gh.prs[126] = {"state": "CLOSED"} + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 1 + updated_body = mock_gh.get_issue_body(123) + assert "- [ ] #126 | status=error-closed-pr" in updated_body + mock_git.checkout.assert_not_called() + mock_git.cherry_pick.assert_not_called() + + +def test_process_backports_ignored_error_status(mock_git, mock_gh): + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -410,74 +248,73 @@ def test_process_backports_ignored_error_status(self): ## Backports - [ ] #124 | status=error-merge-conflict - [ ] #125 | status=error-some-other-error -""" - self.mock_git.get_remote_tags.return_value = [] - self.mock_gh.get_merge_commits_for_prs.return_value = [] - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.get_merge_commits_for_prs.assert_not_called() - self.mock_git.checkout.assert_not_called() - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_cherry_pick_failed(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = argparse.Namespace( - issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_git.get_remote_tags.return_value = [] + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + mock_git.checkout.assert_not_called() + + +def test_process_backports_cherry_pick_failed(mocker, mock_git, mock_gh): + mock_datetime = mocker.patch("tools.private.release.process_backports.datetime") + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch ## Backports - [ ] #124 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_git.checkout.assert_called_once_with( - "release/2.0", track_remote="origin" - ) - self.mock_git.cherry_pick.assert_called_once_with("abcdef12") - self.mock_git.cherry_pick_abort.assert_called_once() - - self.mock_gh.update_issue_body.assert_called_once() - call_args = self.mock_gh.update_issue_body.call_args[0] - self.assertEqual(call_args[0], 123) - self.assertIn("- [ ] #124 | status=error-merge-conflict", call_args[1]) - - self.mock_git.commit.assert_not_called() - self.mock_git.push.assert_not_called() - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = argparse.Namespace( - issue=123, - remote="origin", - dry_run=False, - add=["https://github.com/bazel-contrib/rules_python/pull/124"], - triggering_comment=None, - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_gh.prs[124] = { + "state": "MERGED", + "mergeCommit": {"oid": "abcdef12"}, + } + mock_git.get_remote_tags.return_value = [] + mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 1 + mock_git.checkout.assert_called_once_with("release/2.0", track_remote="origin") + mock_git.cherry_pick.assert_called_once_with("abcdef12") + mock_git.cherry_pick_abort.assert_called_once() + + updated_body = mock_gh.get_issue_body(123) + assert "- [ ] #124 | status=error-merge-conflict" in updated_body + mock_git.commit.assert_not_called() + mock_git.push.assert_not_called() + + +def test_process_backports_add_backports_and_auto_add_rc_task( + mocker, mock_git, mock_gh +): + mocker.patch("tools.private.release.process_backports.changelog_news") + mocker.patch("tools.private.release.process_backports.replace_version_next") + mock_datetime = mocker.patch("tools.private.release.process_backports.datetime") + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, + remote="origin", + dry_run=False, + add=["https://github.com/bazel-contrib/rules_python/pull/124"], + triggering_comment=None, + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 @@ -485,68 +322,43 @@ def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetim - [ ] Tag Final ## Backports -""" - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] - self.mock_git.get_commit_sha.return_value = "12345678" - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - - def mock_resolve(items): - for item in items: - if item.pr_ref == "#124": - item.commit = "abcdef12" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] - - # Mock create_pr to return a string to avoid int(MagicMock) returning 1 - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - - # update_issue_body should be called 3 times: - # 1. When adding backports and auto-adding Tag RC1 task. - # 2. When updating the backport status to done. - # 3. When updating the sync task status to pending. - self.assertEqual(self.mock_gh.update_issue_body.call_count, 3) - - call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] - call2_args = self.mock_gh.update_issue_body.call_args_list[1][0] - - self.assertEqual(call1_args[0], 123) - self.assertIn("- [ ] #124", call1_args[1]) - self.assertIn("- [ ] Tag RC1", call1_args[1]) - self.assertIn("- [ ] Sync Changelog #124", call1_args[1]) - self.assertIn( - "- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]" - " Tag RC1\n- [ ] Sync Changelog #124\n- [ ] Tag Final", - call1_args[1].strip(), - ) - - self.assertEqual(call2_args[0], 123) - self.assertIn("- [x] #124 | status=done rc=rc1 commit= 12345678", call2_args[1]) - - call3_args = self.mock_gh.update_issue_body.call_args_list[2][0] - self.assertEqual(call3_args[0], 123) - self.assertIn( - "- [ ] Sync Changelog #124 | status=pending pr=#999", call3_args[1] - ) - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_add_backports_marks_invalid(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = argparse.Namespace( - issue=123, - remote="origin", - dry_run=False, - add=["124", "invalid", "125"], - triggering_comment=None, - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_gh.prs[124] = { + "state": "MERGED", + "mergeCommit": {"oid": "abcdef12"}, + } + mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + mock_git.get_commit_sha.return_value = "12345678" + mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + mock_git.diff.return_value = "version diff" + mock_git.apply_check.return_value = True + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + updated_body = mock_gh.get_issue_body(123) + assert "- [x] #124 | status=done rc=rc1 commit= 12345678" in updated_body + assert "- [ ] Sync Changelog #124 | status=pending pr=#1001" in updated_body + + +def test_process_backports_add_backports_marks_invalid(mocker, mock_git, mock_gh): + mocker.patch("tools.private.release.process_backports.changelog_news") + mocker.patch("tools.private.release.process_backports.replace_version_next") + mock_datetime = mocker.patch("tools.private.release.process_backports.datetime") + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, + remote="origin", + dry_run=False, + add=["124", "invalid", "125"], + triggering_comment=None, + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 @@ -554,59 +366,48 @@ def test_process_backports_add_backports_marks_invalid(self, mock_datetime): - [ ] Tag Final ## Backports -""" - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] - self.mock_git.get_commit_sha.return_value = "1234567890" - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - - def mock_resolve(items): - # Both 124 and 125 should be processed, 'invalid' should be ignored (it has error status) - for item in items: - if item.pr_ref == "#124": - item.commit = "sha_124" - item.status = "done" - elif item.pr_ref == "#125": - item.commit = "sha_125" - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - self.mock_git.sort_commits_chronologically.return_value = ["sha_124", "sha_125"] - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - # Should have updated body to add 124, 125, and invalid - # update_issue_body should be called 4 times: - # 1. When adding backports. - # 2. When updating 124 status to done. - # 3. When updating 125 status to done. - # 4. When updating sync tasks status to pending. - self.assertEqual(self.mock_gh.update_issue_body.call_count, 4) - call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] - self.assertIn("- [ ] #124", call1_args[1]) - self.assertIn("- [ ] #125", call1_args[1]) - self.assertIn("- [ ] invalid | status=error-invalid-pr", call1_args[1]) - self.assertIn("- [ ] Sync Changelog #124", call1_args[1]) - self.assertIn("- [ ] Sync Changelog #125", call1_args[1]) - - call4_args = self.mock_gh.update_issue_body.call_args_list[3][0] - self.assertIn( - "- [ ] Sync Changelog #124 | status=pending pr=#999", call4_args[1] - ) - self.assertIn( - "- [ ] Sync Changelog #125 | status=pending pr=#999", call4_args[1] - ) - - @patch("tools.private.release.process_backports.datetime") - def test_process_backports_version_sync_failure(self, mock_datetime): - mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) - args = argparse.Namespace( - issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None - ) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.issue_body = """ +""", + "labels": ["type: release"], + } + mock_gh.prs[124] = { + "state": "MERGED", + "mergeCommit": {"oid": "sha_124"}, + } + mock_gh.prs[125] = { + "state": "MERGED", + "mergeCommit": {"oid": "sha_125"}, + } + mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + mock_git.get_commit_sha.return_value = "1234567890" + mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + mock_git.sort_commits_chronologically.return_value = ["sha_124", "sha_125"] + mock_git.diff.return_value = "version diff" + mock_git.apply_check.return_value = True + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + updated_body = mock_gh.get_issue_body(123) + assert "- [ ] invalid | status=error-invalid-pr" in updated_body + assert "- [ ] Sync Changelog #124 | status=pending pr=#1001" in updated_body + assert "- [ ] Sync Changelog #125 | status=pending pr=#1001" in updated_body + + +def test_process_backports_version_sync_failure(mocker, mock_git, mock_gh): + mock_changelog = mocker.patch( + "tools.private.release.process_backports.changelog_news" + ) + mock_replace = mocker.patch( + "tools.private.release.process_backports.replace_version_next" + ) + mock_datetime = mocker.patch("tools.private.release.process_backports.datetime") + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None + ) + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -617,135 +418,76 @@ def test_process_backports_version_sync_failure(self, mock_datetime): ## Backports - [ ] #124 | status=pending - [ ] #125 | status=pending -""" - self.mock_git.get_remote_tags.return_value = [] - - def mock_resolve(items): - for item in items: - if item.pr_ref in ("#124", "#125"): - item.commit = "sha_" + item.pr_ref.lstrip("#") - item.status = "done" - return items - - self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - - self.mock_git.sort_commits_chronologically.return_value = ["sha_124", "sha_125"] - self.mock_git.get_commit_sha.side_effect = [ - "12345678", - "sha_124_amended", - "sha_125_amended", - "main_sha", +""", + "labels": ["type: release"], + } + mock_gh.prs[124] = { + "state": "MERGED", + "mergeCommit": {"oid": "sha_124"}, + } + mock_gh.prs[125] = { + "state": "MERGED", + "mergeCommit": {"oid": "sha_125"}, + } + mock_git.get_remote_tags.return_value = [] + mock_git.sort_commits_chronologically.return_value = ["sha_124", "sha_125"] + mock_git.get_commit_sha.side_effect = [ + "12345678", + "sha_124_amended", + "sha_125_amended", + "main_sha", + ] + mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + mock_git.get_modified_files.side_effect = [ + ["news/124.fixed.md"], + ["news/125.fixed.md"], + ] + mock_git.diff.return_value = "diff content" + mock_git.apply_check.side_effect = [False, True] + + result = ProcessBackports(args, mock_git, mock_gh).run() + + assert result == 0 + mock_git.checkout.assert_has_calls( + [ + call("release/2.0", track_remote="origin"), + call("main", track_remote="origin"), + call("prepare-2.0.0-backports-b552a96", create_branch=True), + call("release/2.0"), + ] + ) + mock_git.cherry_pick.assert_has_calls( + [ + call("sha_124"), + call("sha_125"), ] - self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - self.mock_git.get_modified_files.side_effect = [ - ["news/124.fixed.md"], - ["news/125.fixed.md"], + ) + assert mock_git.diff.call_count == 2 + assert mock_git.apply_check.call_count == 2 + mock_git.apply.assert_called_once_with(ANY) + + mock_changelog.update_changelog.assert_has_calls( + [ + call("2.0.0", "2026-07-01"), + call("2.0.0", "2026-07-01"), + call( + "2.0.0", + "2026-07-01", + news_files=["news/124.fixed.md", "news/125.fixed.md"], + delete_news=True, + ), ] - self.mock_git.diff.side_effect = ["diff 124", "diff 125"] - self.mock_git.apply_check.side_effect = [False, True] - self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/999" - - result = ProcessBackports(args, self.mock_git, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("origin", tags=True, force=True), - call("origin"), - call("origin", refspec="main"), - ] - ) - self.mock_git.checkout.assert_has_calls( - [ - call("release/2.0", track_remote="origin"), - call("main", track_remote="origin"), - call("prepare-2.0.0-backports-b552a96", create_branch=True), - call("release/2.0"), - ] - ) - self.mock_git.cherry_pick.assert_has_calls( - [ - call("sha_124"), - call("sha_125"), - ] - ) - # diff should be called for each successful cherry-pick - self.assertEqual(self.mock_git.diff.call_count, 2) - # apply_check should be called for both patches - self.assertEqual(self.mock_git.apply_check.call_count, 2) - # apply should only be called for 125 (since 124 failed check) - self.mock_git.apply.assert_called_once_with(unittest.mock.ANY) - - self.mock_changelog_news.update_changelog.assert_has_calls( - [ - call("2.0.0", "2026-07-01"), - call("2.0.0", "2026-07-01"), - call( - "2.0.0", - "2026-07-01", - news_files=["news/124.fixed.md", "news/125.fixed.md"], - delete_news=True, - ), - ] - ) - # add_modified_and_deleted called: - # - once per cherry-pick (2) - # - once on main backport branch (1) - # Total = 3 - self.assertEqual(self.mock_git.add_modified_and_deleted.call_count, 3) - # replace_version_next called once per cherry-pick - self.assertEqual(self.mock_replace_version_next.call_count, 2) - - self.mock_git.commit.assert_has_calls( - [ - call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), - call('Cherry-pick "fix bug"\n\nWork towards #123', amend=True), - call("chore(release): sync changelog for v2.0.0 backports"), - ] - ) - self.mock_git.push.assert_has_calls( - [ - call("origin", "release/2.0"), - call("origin", "release/2.0"), - call( - "origin", - "prepare-2.0.0-backports-b552a96", - set_upstream=True, - force=True, - ), - ] - ) - - # PR body should contain warning about 124 - expected_body = ( - "Updates CHANGELOG.md and removes news files for backports:\n" - "- #124\n" - "- #125\n" - "\n" - "Warning: These PRs failed to update their version markers:\n" - "- #124\n" - "\n" - "Work towards #123\n" - "Release-Tracking-Issue: #123" - ) - self.mock_gh.create_pr.assert_called_once_with( - title="chore(release): sync changelog for v2.0.0 backports", - body=expected_body, - base="main", - labels=["type: sync-changelog"], - ) - self.mock_gh.enable_auto_merge.assert_called_once_with(999) - - # update_issue_body called 3 times (twice for backports, once for sync tasks) - self.assertEqual(self.mock_gh.update_issue_body.call_count, 3) - call3_args = self.mock_gh.update_issue_body.call_args_list[2][0] - self.assertIn( - "- [ ] Sync Changelog #124 | status=pending pr=#999", call3_args[1] - ) - self.assertIn( - "- [ ] Sync Changelog #125 | status=pending pr=#999", call3_args[1] - ) - - -if __name__ == "__main__": - unittest.main() + ) + assert mock_git.add_modified_and_deleted.call_count == 3 + assert mock_replace.call_count == 2 + + assert 1001 in mock_gh.prs + pr = mock_gh.prs[1001] + assert ( + "Warning: These PRs failed to update their version markers:\n- #124" + in pr["body"] + ) + + updated_body = mock_gh.get_issue_body(123) + assert "- [ ] Sync Changelog #124 | status=pending pr=#1001" in updated_body + assert "- [ ] Sync Changelog #125 | status=pending pr=#1001" in updated_body diff --git a/tests/tools/private/release/promote_test.py b/tests/tools/private/release/promote_test.py index 8d1d2cfb29..b7fe9c9848 100644 --- a/tests/tools/private/release/promote_test.py +++ b/tests/tools/private/release/promote_test.py @@ -1,313 +1,281 @@ import argparse -import os -import tempfile -import unittest from pathlib import Path from unittest.mock import call, patch -from tests.tools.private.release.release_test_helper import _mock_git_and_gh -from tools.private.release.gh import NoTrackingIssueError from tools.private.release.promote import Promote - -class CmdPromoteTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - self.test_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.test_dir.cleanup) - - def test_promote_rc_success(self): - # Arrange - args = argparse.Namespace( - version="2.0.0", issue=123, dry_run=False, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] - self.mock_git.get_commit_sha.return_value = "abcdef123456" - self.mock_git.tag_exists.return_value = False - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("my-remote", tags=True, force=True), - call("my-remote", refspec="release/2.0"), - ] - ) - self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.0-rc1"), call("my-remote/release/2.0")] - ) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag_exists.assert_called_once_with("2.0.0") - self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") - - # Verify issue update - self.mock_gh.get_issue_body.assert_called_once_with(123) - expected_updated_body = ( - "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" - ) - self.mock_gh.update_issue_body.assert_called_once_with( - 123, expected_updated_body - ) - expected_comment = ( - "**New Release Tagged!** 🐍🌿\n\n" - "Version **2.0.0** has been successfully generated and tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0).\n\n" - "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" - "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" - ) - self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - - def test_promote_rc_writes_github_output(self): - # Arrange - github_output_path = os.path.join(self.test_dir.name, "github_output") - args = argparse.Namespace( - version="2.0.0", issue=123, dry_run=False, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] - self.mock_git.get_commit_sha.return_value = "abcdef123456" - self.mock_git.tag_exists.return_value = False - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - with patch.dict("os.environ", {"GITHUB_OUTPUT": github_output_path}): - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.assertTrue(os.path.exists(github_output_path)) - content = Path(github_output_path).read_text(encoding="utf-8") - self.assertEqual(content, "version=2.0.0\n") - - def test_promote_rc_resolve_issue_success(self): - # Arrange - args = argparse.Namespace( - version="2.0.0", issue=None, dry_run=False, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = None - self.mock_gh.get_release_tracking_issue.return_value = 123 - self.mock_git.get_commit_sha.return_value = "abcdef123456" - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("my-remote", tags=True, force=True), - call("my-remote", refspec="release/2.0"), - ] - ) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.0-rc1"), call("my-remote/release/2.0")] - ) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.0") - self.mock_gh.get_issue_body.assert_called_once_with(123) - expected_updated_body = ( - "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" - ) - self.mock_gh.update_issue_body.assert_called_once_with( - 123, expected_updated_body - ) - expected_comment = ( - "**New Release Tagged!** 🐍🌿\n\n" - "Version **2.0.0** has been successfully generated and tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0).\n\n" - "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" - "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" - ) - self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - - def test_promote_patch_success(self): - # Arrange - args = argparse.Namespace( - version="2.0.1", issue=123, dry_run=False, remote="my-remote" - ) - self.mock_git.tag_exists.return_value = False - self.mock_git.get_commit_sha.return_value = "12345678" - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("my-remote", tags=True, force=True), - call("my-remote", refspec="release/2.0"), - ] - ) - self.mock_git.get_current_branch.assert_not_called() - self.mock_git.get_tags.assert_not_called() - self.mock_git.get_remote_tags.assert_not_called() - - self.mock_git.checkout.assert_not_called() - self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") - self.mock_git.tag.assert_called_once_with("2.0.1", "12345678") - self.mock_git.push.assert_called_once_with("my-remote", "2.0.1") - - expected_updated_body = ( - "- [x] Tag Final | status=done tag=2.0.1 commit= 12345678" - ) - self.mock_gh.update_issue_body.assert_called_once_with( - 123, expected_updated_body - ) - expected_comment = ( - "**New Release Tagged!** 🐍🌿\n\n" - "Version **2.0.1** has been successfully generated and tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0).\n\n" - "- [Github Release 2.0.1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1)\n" - "- [BCR Entry 2.0.1](https://registry.bazel.build/modules/rules_python/2.0.1)\n" - "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" - ) - self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - - @patch("builtins.print") - def test_promote_rc_dry_run_success(self, mock_print): - # Arrange - args = argparse.Namespace( - version="2.0.0", issue=123, dry_run=True, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] - self.mock_git.get_commit_sha.return_value = "abcdef123456" - self.mock_git.tag_exists.return_value = False - initial_body = "- [ ] Tag Final" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 0) - self.mock_git.fetch.assert_has_calls( - [ - call("my-remote", tags=True, force=True), - call("my-remote", refspec="release/2.0"), - ] - ) - self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.0-rc1"), call("my-remote/release/2.0")] - ) - self.mock_git.tag_exists.assert_called_once_with("2.0.0") - - # Core dry-run assertions: NO modifications - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - self.mock_gh.post_issue_comment.assert_not_called() - - mock_print.assert_has_calls( - [ - call("Verifying tracking issue #123 format..."), - call("Fetching remote branch my-remote/release/2.0..."), - call( - "[DRY RUN] Pre-conditions passed successfully for promoting" - " 2.0.0-rc1 to 2.0.0." - ), - call("[DRY RUN] Would tag commit abcdef12 as 2.0.0"), - call("[DRY RUN] Would push tag 2.0.0 to my-remote"), - call("[DRY RUN] Would update tracking issue #123 checklist"), - call("[DRY RUN] Would post comment to tracking issue #123"), - ] - ) - - def test_promote_rc_tag_already_exists(self): - # Arrange - args = argparse.Namespace( - version="2.0.0", issue=123, dry_run=False, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = True - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.get_issue_body.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_promote_rc_issue_not_found(self): - # Arrange - args = argparse.Namespace( - version="2.0.0", issue=None, dry_run=False, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( - "Not found" - ) - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.get_issue_body.assert_not_called() - - def test_promote_rc_issue_malformed(self): - # Arrange - args = argparse.Namespace( - version="2.0.0", issue=123, dry_run=False, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] - self.mock_git.tag_exists.return_value = False - self.mock_git.get_commit_sha.return_value = "abcdef123456" - initial_body = "malformed body" - self.mock_gh.get_issue_body.return_value = initial_body - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_gh.get_issue_body.assert_called_once_with(123) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - - def test_promote_rc_no_rc_found(self): - # Arrange - args = argparse.Namespace( - version="2.0.0", issue=123, dry_run=False, remote="my-remote" - ) - self.mock_git.get_remote_tags.return_value = [] - - # Act - result = Promote(args, self.mock_git, self.mock_gh).run() - - # Assert - self.assertEqual(result, 1) - self.mock_git.checkout.assert_not_called() - self.mock_git.tag.assert_not_called() - self.mock_gh.get_issue_body.assert_not_called() - - -if __name__ == "__main__": - unittest.main() +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +def test_promote_rc_success(mock_git, mock_gh): + # Arrange + issue_num = mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Tag Final", + labels=["type: release"], + ) + args = argparse.Namespace( + version="2.0.0", issue=issue_num, dry_run=False, remote="my-remote" + ) + mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + mock_git.get_commit_sha.return_value = "abcdef123456" + mock_git.tag_exists.return_value = False + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) + mock_git.get_commit_sha.assert_has_calls( + [call("2.0.0-rc1"), call("my-remote/release/2.0")] + ) + mock_git.checkout.assert_not_called() + mock_git.tag_exists.assert_called_once_with("2.0.0") + mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") + mock_git.push.assert_called_once_with("my-remote", "2.0.0") + + # Verify issue update + expected_updated_body = "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + assert mock_gh.get_issue_body(issue_num) == expected_updated_body + + expected_comment = ( + "**New Release Tagged!** 🐍🌿\n\n" + "Version **2.0.0** has been successfully generated and tagged on branch [`release/2.0`](https://github.com/bazel-contrib/rules_python/tree/release/2.0).\n\n" + "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" + "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" + ) + assert mock_gh.issue_comments[issue_num] == [expected_comment] + + +def test_promote_rc_writes_github_output(tmp_path, monkeypatch, mock_git, mock_gh): + # Arrange + github_output_path = str(tmp_path / "github_output") + monkeypatch.setenv("GITHUB_OUTPUT", github_output_path) + issue_num = mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Tag Final", + labels=["type: release"], + ) + args = argparse.Namespace( + version="2.0.0", issue=issue_num, dry_run=False, remote="my-remote" + ) + mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + mock_git.get_commit_sha.return_value = "abcdef123456" + mock_git.tag_exists.return_value = False + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + assert Path(github_output_path).exists() + content = Path(github_output_path).read_text(encoding="utf-8") + assert content == "version=2.0.0\n" + + +def test_promote_rc_resolve_issue_success(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + version="2.0.0", issue=None, dry_run=False, remote="my-remote" + ) + issue_num = mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Tag Final", + labels=["type: release"], + ) + mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + mock_git.tag_exists.return_value = False + mock_git.get_commit_sha.return_value = "abcdef123456" + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) + mock_git.get_commit_sha.assert_has_calls( + [call("2.0.0-rc1"), call("my-remote/release/2.0")] + ) + mock_git.checkout.assert_not_called() + mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456") + mock_git.push.assert_called_once_with("my-remote", "2.0.0") + + expected_updated_body = "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + assert mock_gh.get_issue_body(issue_num) == expected_updated_body + + +def test_promote_patch_success(mock_git, mock_gh): + # Arrange + issue_num = mock_gh.create_issue( + title="Release 2.0.1", + body="- [ ] Tag Final", + labels=["type: release"], + ) + args = argparse.Namespace( + version="2.0.1", issue=issue_num, dry_run=False, remote="my-remote" + ) + mock_git.tag_exists.return_value = False + mock_git.get_commit_sha.return_value = "12345678" + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) + mock_git.get_current_branch.assert_not_called() + mock_git.get_tags.assert_not_called() + mock_git.get_remote_tags.assert_not_called() + + mock_git.checkout.assert_not_called() + mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") + mock_git.tag.assert_called_once_with("2.0.1", "12345678") + mock_git.push.assert_called_once_with("my-remote", "2.0.1") + + expected_updated_body = "- [x] Tag Final | status=done tag=2.0.1 commit= 12345678" + assert mock_gh.get_issue_body(issue_num) == expected_updated_body + + +@patch("builtins.print") +def test_promote_rc_dry_run_success(mock_print, mock_git, mock_gh): + # Arrange + issue_num = mock_gh.create_issue( + title="Release 2.0.0", + body="- [ ] Tag Final", + labels=["type: release"], + ) + args = argparse.Namespace( + version="2.0.0", issue=issue_num, dry_run=True, remote="my-remote" + ) + mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + mock_git.get_commit_sha.return_value = "abcdef123456" + mock_git.tag_exists.return_value = False + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 0 + mock_git.fetch.assert_has_calls( + [ + call("my-remote", tags=True, force=True), + call("my-remote", refspec="release/2.0"), + ] + ) + mock_git.get_commit_sha.assert_has_calls( + [call("2.0.0-rc1"), call("my-remote/release/2.0")] + ) + mock_git.tag_exists.assert_called_once_with("2.0.0") + + # Core dry-run assertions: NO modifications + mock_git.tag.assert_not_called() + mock_git.push.assert_not_called() + + mock_print.assert_has_calls( + [ + call(f"Verifying tracking issue #{issue_num} format..."), + call("Fetching remote branch my-remote/release/2.0..."), + call( + "[DRY RUN] Pre-conditions passed successfully for promoting" + " 2.0.0-rc1 to 2.0.0." + ), + call("[DRY RUN] Would tag commit abcdef12 as 2.0.0"), + call("[DRY RUN] Would push tag 2.0.0 to my-remote"), + call(f"[DRY RUN] Would update tracking issue #{issue_num} checklist"), + call(f"[DRY RUN] Would post comment to tracking issue #{issue_num}"), + ] + ) + + +def test_promote_rc_tag_already_exists(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=False, remote="my-remote" + ) + mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + mock_git.tag_exists.return_value = True + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.checkout.assert_not_called() + mock_git.tag.assert_not_called() + mock_git.push.assert_not_called() + + +def test_promote_rc_issue_not_found(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + version="2.0.0", issue=None, dry_run=False, remote="my-remote" + ) + mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + mock_git.tag_exists.return_value = False + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.checkout.assert_not_called() + mock_git.tag.assert_not_called() + mock_git.push.assert_not_called() + + +def test_promote_rc_issue_malformed(mock_git, mock_gh): + # Arrange + issue_num = mock_gh.create_issue( + title="Release 2.0.0", + body="malformed body", + labels=["type: release"], + ) + args = argparse.Namespace( + version="2.0.0", issue=issue_num, dry_run=False, remote="my-remote" + ) + mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] + mock_git.tag_exists.return_value = False + mock_git.get_commit_sha.return_value = "abcdef123456" + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.checkout.assert_not_called() + mock_git.tag.assert_not_called() + mock_git.push.assert_not_called() + + +def test_promote_rc_no_rc_found(mock_git, mock_gh): + # Arrange + args = argparse.Namespace( + version="2.0.0", issue=123, dry_run=False, remote="my-remote" + ) + mock_git.get_remote_tags.return_value = [] + + # Act + result = Promote(args, mock_git, mock_gh).run() + + # Assert + assert result == 1 + mock_git.checkout.assert_not_called() + mock_git.tag.assert_not_called() diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py index 36f0dbd704..869a303ebf 100644 --- a/tests/tools/private/release/release_issue_test.py +++ b/tests/tools/private/release/release_issue_test.py @@ -1,5 +1,3 @@ -import unittest - from tools.private.release.release_issue import ( add_backports_to_body, add_sync_changelog_task_to_body, @@ -9,67 +7,66 @@ ) -class ReleaseIssueTest(unittest.TestCase): - def test_parse_metadata_line_spaces(self): - # Test with spaces around '=' - line = "- [ ] Tag Final | tag = 2.0.0 commit = abcdef12" - expected = { - "checked": False, - "name": "Tag Final", - "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, - "original_line": line, - } - self.assertEqual(parse_metadata_line(line), expected) - - # Test with spaces after '=' - line = "- [ ] Tag Final | tag= 2.0.0 commit= abcdef12" - expected = { - "checked": False, - "name": "Tag Final", - "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, - "original_line": line, - } - self.assertEqual(parse_metadata_line(line), expected) - - # Test with standard format (no spaces) - line = "- [ ] Tag Final | tag=2.0.0 commit=abcdef12" - expected = { - "checked": False, - "name": "Tag Final", - "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, - "original_line": line, - } - self.assertEqual(parse_metadata_line(line), expected) - - # Test with no metadata - line = "- [ ] Tag Final" - expected = { - "checked": False, - "name": "Tag Final", - "metadata": {}, - "original_line": line, - } - self.assertEqual(parse_metadata_line(line), expected) - - def test_format_metadata_line(self): - # Test with commit metadata (should have space) - metadata = {"status": "done", "tag": "2.0.0", "commit": "abcdef12"} - expected = "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" - self.assertEqual(format_metadata_line(True, "Tag Final", metadata), expected) - - # Test with other metadata (should not have space) - metadata = {"status": "done", "pr": "#122"} - expected = "- [x] Prepare Release | status=done pr=#122" - self.assertEqual( - format_metadata_line(True, "Prepare Release", metadata), expected - ) - - # Test with no metadata - expected = "- [ ] Tag Final" - self.assertEqual(format_metadata_line(False, "Tag Final", {}), expected) - - def test_add_backports_to_body(self): - body = """ +def test_parse_metadata_line_spaces(): + # Test with spaces around '=' + line = "- [ ] Tag Final | tag = 2.0.0 commit = abcdef12" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, + "original_line": line, + } + assert parse_metadata_line(line) == expected + + # Test with spaces after '=' + line = "- [ ] Tag Final | tag= 2.0.0 commit= abcdef12" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, + "original_line": line, + } + assert parse_metadata_line(line) == expected + + # Test with standard format (no spaces) + line = "- [ ] Tag Final | tag=2.0.0 commit=abcdef12" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {"tag": "2.0.0", "commit": "abcdef12"}, + "original_line": line, + } + assert parse_metadata_line(line) == expected + + # Test with no metadata + line = "- [ ] Tag Final" + expected = { + "checked": False, + "name": "Tag Final", + "metadata": {}, + "original_line": line, + } + assert parse_metadata_line(line) == expected + + +def test_format_metadata_line(): + # Test with commit metadata (should have space) + metadata = {"status": "done", "tag": "2.0.0", "commit": "abcdef12"} + expected = "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + assert format_metadata_line(True, "Tag Final", metadata) == expected + + # Test with other metadata (should not have space) + metadata = {"status": "done", "pr": "#122"} + expected = "- [x] Prepare Release | status=done pr=#122" + assert format_metadata_line(True, "Prepare Release", metadata) == expected + + # Test with no metadata + expected = "- [ ] Tag Final" + assert format_metadata_line(False, "Tag Final", {}) == expected + + +def test_add_backports_to_body(): + body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -78,14 +75,14 @@ def test_add_backports_to_body(self): ## Backports - [ ] #123 | status=done """ - items = [ - {"ref": "124"}, - {"ref": "#124"}, - {"ref": "125"}, - {"ref": "#123"}, - ] - updated_body = add_backports_to_body(body, items) - expected_body = """ + items = [ + {"ref": "124"}, + {"ref": "#124"}, + {"ref": "125"}, + {"ref": "#123"}, + ] + updated_body = add_backports_to_body(body, items) + expected_body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -96,29 +93,30 @@ def test_add_backports_to_body(self): - [ ] #124 - [ ] #125 """ - self.assertEqual(updated_body.strip(), expected_body.strip()) + assert updated_body.strip() == expected_body.strip() + - def test_add_sync_changelog_task_to_body(self): - body = """ +def test_add_sync_changelog_task_to_body(): + body = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch - [ ] Tag Final """ - # Insert first task (should go before Tag Final) - body = add_sync_changelog_task_to_body(body, 124) - expected = """ + # Insert first task (should go before Tag Final) + body = add_sync_changelog_task_to_body(body, 124) + expected = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch - [ ] Sync Changelog #124 - [ ] Tag Final """ - self.assertEqual(body.strip(), expected.strip()) + assert body.strip() == expected.strip() - # Insert second task (should go after the last Sync Changelog task) - body = add_sync_changelog_task_to_body(body, 125) - expected = """ + # Insert second task (should go after the last Sync Changelog task) + body = add_sync_changelog_task_to_body(body, 125) + expected = """ ## Checklist - [ ] Prepare Release - [ ] Create Release branch @@ -126,14 +124,15 @@ def test_add_sync_changelog_task_to_body(self): - [ ] Sync Changelog #125 - [ ] Tag Final """ - self.assertEqual(body.strip(), expected.strip()) + assert body.strip() == expected.strip() - # Insert duplicate (should be ignored) - body = add_sync_changelog_task_to_body(body, 124) - self.assertEqual(body.strip(), expected.strip()) + # Insert duplicate (should be ignored) + body = add_sync_changelog_task_to_body(body, 124) + assert body.strip() == expected.strip() - def test_parse_checklist_state_with_sync_changelogs(self): - body = """ + +def test_parse_checklist_state_with_sync_changelogs(): + body = """ ## Checklist - [x] Prepare Release | status=done pr=#122 commit=abcdef12 - [x] Create Release branch | status=done branch=release/2.0 @@ -141,22 +140,18 @@ def test_parse_checklist_state_with_sync_changelogs(self): - [ ] Sync Changelog #126 - [ ] Tag Final """ - state = parse_checklist_state(body) - self.assertIn(124, state["sync_changelogs"]) - self.assertIn(126, state["sync_changelogs"]) - - task_124 = state["sync_changelogs"][124] - self.assertEqual(task_124.name, "Sync Changelog #124") - self.assertFalse(task_124.checked) - self.assertEqual(task_124.status, "pending") - self.assertEqual(task_124.pr, "#125") - - task_126 = state["sync_changelogs"][126] - self.assertEqual(task_126.name, "Sync Changelog #126") - self.assertFalse(task_126.checked) - self.assertIsNone(task_126.status) - self.assertIsNone(task_126.pr) - - -if __name__ == "__main__": - unittest.main() + state = parse_checklist_state(body) + assert 124 in state["sync_changelogs"] + assert 126 in state["sync_changelogs"] + + task_124 = state["sync_changelogs"][124] + assert task_124.name == "Sync Changelog #124" + assert not task_124.checked + assert task_124.status == "pending" + assert task_124.pr == "#125" + + task_126 = state["sync_changelogs"][126] + assert task_126.name == "Sync Changelog #126" + assert not task_126.checked + assert task_126.status is None + assert task_126.pr is None diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index f0cdbdfe1d..651af24c2f 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -1,23 +1,19 @@ -import unittest +import pytest from tools.private.release import release as releaser -class ReleaseCLITest(unittest.TestCase): - def test_valid_version(self): - # These should not raise an exception - releaser.create_parser().parse_args(["prepare", "0.28.0"]) - releaser.create_parser().parse_args(["promote", "1.0.0", "--remote", "origin"]) - releaser.create_parser().parse_args( - ["create-release-issue", "--version", "1.2.3rc4"] - ) +def test_valid_version(): + # These should not raise an exception + releaser.create_parser().parse_args(["prepare", "0.28.0"]) + releaser.create_parser().parse_args(["promote", "1.0.0", "--remote", "origin"]) + releaser.create_parser().parse_args( + ["create-release-issue", "--version", "1.2.3rc4"] + ) - def test_invalid_version(self): - with self.assertRaises(SystemExit): - releaser.create_parser().parse_args(["prepare", "0.28"]) - with self.assertRaises(SystemExit): - releaser.create_parser().parse_args(["prepare", "a.b.c"]) - -if __name__ == "__main__": - unittest.main() +def test_invalid_version(): + with pytest.raises(SystemExit): + releaser.create_parser().parse_args(["prepare", "0.28"]) + with pytest.raises(SystemExit): + releaser.create_parser().parse_args(["prepare", "a.b.c"]) diff --git a/tests/tools/private/release/release_test_helper.py b/tests/tools/private/release/release_test_helper.py index 12945193ef..c738d834f9 100644 --- a/tests/tools/private/release/release_test_helper.py +++ b/tests/tools/private/release/release_test_helper.py @@ -1,55 +1,21 @@ -import os -import pathlib -import shutil -import tempfile -import unittest +import dataclasses +from pathlib import Path from unittest.mock import MagicMock, patch -from tools.private.release.gh import ( - MultipleTrackingIssuesError, - NoTrackingIssueError, -) -from tools.private.release.mock_gh import MockGitHub - - -def _mock_git(test_case): - mock_git = MagicMock() - test_case.mock_git = mock_git - - # Mock Git inside utils.py since it instantiates it locally - patch("tools.private.release.utils.Git", return_value=mock_git).start() - - test_case.addCleanup(patch.stopall) - - # Apply safe defaults - mock_git.get_current_branch.return_value = None - mock_git.get_tags.return_value = [] - mock_git.get_remote_tags.return_value = [] - mock_git.status.return_value = "" - mock_git.branch_exists.return_value = False - mock_git.tag_exists.return_value = False - return mock_git +import pytest +from tools.private.release.mock_gh import MockGitHub -def _mock_git_and_gh(test_case): - _mock_git(test_case) - mock_gh = MagicMock() - test_case.mock_gh = mock_gh - - mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError - mock_gh.NoTrackingIssueError = NoTrackingIssueError - mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") - mock_gh.get_open_pr.return_value = None +@dataclasses.dataclass +class ReleaseToolEnv: + """Environment setup for testing release tools. + Attributes: + git_root: The root path of the temporary Git repository workspace. + """ -class TempDirTestCase(unittest.TestCase): - def setUp(self): - self.tmpdir = pathlib.Path(tempfile.mkdtemp()) - self.original_cwd = os.getcwd() - self.addCleanup(shutil.rmtree, self.tmpdir) - os.chdir(self.tmpdir) - self.addCleanup(os.chdir, self.original_cwd) + git_root: Path DEFAULT_RELEASE_TEMPLATE_CONTENT = ( @@ -62,15 +28,31 @@ def setUp(self): ) -class ReleaseToolTestCase(TempDirTestCase): - def setUp(self): - super().setUp() - self.gh = MockGitHub() - self.setUpReleaseTemplate() +@pytest.fixture(name="mock_git") +def fixture_mock_git(): + mock_git_inst = MagicMock() + mock_git_inst.get_current_branch.return_value = None + mock_git_inst.get_tags.return_value = [] + mock_git_inst.get_remote_tags.return_value = [] + mock_git_inst.status.return_value = "" + mock_git_inst.branch_exists.return_value = False + mock_git_inst.tag_exists.return_value = False + + with patch("tools.private.release.utils.Git", return_value=mock_git_inst): + yield mock_git_inst + + +@pytest.fixture(name="mock_gh") +def fixture_mock_gh(): + return MockGitHub() + - def setUpReleaseTemplate(self): - template_dir = self.tmpdir / ".github/ISSUE_TEMPLATE" - template_dir.mkdir(parents=True, exist_ok=True) - self.template_file = template_dir / "release_tracking_template.md" - self.template_content = DEFAULT_RELEASE_TEMPLATE_CONTENT - self.template_file.write_text(self.template_content, encoding="utf-8") +@pytest.fixture(name="release_tool_env") +def fixture_release_tool_env(tmp_path, monkeypatch): + """Fixture providing a temp cwd with release template set up.""" + monkeypatch.chdir(tmp_path) + template_dir = tmp_path / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + template_file = template_dir / "release_tracking_template.md" + template_file.write_text(DEFAULT_RELEASE_TEMPLATE_CONTENT, encoding="utf-8") + yield ReleaseToolEnv(git_root=tmp_path) diff --git a/tests/tools/private/release/utils_test.py b/tests/tools/private/release/utils_test.py index 796bab1619..f161308b60 100644 --- a/tests/tools/private/release/utils_test.py +++ b/tests/tools/private/release/utils_test.py @@ -1,213 +1,243 @@ -import unittest -from unittest.mock import patch +import pytest -from tests.tools.private.release.release_test_helper import TempDirTestCase from tools.private.release import utils +pytest_plugins = ["tests.tools.private.release.release_test_helper"] -class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_success(self, mock_get_tags): - mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] - self.assertEqual(utils.get_latest_version(), "1.0.0") - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_rc_is_latest(self, mock_get_tags): - mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] - with self.assertRaisesRegex( - ValueError, "The latest version is a pre-release version: 1.1.0rc0" - ): - utils.get_latest_version() - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_no_tags(self, mock_get_tags): - mock_get_tags.return_value = [] - with self.assertRaisesRegex( - RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." - ): - utils.get_latest_version() - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_no_matching_tags(self, mock_get_tags): - mock_get_tags.return_value = ["v1.0", "latest"] - with self.assertRaisesRegex( - RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." - ): - utils.get_latest_version() - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_version_only_rc_tags(self, mock_get_tags): - mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] - with self.assertRaisesRegex( - ValueError, "The latest version is a pre-release version: 1.1.0rc0" - ): - utils.get_latest_version() - - -class GetLatestRcTagTest(unittest.TestCase): - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_no_tags(self, mock_get_tags): - mock_get_tags.return_value = [] - self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): - mock_get_tags.return_value = [ + +def test_get_latest_version_success(mocker): + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=["0.1.0", "1.0.0", "0.2.0"], + ) + assert utils.get_latest_version() == "1.0.0" + + +def test_get_latest_version_rc_is_latest(mocker): + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=["0.1.0", "1.0.0", "1.1.0rc0"], + ) + with pytest.raises( + ValueError, match="The latest version is a pre-release version: 1.1.0rc0" + ): + utils.get_latest_version() + + +def test_get_latest_version_no_tags(mocker): + mocker.patch("tools.private.release.git.Git.get_tags", return_value=[]) + with pytest.raises( + RuntimeError, match="No git tags found matching X.Y.Z or X.Y.ZrcN format." + ): + utils.get_latest_version() + + +def test_get_latest_version_no_matching_tags(mocker): + mocker.patch( + "tools.private.release.git.Git.get_tags", return_value=["v1.0", "latest"] + ) + with pytest.raises( + RuntimeError, match="No git tags found matching X.Y.Z or X.Y.ZrcN format." + ): + utils.get_latest_version() + + +def test_get_latest_version_only_rc_tags(mocker): + mocker.patch( + "tools.private.release.git.Git.get_tags", return_value=["1.0.0rc0", "1.1.0rc0"] + ) + with pytest.raises( + ValueError, match="The latest version is a pre-release version: 1.1.0rc0" + ): + utils.get_latest_version() + + +def test_get_latest_rc_tag_no_tags(mocker): + mocker.patch("tools.private.release.git.Git.get_tags", return_value=[]) + assert utils.get_latest_rc_tag("2.0.0") is None + + +def test_get_latest_rc_tag_no_matching_tags(mocker): + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=[ "1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0", - ] - self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) + ], + ) + assert utils.get_latest_rc_tag("2.0.0") is None - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_success(self, mock_get_tags): - mock_get_tags.return_value = [ + +def test_get_latest_rc_tag_success(mocker): + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=[ "2.0.0-rc0", "2.0.0-rc2", "2.0.0-rc1", "2.1.0-rc0", - ] - self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") + ], + ) + assert utils.get_latest_rc_tag("2.0.0") == "2.0.0-rc2" + + +def test_get_latest_rc_tag_ignores_v_prefix(mocker): + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=["v2.0.0-rc0", "2.0.0-rc1"], + ) + assert utils.get_latest_rc_tag("2.0.0") == "2.0.0-rc1" - @patch("tools.private.release.git.Git.get_tags") - def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): - mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] - self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") - @patch("tools.private.release.git.Git.get_remote_tags") - def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): - mock_get_remote_tags.return_value = [ +def test_get_latest_rc_tag_remote_success(mocker): + mock_get_remote_tags = mocker.patch( + "tools.private.release.git.Git.get_remote_tags", + return_value=[ "2.0.0-rc0", "2.0.0-rc2", "2.0.0-rc1", "2.1.0-rc0", - ] - self.assertEqual(utils.get_latest_rc_tag("2.0.0", remote="origin"), "2.0.0-rc2") - mock_get_remote_tags.assert_called_once_with("origin") + ], + ) + assert utils.get_latest_rc_tag("2.0.0", remote="origin") == "2.0.0-rc2" + mock_get_remote_tags.assert_called_once_with("origin") -class DetermineNextVersionTest(TempDirTestCase): - def setUp(self): - super().setUp() - self.mock_get_latest_version = patch( - "tools.private.release.utils.get_latest_version" - ).start() - self.mock_get_current_branch = patch( - "tools.private.release.git.Git.get_current_branch" - ).start() - self.mock_get_current_branch.return_value = "main" - self.addCleanup(patch.stopall) +def test_determine_next_version_no_markers(mocker, release_tool_env): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="main" + ) + mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3") + (release_tool_env.git_root / "mock_file.bzl").write_text("no markers here") - def test_no_markers(self): - (self.tmpdir / "mock_file.bzl").write_text("no markers here") - self.mock_get_latest_version.return_value = "1.2.3" + next_version = utils.determine_next_version() - next_version = utils.determine_next_version() + assert next_version == "1.2.4" - self.assertEqual(next_version, "1.2.4") - def test_only_patch(self): - (self.tmpdir / "mock_file.bzl").write_text( - ":::{versionchanged} VERSION_NEXT_PATCH" - ) - self.mock_get_latest_version.return_value = "1.2.3" +def test_determine_next_version_only_patch(mocker, release_tool_env): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="main" + ) + mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3") + (release_tool_env.git_root / "mock_file.bzl").write_text( + ":::{versionchanged} VERSION_NEXT_PATCH" + ) - next_version = utils.determine_next_version() + next_version = utils.determine_next_version() - self.assertEqual(next_version, "1.2.4") + assert next_version == "1.2.4" - def test_only_feature(self): - (self.tmpdir / "mock_file.bzl").write_text( - ":::{versionadded} VERSION_NEXT_FEATURE" - ) - self.mock_get_latest_version.return_value = "1.2.3" - next_version = utils.determine_next_version() +def test_determine_next_version_only_feature(mocker, release_tool_env): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="main" + ) + mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3") + (release_tool_env.git_root / "mock_file.bzl").write_text( + ":::{versionadded} VERSION_NEXT_FEATURE" + ) - self.assertEqual(next_version, "1.3.0") + next_version = utils.determine_next_version() - def test_both_markers(self): - (self.tmpdir / "mock_file_patch.bzl").write_text( - ":::{versionchanged} VERSION_NEXT_PATCH" - ) - (self.tmpdir / "mock_file_feature.bzl").write_text( - ":::{versionadded} VERSION_NEXT_FEATURE" - ) - self.mock_get_latest_version.return_value = "1.2.3" + assert next_version == "1.3.0" - next_version = utils.determine_next_version() - self.assertEqual(next_version, "1.3.0") +def test_determine_next_version_both_markers(mocker, release_tool_env): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="main" + ) + mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3") + (release_tool_env.git_root / "mock_file_patch.bzl").write_text( + ":::{versionchanged} VERSION_NEXT_PATCH" + ) + (release_tool_env.git_root / "mock_file_feature.bzl").write_text( + ":::{versionadded} VERSION_NEXT_FEATURE" + ) - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_with_existing_tags( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.37" - mock_get_tags.return_value = ["0.37.0", "0.37.1", "0.36.0"] + next_version = utils.determine_next_version() - next_version = utils.determine_next_version() + assert next_version == "1.3.0" - self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_no_tags( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.38" - mock_get_tags.return_value = ["0.37.0"] # No 0.38.x tags +def test_determine_next_version_on_release_branch_with_existing_tags(mocker): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="release/0.37" + ) + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=["0.37.0", "0.37.1", "0.36.0"], + ) - next_version = utils.determine_next_version() + next_version = utils.determine_next_version() - self.assertEqual(next_version, "0.38.0") + assert next_version == "0.37.2" - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_with_active_rc( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.37" - # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet - mock_get_tags.return_value = ["0.37.0-rc0", "0.37.0-rc1", "0.36.0"] - next_version = utils.determine_next_version() +def test_determine_next_version_on_release_branch_no_tags(mocker): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="release/0.38" + ) + mocker.patch( + "tools.private.release.git.Git.get_tags", return_value=["0.37.0"] + ) # No 0.38.x tags - # Should target 0.37.0, not 0.37.1 - self.assertEqual(next_version, "0.37.0") + next_version = utils.determine_next_version() - @patch("tools.private.release.git.Git.get_current_branch") - @patch("tools.private.release.git.Git.get_tags") - def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( - self, mock_get_tags, mock_get_branch - ): - mock_get_branch.return_value = "release/0.37" - # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet) - mock_get_tags.return_value = ["0.37.0", "0.37.1-rc0", "0.36.0"] + assert next_version == "0.38.0" + + +def test_determine_next_version_on_release_branch_with_active_rc(mocker): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="release/0.37" + ) + # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=["0.37.0-rc0", "0.37.0-rc1", "0.36.0"], + ) - next_version = utils.determine_next_version() + next_version = utils.determine_next_version() - # Should target 0.37.1, not 0.37.2 - self.assertEqual(next_version, "0.37.1") + # Should target 0.37.0, not 0.37.1 + assert next_version == "0.37.0" - @patch("tools.private.release.git.Git.get_current_branch") - def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): - mock_get_branch.return_value = "main" - # Should fallback to default behavior (which uses mock_get_latest_version from setUp) - self.mock_get_latest_version.return_value = "1.2.3" - (self.tmpdir / "mock_file.bzl").write_text("no markers here") - next_version = utils.determine_next_version() +def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( + mocker, +): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="release/0.37" + ) + # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet) + mocker.patch( + "tools.private.release.git.Git.get_tags", + return_value=["0.37.0", "0.37.1-rc0", "0.36.0"], + ) - self.assertEqual(next_version, "1.2.4") + next_version = utils.determine_next_version() + # Should target 0.37.1, not 0.37.2 + assert next_version == "0.37.1" -class ReplaceVersionNextTest(TempDirTestCase): - def test_replace_version_next(self): - # Arrange - mock_file_content = """ + +def test_determine_next_version_on_main_branch_fallback(mocker, release_tool_env): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="main" + ) + mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3") + (release_tool_env.git_root / "mock_file.bzl").write_text("no markers here") + + next_version = utils.determine_next_version() + + assert next_version == "1.2.4" + + +def test_replace_version_next(release_tool_env): + # Arrange + mock_file_content = """ :::{versionadded} VERSION_NEXT_FEATURE blabla ::: @@ -216,51 +246,47 @@ def test_replace_version_next(self): blabla ::: """ - (self.tmpdir / "mock_file.bzl").write_text(mock_file_content) + (release_tool_env.git_root / "mock_file.bzl").write_text(mock_file_content) + + utils.replace_version_next("0.28.0") - utils.replace_version_next("0.28.0") + new_content = (release_tool_env.git_root / "mock_file.bzl").read_text() - new_content = (self.tmpdir / "mock_file.bzl").read_text() + assert ":::{versionadded} 0.28.0" in new_content + assert "VERSION_NEXT_FEATURE" not in new_content + assert "VERSION_NEXT_PATCH" not in new_content - self.assertIn(":::{versionadded} 0.28.0", new_content) - self.assertIn(":::{versionadded} 0.28.0", new_content) - self.assertNotIn("VERSION_NEXT_FEATURE", new_content) - self.assertNotIn("VERSION_NEXT_PATCH", new_content) - def test_replace_version_next_excludes_bazel_dirs(self): - # Arrange - mock_file_content = """ +def test_replace_version_next_excludes_bazel_dirs(release_tool_env): + # Arrange + mock_file_content = """ :::{versionadded} VERSION_NEXT_FEATURE blabla ::: """ - bazel_dir = self.tmpdir / "bazel-rules_python" - bazel_dir.mkdir() - (bazel_dir / "mock_file.bzl").write_text(mock_file_content) - - tools_dir = self.tmpdir / "tools" / "private" / "release" - tools_dir.mkdir(parents=True) - (tools_dir / "mock_file.bzl").write_text(mock_file_content) - - tests_dir = self.tmpdir / "tests" / "tools" / "private" / "release" - tests_dir.mkdir(parents=True) - (tests_dir / "mock_file.bzl").write_text(mock_file_content) + bazel_dir = release_tool_env.git_root / "bazel-rules_python" + bazel_dir.mkdir() + (bazel_dir / "mock_file.bzl").write_text(mock_file_content) - version = "0.28.0" + tools_dir = release_tool_env.git_root / "tools" / "private" / "release" + tools_dir.mkdir(parents=True) + (tools_dir / "mock_file.bzl").write_text(mock_file_content) - # Act - utils.replace_version_next(version) + tests_dir = release_tool_env.git_root / "tests" / "tools" / "private" / "release" + tests_dir.mkdir(parents=True) + (tests_dir / "mock_file.bzl").write_text(mock_file_content) - # Assert - new_content = (bazel_dir / "mock_file.bzl").read_text() - self.assertIn("VERSION_NEXT_FEATURE", new_content) + version = "0.28.0" - new_content = (tools_dir / "mock_file.bzl").read_text() - self.assertIn("VERSION_NEXT_FEATURE", new_content) + # Act + utils.replace_version_next(version) - new_content = (tests_dir / "mock_file.bzl").read_text() - self.assertIn("VERSION_NEXT_FEATURE", new_content) + # Assert + new_content = (bazel_dir / "mock_file.bzl").read_text() + assert "VERSION_NEXT_FEATURE" in new_content + new_content = (tools_dir / "mock_file.bzl").read_text() + assert "VERSION_NEXT_FEATURE" in new_content -if __name__ == "__main__": - unittest.main() + new_content = (tests_dir / "mock_file.bzl").read_text() + assert "VERSION_NEXT_FEATURE" in new_content diff --git a/tools/private/release/backport_create_releases.py b/tools/private/release/backport_create_releases.py index 662ef96eb1..a6d2beabdf 100644 --- a/tools/private/release/backport_create_releases.py +++ b/tools/private/release/backport_create_releases.py @@ -162,7 +162,7 @@ def run(self) -> int: ) new_issue_num = "" else: - new_issue_num = self._gh.create_tracking_issue( + new_issue_num = self._gh.create_release_tracking_issue( version, issue_template ) print( diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py index 1c28e86c7d..72b71aa716 100644 --- a/tools/private/release/create_release_issue.py +++ b/tools/private/release/create_release_issue.py @@ -42,7 +42,7 @@ def run(self) -> int: lines = [line for line in lines if not re.search(r"Tag RC\d+", line)] template_content = "\n".join(lines) - issue_num = self.gh.create_tracking_issue(version, template_content) + issue_num = self.gh.create_release_tracking_issue(version, template_content) print(f"Created tracking issue #{issue_num} for v{version}") return 0 diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 27206f1ffe..9d157f31d6 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -1,9 +1,11 @@ """GitHub CLI helper functions for the release tool.""" +import enum import json import os import re import tempfile +from typing import TypedDict from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd @@ -24,6 +26,71 @@ GH_REACTION_EYES = "eyes" +class BackportTaskStatus(str, enum.Enum): + """Status strings for backport tasks on a release tracking issue.""" + + PENDING = "pending" + DONE = "done" + RESOLVED = "resolved" + OPEN_PR = "open-pr" + DRAFT_PR = "draft-pr" + ERROR_NOT_FOUND = "error-not-found" + ERROR_CLOSED_PR = "error-closed-pr" + ERROR_NO_MERGE_COMMIT = "error-no-merge-commit" + ERROR_UNKNOWN = "error-unknown" + ERROR_RESOLUTION_FAILED = "error-resolution-failed" + ERROR_MERGE_CONFLICT = "error-merge-conflict" + ERROR_INVALID_PR = "error-invalid-pr" + IGNORE = "ignore" + + def __str__(self) -> str: + return self.value + + +class IssueDict(TypedDict, total=False): + """In-memory representation of a GitHub Issue object. + + See GitHub API docs: + https://docs.github.com/en/rest/issues/issues#get-an-issue + """ + + number: int + title: str + body: str + labels: list[str] + url: str + + +class AutoMergeDict(TypedDict, total=False): + """Representation of auto-merge status on a Pull Request. + + See GitHub API docs: + https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request + """ + + merge_method: str + + +class PrDict(TypedDict, total=False): + """In-memory representation of a GitHub Pull Request object. + + See GitHub API docs: + https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request + """ + + number: int + title: str + body: str + base: str + head: str + labels: list[str] + url: str + state: str + isDraft: bool + mergeCommit: dict[str, str] + auto_merge: AutoMergeDict | None + + class MultipleTrackingIssuesError(ValueError): """Raised when multiple open tracking issues are found for a version.""" @@ -93,97 +160,107 @@ def list_issues( label: str | None = None, state: str | None = None, search: str | None = None, - ) -> list[dict]: + ) -> list[IssueDict]: """Helper to list issues using gh CLI. Args: fields: Comma-separated list of fields to return. label: Filter by label. - state: Filter by state (open, closed, all). + state: Filter by state ('open', 'closed', 'all'). search: Search query. Returns: - A list of dictionaries representing the issues. + A list of issue dictionaries. """ - cmd = ["list"] + cmd = ["list", f"--json={fields}"] if label: cmd.append(f"--label={label}") if state: cmd.append(f"--state={state}") if search: cmd.append(f"--search={search}") - cmd.append(f"--json={fields}") output = self._gh_issue(*cmd) return json.loads(output) if output else [] - def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: - """Returns a list of open tracking issues with the 'type: release' label. + def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: + """Finds open tracking issues for release. Args: - version: Optional version to filter by. + version: Optional specific version to match (e.g., "1.0.0"). Returns: - A list of open tracking issues. + List of matching open release tracking issue dictionaries. """ - search = f'"Release {version}" in:title' if version else None + search = f"Release {version}" if version else None return self.list_issues( + fields="number,title,url", label=RELEASE_LABEL, state="open", search=search, - fields="number,title,url", ) def get_release_tracking_issue(self, version: str) -> int: - """Resolves the tracking issue number for a given version. - - Searches for an open issue with label 'type: release' and 'Release - ' in the title. + """Finds the single open tracking issue for a given version. Args: - version: The version to find the tracking issue for. + version: Version string (e.g. "1.0.0"). Returns: - The tracking issue number. + The issue number. Raises: NoTrackingIssueError: If no open tracking issue is found. - MultipleTrackingIssuesError: If multiple open tracking issues are - found. + MultipleTrackingIssuesError: If multiple open tracking issues are found. """ - matching_issues = self.get_open_tracking_issues(version) - - exact_matches = [] - for issue in matching_issues: - if issue["title"] == f"Release {version}": - exact_matches.append(issue) - - if not exact_matches: + issues = self.get_open_tracking_issues(version) + matching = [i for i in issues if i["title"] == f"Release {version}"] + if not matching: raise NoTrackingIssueError( - f"No open tracking issue found matching 'Release {version}' " - f"in repo {self.repo} with label '{RELEASE_LABEL}'" + f"No open tracking issue found for Release {version}" ) - if len(exact_matches) > 1: - urls = [issue["url"] for issue in exact_matches] + if len(matching) > 1: raise MultipleTrackingIssuesError( - f"Multiple open tracking issues found for version {version} " - f"in repo {self.repo} with label '{RELEASE_LABEL}':\n" + "\n".join(urls) + f"Multiple open tracking issues found for Release {version}: " + + ", ".join(str(i["number"]) for i in matching) ) + return matching[0]["number"] - return exact_matches[0]["number"] + def create_issue( + self, title: str, body: str, labels: list[str] | None = None + ) -> int: + """Creates an issue using gh CLI. - def create_tracking_issue(self, version: str, template_content: str) -> int: - """Creates a new release tracking issue from template content. + Args: + title: Title of the issue. + body: Body of the issue. + labels: List of labels to add. - Strips YAML frontmatter if present. + Returns: + The issue number. + """ + cmd = ["create", f"--title={title}", f"--body={body}"] + if labels: + for label in labels: + cmd.append(f"--label={label}") + + output = self._gh_issue(*cmd) + if not output: + raise RuntimeError("gh issue create returned no output") + # output is URL: https://github.com/owner/repo/issues/123 + return int(output.rstrip("/").split("/")[-1]) + + def create_release_tracking_issue(self, version: str, template_content: str) -> int: + """Creates a release tracking issue from a template. Args: - version: The version to create the tracking issue for. - template_content: The markdown template content for the issue body. + version: Release version string (e.g., "1.0.0"). + template_content: Content of the issue template markdown file. Returns: The created issue number. """ + title = f"Release {version}" # Strip YAML frontmatter if present issue_body = template_content if template_content.startswith("---"): @@ -191,110 +268,111 @@ def create_tracking_issue(self, version: str, template_content: str) -> int: if len(parts) >= 3: issue_body = parts[2].strip() - with tempfile.NamedTemporaryFile(mode="w", suffix=".md") as f: - f.write(issue_body) - f.flush() - temp_path = f.name - - output = self._gh_issue( - "create", - f"--title=Release {version}", - f"--label={RELEASE_LABEL}", - f"--body-file={temp_path}", - ) - if not output: - raise RuntimeError("Failed to get issue URL from gh issue create") - issue_url = output.strip() - issue_num = int(issue_url.split("/")[-1]) - return issue_num - - def create_issue( - self, title: str, body: str, labels: list[str] | None = None - ) -> int: - """Creates a generic issue. - - Args: - title: The title of the issue. - body: The body of the issue. - labels: Optional list of labels to add. - - Returns: - The created issue number. - """ - with tempfile.NamedTemporaryFile(mode="w", suffix=".md") as f: - f.write(body) - f.flush() - temp_path = f.name - - cmd = [ - "create", - f"--title={title}", - f"--body-file={temp_path}", - ] - if labels: - for label in labels: - cmd.append(f"--label={label}") - - output = self._gh_issue(*cmd) - if not output: - raise RuntimeError("Failed to get issue URL from gh issue create") - issue_url = output.strip() - issue_num = int(issue_url.split("/")[-1]) - return issue_num + return self.create_issue(title=title, body=issue_body, labels=[RELEASE_LABEL]) def get_issue_body(self, issue_num: int) -> str: - """Fetches the body of a specific issue. + """Gets the body content of an issue. Args: issue_num: The issue number. Returns: - The issue body markdown. + The body string of the issue. """ - output = self._gh_issue( - "view", - str(issue_num), - "--json=body", - "--jq=.body", - ) - return output if output else "" + output = self._gh_issue("view", str(issue_num), "--json=body") + if not output: + return "" + data = json.loads(output) + return data.get("body", "") def get_issue_title(self, issue_num: int) -> str: - """Fetches the title of a specific issue. + """Gets the title of an issue. Args: issue_num: The issue number. Returns: - The issue title. + The title string of the issue. """ - output = self._gh_issue( - "view", - str(issue_num), - "--json=title", - ) - return json.loads(output)["title"] if output else "" + output = self._gh_issue("view", str(issue_num), "--json=title") + if not output: + return "" + data = json.loads(output) + return data.get("title", "") def update_issue_body(self, issue_num: int, body: str) -> None: - """Updates the body of a specific issue. + """Updates the body of an issue. Args: issue_num: The issue number. - body: The new issue body markdown. + body: The new body content. """ - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + with tempfile.NamedTemporaryFile("w", delete=False, mode="w") as f: f.write(body) + f.flush() temp_path = f.name + try: self._gh_issue( - "edit", - str(issue_num), - f"--body-file={temp_path}", - capture_output=False, + "edit", str(issue_num), f"--body-file={temp_path}", capture_output=False ) finally: if os.path.exists(temp_path): - os.unlink(temp_path) + os.remove(temp_path) + + def resolve_pr_number(self, pr_ref: str) -> int: + """Resolves a PR reference (number, #number, or GitHub URL) to a PR number. + + Args: + pr_ref: PR number string (e.g., "123", "#123") or URL. + + Returns: + The integer PR number. + + Raises: + ValueError: If the PR reference cannot be resolved or is for another repo. + """ + clean_ref = pr_ref.lstrip("#") + if clean_ref.isdigit(): + return int(clean_ref) + + if pr_ref.startswith("http"): + pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)" + match = re.search(pattern, pr_ref, re.IGNORECASE) + if match: + return int(match.group(1)) + raise ValueError( + f"URL is not for the configured repository ({self.repo}): {pr_ref}" + ) + + raise ValueError(f"Could not resolve PR reference: {pr_ref}") + + def get_pr_info(self, pr_num: int) -> PrDict: + """Gets info about a PR using gh CLI. + + Args: + pr_num: The PR number. + + Returns: + Dictionary containing PR fields (state, isDraft, mergeCommit, etc.). + """ + output = self._gh_pr("view", str(pr_num), "--json=state,isDraft,mergeCommit") + return json.loads(output) if output else {} + + def get_pr_comments(self, pr_num: int) -> list[dict]: + """Gets all comments for a PR using gh CLI. + + Args: + pr_num: The PR number. + + Returns: + List of comment objects (with body, author, etc.). + """ + output = self._gh_pr("view", str(pr_num), "--json=comments") + if not output: + return [] + data = json.loads(output) + return data.get("comments", []) def create_pr( self, @@ -306,10 +384,10 @@ def create_pr( """Creates a pull request. Args: - title: The title of the PR. - body: The body of the PR. - base: The base branch to merge into (default: 'main'). - labels: Optional list of labels to add to the PR. + title: Title of the PR. + body: Body of the PR. + base: Base branch to merge into (default: "main"). + labels: Optional list of labels to add. Returns: The URL of the created PR. @@ -342,95 +420,31 @@ def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: cmd.append("--merge") self._gh_pr(*cmd, capture_output=False) - def get_open_pr(self, branch_name: str) -> dict | None: - """Returns PR info if an open PR exists for the given branch. + def get_open_pr(self, branch_name: str) -> PrDict | None: + """Finds an open PR for the given branch. Args: - branch_name: The head branch name of the PR. + branch_name: The head branch name to search for. Returns: - A dictionary with 'number' and 'url' of the PR, or None. + Dictionary with 'number' and 'url' if an open PR exists, else None. """ - output = self._gh_pr( + cmd = [ "list", f"--head={branch_name}", "--state=open", "--json=number,url", - ) + ] + output = self._gh_pr(*cmd) prs = json.loads(output) if output else [] return prs[0] if prs else None - def get_pr_info(self, pr_num: int) -> dict: - """Gets information about a PR. - - Includes state, merge commit, body, and draft status. - - Args: - pr_num: The PR number. - - Returns: - A dictionary containing the PR info. - """ - output = self._gh_pr( - "view", - str(pr_num), - "--json=state,mergeCommit,body,isDraft", - ) - return json.loads(output) if output else {} - - def get_pr_comments(self, pr_num: int) -> list[dict]: - """Gets comments for a PR. - - Args: - pr_num: The PR number. - - Returns: - A list of comments. - """ - output = self._gh_pr( - "view", - str(pr_num), - "--json=comments", - ) - return json.loads(output).get("comments") or [] - - def resolve_pr_number(self, pr_ref: str) -> int: - """Resolves a PR reference (number, #number, URL) to a PR number. - - Args: - pr_ref: The PR reference string. - - Returns: - The resolved PR number. - - Raises: - ValueError: If the reference cannot be resolved. - """ - # 1. Try number (e.g. "123" or "#123") - clean_ref = pr_ref.lstrip("#") - if clean_ref.isdigit(): - return int(clean_ref) - - # 2. Try URL (starts with http) - if pr_ref.startswith("http"): - # Try to extract PR number from URL using regex - # Pattern matches: github.com//pull/ followed by /, ?, or EOF - pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)" - match = re.search(pattern, pr_ref, re.IGNORECASE) - if match: - return int(match.group(1)) - raise ValueError( - f"URL is not for the configured repository ({self.repo}): {pr_ref}" - ) - - raise ValueError(f"Could not resolve PR reference: {pr_ref}") - def post_issue_comment(self, issue_num: int, comment_body: str) -> None: - """Posts a comment to a specific issue. + """Posts a comment on an issue or PR. Args: - issue_num: The issue number. - comment_body: The comment body markdown. + issue_num: The issue or PR number. + comment_body: The body content of the comment. """ self._gh_issue( "comment", @@ -440,22 +454,15 @@ def post_issue_comment(self, issue_num: int, comment_body: str) -> None: ) def add_comment_reaction(self, comment_id: int, reaction: str) -> None: - """Adds a reaction to a comment. + """Adds a reaction to an issue or PR comment. Args: - comment_id: The ID of the comment. - reaction: The reaction type (e.g. '+1', '-1', 'eyes', etc). + comment_id: The comment ID (note: gh api endpoint needed for comment reactions). + reaction: The reaction type (e.g., "+1", "-1", "rocket"). """ - path = f"/repos/{self.repo}/issues/comments/{comment_id}/reactions" self._run_gh( "api", - "--method", - "POST", - "-H", - "Accept: application/vnd.github+json", - "-H", - "X-GitHub-Api-Version: 2022-11-28", - path, + f"repos/{self.repo}/issues/comments/{comment_id}/reactions", "-f", f"content={reaction}", capture_output=False, @@ -474,40 +481,61 @@ def get_merge_commits_for_prs( Returns: The list of resolved BackportTask items. """ - resolved_items = [] - for item in pending_items: - pr_num = int(item.pr_ref.lstrip("#")) - print(f"Resolving PR #{pr_num} to merge commit...") - try: - pr_info = self.get_pr_info(pr_num) - if not pr_info: - print(f"PR #{pr_num} not found. Gating.") - item.status = "error-not-found" - else: - state = pr_info.get("state") - is_draft = pr_info.get("isDraft", False) - if state == "OPEN" or is_draft: - print( - f"PR #{pr_num} is open or draft (state: {state}," - f" draft: {is_draft}). Ignoring." - ) - item.status = "open-pr" if not is_draft else "draft-pr" - elif state == "CLOSED": - print(f"PR #{pr_num} is closed but not merged. Gating.") - item.status = "error-closed-pr" - elif state == "MERGED": - merge_commit = pr_info.get("mergeCommit") - if merge_commit and "oid" in merge_commit: - item.commit = merge_commit["oid"] - item.status = "resolved" - else: - print(f"PR #{pr_num} has no merge commit SHA. Gating.") - item.status = "error-no-merge-commit" + return resolve_merge_commits_for_prs(self, pending_items) + + +def resolve_merge_commits_for_prs( + gh_client: GitHub, pending_items: list[BackportTask] +) -> list[BackportTask]: + """Resolves PR references in pending backports to their merge commit SHAs. + + Updates item.status based on PR state if it cannot be resolved. + + Args: + gh_client: The GitHub client. + pending_items: A list of BackportTask items to resolve. + + Returns: + The list of resolved BackportTask items. + """ + resolved_items = [] + for item in pending_items: + pr_num = int(item.pr_ref.lstrip("#")) + print(f"Resolving PR #{pr_num} to merge commit...") + try: + pr_info = gh_client.get_pr_info(pr_num) + if not pr_info: + print(f"PR #{pr_num} not found. Gating.") + item.status = BackportTaskStatus.ERROR_NOT_FOUND + else: + state = pr_info.get("state") + is_draft = pr_info.get("isDraft", False) + if state == "OPEN" or is_draft: + print( + f"PR #{pr_num} is open or draft (state: {state}," + f" draft: {is_draft}). Ignoring." + ) + item.status = ( + BackportTaskStatus.OPEN_PR + if not is_draft + else BackportTaskStatus.DRAFT_PR + ) + elif state == "CLOSED": + print(f"PR #{pr_num} is closed but not merged. Gating.") + item.status = BackportTaskStatus.ERROR_CLOSED_PR + elif state == "MERGED": + merge_commit = pr_info.get("mergeCommit") + if merge_commit and "oid" in merge_commit: + item.commit = merge_commit["oid"] + item.status = BackportTaskStatus.RESOLVED else: - print(f"PR #{pr_num} has unknown state: {state}. Gating.") - item.status = "error-unknown" - except Exception as e: - print(f"Error resolving PR #{pr_num}: {e}. Gating.") - item.status = "error-resolution-failed" - resolved_items.append(item) - return resolved_items + print(f"PR #{pr_num} has no merge commit SHA. Gating.") + item.status = BackportTaskStatus.ERROR_NO_MERGE_COMMIT + else: + print(f"PR #{pr_num} has unknown state: {state}. Gating.") + item.status = BackportTaskStatus.ERROR_UNKNOWN + except Exception as e: + print(f"Error resolving PR #{pr_num}: {e}. Gating.") + item.status = BackportTaskStatus.ERROR_RESOLUTION_FAILED + resolved_items.append(item) + return resolved_items diff --git a/tools/private/release/mock_gh.py b/tools/private/release/mock_gh.py index 3a5a8dde48..e5def53799 100644 --- a/tools/private/release/mock_gh.py +++ b/tools/private/release/mock_gh.py @@ -2,15 +2,36 @@ import re -from tools.private.release.gh import RELEASE_LABEL +from tools.private.release.gh import ( + RELEASE_LABEL, + IssueDict, + MultipleTrackingIssuesError, + NoTrackingIssueError, + PrDict, + resolve_merge_commits_for_prs, +) class MockGitHub: def __init__(self, repo: str = "bazel-contrib/rules_python"): self.repo = repo - self.issues = {} + self.issues: dict[int, IssueDict] = {} self.next_issue_num = 1001 - self.prs = {} # num -> pr_info + self.prs: dict[int, PrDict] = {} # num -> pr_info + self.issue_comments: dict[int, list[str]] = {} + self.reactions: dict[int, list[str]] = {} + self.pr_comments: dict[int, list[dict]] = {} + + def post_issue_comment(self, issue_num: int, comment_body: str) -> None: + self.issue_comments.setdefault(issue_num, []).append(comment_body) + + def add_comment_reaction(self, comment_id: int, reaction: str) -> None: + self.reactions.setdefault(comment_id, []).append(reaction) + + def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: + if pr_num not in self.prs: + self.create_pr(title="", body="") + self.prs[pr_num]["auto_merge"] = {"merge_method": method} def create_issue( self, title: str, body: str, labels: list[str] | None = None @@ -26,7 +47,7 @@ def create_issue( } return issue_num - def create_tracking_issue(self, version: str, template_content: str) -> int: + def create_release_tracking_issue(self, version: str, template_content: str) -> int: # Strip YAML frontmatter if present (simplified copy from gh.py) issue_body = template_content if template_content.startswith("---"): @@ -43,6 +64,11 @@ def get_issue_body(self, issue_num: int) -> str: raise ValueError(f"Issue #{issue_num} not found in MockGitHub") return self.issues[issue_num]["body"] + def get_issue_title(self, issue_num: int) -> str: + if issue_num not in self.issues: + raise ValueError(f"Issue #{issue_num} not found in MockGitHub") + return self.issues[issue_num]["title"] + def update_issue_body(self, issue_num: int, body: str): if issue_num not in self.issues: raise ValueError(f"Issue #{issue_num} not found in MockGitHub") @@ -64,7 +90,52 @@ def resolve_pr_number(self, pr_ref: str) -> int: ) raise ValueError(f"Could not resolve PR ref: {pr_ref}") - def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: + def get_release_tracking_issue(self, version: str) -> int: + search_title = f"Release {version}" + matching = [ + num + for num, issue in self.issues.items() + if issue["title"] == search_title + and RELEASE_LABEL in issue.get("labels", []) + ] + if not matching: + raise NoTrackingIssueError( + f"No open tracking issue found for Release {version}" + ) + if len(matching) > 1: + raise MultipleTrackingIssuesError( + f"Multiple open tracking issues found for Release {version}" + ) + return matching[0] + + def create_pr( + self, + title: str, + body: str, + base: str = "main", + labels: list[str] | None = None, + ) -> str: + pr_num = self.next_issue_num + self.next_issue_num += 1 + url = f"https://github.com/{self.repo}/pull/{pr_num}" + self.prs[pr_num] = { + "title": title, + "body": body, + "base": base, + "labels": labels or [], + "number": pr_num, + "url": url, + "state": "OPEN", + } + return url + + def get_open_pr(self, branch_name: str) -> PrDict | None: + for pr in self.prs.values(): + if pr.get("head") == branch_name and pr.get("state") == "OPEN": + return pr + return None + + def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: results = [] for issue in self.issues.values(): if RELEASE_LABEL in issue["labels"]: @@ -75,10 +146,16 @@ def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: results.append(issue) return results - def get_pr_info(self, pr_num: int) -> dict: + def get_pr_info(self, pr_num: int) -> PrDict: if pr_num in self.prs: return self.prs[pr_num] return { "state": "MERGED", "mergeCommit": {"oid": f"mock_merge_sha_{pr_num}"}, } + + def get_pr_comments(self, pr_num: int) -> list[dict]: + return self.pr_comments.get(pr_num, []) + + def get_merge_commits_for_prs(self, pending_items: list) -> list: + return resolve_merge_commits_for_prs(self, pending_items) diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index fd19362bec..b01f135ff8 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -88,7 +88,9 @@ def run(self) -> int: f"No active tracking issue found for {version}." " Creating a new one..." ) - issue_num = self.gh.create_tracking_issue(version, template_content) + issue_num = self.gh.create_release_tracking_issue( + version, template_content + ) print(f"Tracking issue: #{issue_num}") else: print(f"Tracking issue: #{issue_num}") From 7fce328b34a7296e97548e8f3f24937027e65ae4 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 20 Jul 2026 11:17:46 -0700 Subject: [PATCH 092/179] refactor(release): extract pypi publishing to standalone workflow (#3940) Factor out PyPI publishing from release_publish.yaml into a new standalone workflow publish_pypi.yaml. This enables PyPI publishing to be manually executed via workflow_dispatch while maintaining the ability to be invoked by release_publish.yaml during release execution. --- .github/workflows/publish_pypi.yaml | 32 ++++++++++++++++++++++++++ .github/workflows/release_publish.yaml | 20 ++++------------ RELEASING.md | 10 ++++++++ 3 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/publish_pypi.yaml diff --git a/.github/workflows/publish_pypi.yaml b/.github/workflows/publish_pypi.yaml new file mode 100644 index 0000000000..44f0a1d9fb --- /dev/null +++ b/.github/workflows/publish_pypi.yaml @@ -0,0 +1,32 @@ +name: Publish to PyPI + +on: + workflow_call: + inputs: + tag_name: + description: "release tag: tag that will be released" + required: true + type: string + workflow_dispatch: + inputs: + tag_name: + description: "release tag: tag that will be released" + required: true + type: string + +jobs: + publish_pypi: + name: Publish runfiles to PyPI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag_name || github.ref_name }} + - env: + # This special value tells pypi that the user identity is supplied within the token + TWINE_USERNAME: __token__ + # Note, the PYPI_API_TOKEN is for the rules-python pypi user, added by @rickylev on + # https://github.com/bazel-contrib/rules_python/settings/secrets/actions + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: bazel run --stamp --embed_label=${{ inputs.tag_name || github.ref_name }} //python/runfiles:wheel.publish diff --git a/.github/workflows/release_publish.yaml b/.github/workflows/release_publish.yaml index 8379c3d46d..eedf653fc1 100644 --- a/.github/workflows/release_publish.yaml +++ b/.github/workflows/release_publish.yaml @@ -77,19 +77,9 @@ jobs: publish_pypi: # We just want publish_pypi last, since once uploaded, it can't be changed. - name: Publish runfiles to PyPI needs: publish_bcr - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - ref: ${{ inputs.tag_name || github.ref_name }} - - if: github.event_name == 'push' || github.event.inputs.publish_to_pypi - env: - # This special value tells pypi that the user identity is supplied within the token - TWINE_USERNAME: __token__ - # Note, the PYPI_API_TOKEN is for the rules-python pypi user, added by @rickylev on - # https://github.com/bazel-contrib/rules_python/settings/secrets/actions - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: bazel run --stamp --embed_label=${{ inputs.tag_name || github.ref_name }} //python/runfiles:wheel.publish + if: github.event_name == 'push' || inputs.publish_to_pypi + uses: ./.github/workflows/publish_pypi.yaml + with: + tag_name: ${{ inputs.tag_name || github.ref_name }} + secrets: inherit diff --git a/RELEASING.md b/RELEASING.md index a03cd83652..1942553538 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -63,6 +63,16 @@ you can set the `publish_to_pypi` input to `false`: gh workflow run release_publish.yaml --ref -f publish_to_pypi=false ``` +### Manually publishing to PyPI + +If PyPI publishing failed or was skipped during the main release, the PyPI +publishing workflow can be triggered manually using the GitHub CLI (`gh`) or +via the [GitHub Actions UI](https://github.com/bazel-contrib/rules_python/actions/workflows/publish_pypi.yaml): + +```shell +gh workflow run publish_pypi.yaml --ref -f tag_name= +``` + ### Determining Semantic Version **rules_python** uses [semantic version](https://semver.org), so releases with From a57eff9129a67d034d4bf3a6f4b22914196572b4 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 20 Jul 2026 11:47:56 -0700 Subject: [PATCH 093/179] docs(releasing): update PyPI account owner information (#3941) Update RELEASING.md to clarify that rickeylev manages the PyPI account, while Google retains recovery access via recovery codes and the rules-python-pypi@google.com email address. --- RELEASING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 1942553538..2b3d5859c1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -260,5 +260,6 @@ The checklist items use metadata suffix: `| key=value key2=value2`. ### PyPI user rules-python Part of the release process uploads packages to PyPI as the user `rules-python`. -This account is managed by Google; contact rules-python-pyi@google.com if -something needs to be done with the PyPI account. +This account is managed by `rickeylev`. Note that Google also has recovery access +and can be contacted at rules-python-pypi@google.com if something needs to be +done with the PyPI account. From ced895d0d59eefc24d1d5522a04861dae6d01e98 Mon Sep 17 00:00:00 2001 From: Logan Pulley Date: Mon, 20 Jul 2026 18:51:06 -0500 Subject: [PATCH 094/179] fix(pypi): don't expose source-less uv.lock packages (#3938) > [!NOTE] > I created this PR with AI. Fixes #3934. ## Problem `pip.parse(uv_lock = ...)` exposes every `[[package]]` in the lock, including uv workspace/root members with `source = { virtual = "." }` (and editable installs). These resolve to no wheel/sdist (`srcs = []`) but were still marked `is_exposed = True`, so the hub adds them to `all_requirements` / `all_whl_requirements` and creates an alias to a subpackage that doesn't exist. Anything enumerating the full set then fails analysis, e.g. `modules_mapping(wheels = all_whl_requirements)`: ``` ERROR: no such package '@@rules_python++pip+pip//myproject': BUILD file not found ... and referenced by '//:modules_map' ``` ## Fix `_parse_uv_lock_json` in `python/private/pypi/parse_requirements.bzl` set `is_exposed = True` unconditionally. This gates it on whether the package actually resolved to any sources: ```starlark is_exposed = bool(info["resolved_srcs"]), ``` The entry is still kept; it just isn't exposed when nothing resolved. This mirrors the requirements path, which already gates `is_exposed`. ## Tests As called out in the issue, this flips the expectations in two existing tests, both of which assert on source-less packages: - `_test_uv_lock_primary_source_includes_virtual` (the `virtual_pkg` entry) - `_test_uv_lock_requires_dist_extras` (the `root_pkg` entry) Both now expect `is_exposed = False`. A `news/3934.fixed.md` fragment is included. --- news/3934.fixed.md | 7 +++++++ python/private/pypi/parse_requirements.bzl | 9 ++++++++- .../pypi/parse_requirements/parse_requirements_tests.bzl | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 news/3934.fixed.md diff --git a/news/3934.fixed.md b/news/3934.fixed.md new file mode 100644 index 0000000000..a30f475770 --- /dev/null +++ b/news/3934.fixed.md @@ -0,0 +1,7 @@ +(pypi) `pip.parse(uv_lock = ...)` no longer exposes uv workspace/root members +that resolve to no wheel or sdist (e.g. `source = { virtual = "." }` or editable +installs). Previously these source-less packages were added to the hub's +`all_requirements` / `all_whl_requirements` with an alias to a subpackage that +does not exist, breaking analysis for anything enumerating the full set such as +`modules_mapping(wheels = all_whl_requirements)` +([#3934](https://github.com/bazel-contrib/rules_python/issues/3934)). diff --git a/python/private/pypi/parse_requirements.bzl b/python/private/pypi/parse_requirements.bzl index be3ad9a5e5..648ec9e65c 100644 --- a/python/private/pypi/parse_requirements.bzl +++ b/python/private/pypi/parse_requirements.bzl @@ -292,7 +292,14 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p versions = sorted(info["versions"].keys()) item = struct( name = norm_name, - is_exposed = True, + # Only expose packages that resolved to at least one source. uv + # workspace/root members (e.g. `source = { virtual = "." }` or + # editable installs) resolve to no wheel/sdist, so exposing them + # would add a dangling entry to the hub's `all_requirements` / + # `all_whl_requirements` and create an alias to a subpackage that + # doesn't exist. This mirrors the requirements path, which also + # gates `is_exposed`. + is_exposed = bool(info["resolved_srcs"]), is_multiple_versions = len(versions) > 1, index_url = info["index_url"], srcs = info["resolved_srcs"], diff --git a/tests/pypi/parse_requirements/parse_requirements_tests.bzl b/tests/pypi/parse_requirements/parse_requirements_tests.bzl index 576a31ae5d..57b3ea5d3e 100644 --- a/tests/pypi/parse_requirements/parse_requirements_tests.bzl +++ b/tests/pypi/parse_requirements/parse_requirements_tests.bzl @@ -1193,7 +1193,7 @@ def _test_uv_lock_primary_source_includes_virtual(env): struct( name = "virtual_pkg", index_url = "", - is_exposed = True, + is_exposed = False, is_multiple_versions = False, srcs = [], ), @@ -1620,7 +1620,7 @@ def _test_uv_lock_requires_dist_extras(env): struct( name = "root_pkg", index_url = "", - is_exposed = True, + is_exposed = False, is_multiple_versions = False, srcs = [], ), From 8cb7d1cdc8b79912b917c90ab97497a407347f9a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 21 Jul 2026 19:20:36 -0700 Subject: [PATCH 095/179] refactor(reviewbot): use separate jobs to protect access to credentials (#3943) automated_pr_review.yaml previously checked out untrusted pull request code into the workflow root and ran uv run directly from that directory while exposing GEMINI_API_KEY. Because uv run by default searches $PWD for project build files (pyproject.toml, setup.py), an untrusted PR could trigger arbitrary code execution during workflow runs. To fix: - Split the workflow into two separate jobs: `prepare_diff` (runs in an unprivileged context without access to secrets to safely extract the git diff from untrusted PR code) and `review` (runs in the trusted base branch context with secrets, consuming only the text diff artifact without checking out untrusted PR head onto disk). - Check out the untrusted PR branch into a dedicated `untrusted_pr_head` subdirectory in `prepare_diff` so it is treated strictly as input data. - Run `uv` with `--no-project --directory .` inside `working-directory: reviewbot` to prevent `uv` from searching `$PWD` or discovering untrusted project configuration files. --- .github/workflows/automated_pr_review.yaml | 45 ++++++++++++++----- tools/private/reviewbot/antigravity_review.py | 7 ++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index 1076101db6..3fe93db7f3 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -21,10 +21,10 @@ jobs: - name: Workflow trigger check run: echo "Workflow triggered successfully." - review: + # Job 1: Runs without secrets to extract the git diff from untrusted PR code. + # Keeps GEMINI_API_KEY away from any environment that checks out untrusted PR files. + prepare_diff: runs-on: ubuntu-latest - # Trigger only if it is a regular comment on a pull request, the comment body has a line - # starting with "/review", and the commenter is a maintainer (OWNER, MEMBER, or COLLABORATOR). if: > github.event_name == 'issue_comment' && github.event.issue.pull_request != null && (startsWith(github.event.comment.body, '/review') || @@ -32,19 +32,18 @@ jobs: contains(github.event.comment.body, '\r\n/review')) && contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) steps: - - name: Checkout PR Branch + - name: Checkout PR Branch (Data Only) uses: actions/checkout@v7 with: - # Note: In GHA, during an issue_comment event on a PR, github.event.pull_request is null - # and the PR number is placed inside github.event.issue.number (because every PR is an issue). - # Therefore, github.event.issue.number is the PR number when checking out refs/pull//head. ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head + path: untrusted_pr_head persist-credentials: false - name: Fetch Base Branch and Negotiate Minimal Diff History env: PR_COMMITS: ${{ github.event.pull_request.commits }} run: | + cd untrusted_pr_head # 1. Fetch the tip of main git fetch origin main:refs/remotes/origin/main --depth=1 @@ -59,8 +58,27 @@ jobs: git fetch --unshallow || git fetch --deepen=50 fi fi + git diff origin/main...HEAD > ../pr_diff.txt + + # Upload extracted diff as artifact to pass to Job 2 safely as text data. + - name: Upload Diff Artifact + uses: actions/upload-artifact@v4 + with: + name: pr_diff + path: pr_diff.txt + + # Job 2: Runs with secrets in base branch context. + review: + needs: prepare_diff + runs-on: ubuntu-latest + steps: + - name: Download Diff Artifact + uses: actions/download-artifact@v4 + with: + name: pr_diff - - name: Checkout Reviewbot (Base Branch) + # Check out reviewbot into a separate directory to isolate base branch tool code. + - name: Checkout Reviewbot (Base Branch Only) uses: actions/checkout@v7 with: sparse-checkout: | @@ -71,9 +89,16 @@ jobs: uses: astral-sh/setup-uv@v8.3.2 - name: Run Antigravity Review + # Run inside reviewbot directory so execution context is the trusted base branch. + # This also helps prevent uv from looking for config files in locations it shouldn't, + # i.e. by default, uv will look in $PWD for a pyproject file to build. + working-directory: reviewbot env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - uv run reviewbot/tools/private/reviewbot/antigravity_review.py \ - --prompt reviewbot/tools/private/reviewbot/prompt.txt + # Use --no-project to prevent uv from discovering or building pyproject.toml/setup.py + # in the workspace, ensuring only standalone script dependencies are resolved. + uv run --no-project --directory . tools/private/reviewbot/antigravity_review.py \ + --prompt tools/private/reviewbot/prompt.txt \ + --diff-file ../pr_diff.txt diff --git a/tools/private/reviewbot/antigravity_review.py b/tools/private/reviewbot/antigravity_review.py index 3aef480f3b..4ae0ef1690 100644 --- a/tools/private/reviewbot/antigravity_review.py +++ b/tools/private/reviewbot/antigravity_review.py @@ -17,11 +17,14 @@ def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--prompt", required=True, help="Path to prompt file") + parser.add_argument("--diff-file", help="Path to pre-computed diff file") return parser.parse_args() -def get_pr_diff() -> str: +def get_pr_diff(diff_file: str | None = None) -> str: """Fetches the git diff for the current pull request against origin/main.""" + if diff_file and Path(diff_file).exists(): + return Path(diff_file).read_text() try: return subprocess.check_output( ["git", "diff", "origin/main...HEAD"], text=True, stderr=subprocess.DEVNULL @@ -35,7 +38,7 @@ async def main(): # Read prompt file and pre-hydrate with the exact PR code diff base_prompt = Path(args.prompt).read_text() - diff_text = get_pr_diff() + diff_text = get_pr_diff(args.diff_file) prompt = ( f"{base_prompt}\n\n## Pull Request Git Diff\n" f"Here is the exact code diff for this pull request:\n```diff\n{diff_text}\n```" From d21d3f073f70f57838b7828142d37c6de7e7d188 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 22 Jul 2026 10:34:27 -0700 Subject: [PATCH 096/179] test(tools): auto-patch release CLI helpers in conftest.py (#3939) Add an autouse fixture in conftest.py for tests/tools/private/release that automatically patches run_cmd, Git._run_git, and GitHub._run_gh using pytest-mock. This prevents tests from executing command line tools that could have side-effects. --- tests/tools/private/release/BUILD.bazel | 5 ++- tests/tools/private/release/conftest.py | 50 +++++++++++++++++++++++++ tests/tools/private/release/gh_test.py | 18 +++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/tools/private/release/conftest.py diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 5ac912a955..317a4626c9 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -4,7 +4,10 @@ load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") py_library( name = "release_test_helper", - srcs = ["release_test_helper.py"], + srcs = [ + "conftest.py", + "release_test_helper.py", + ], target_compatible_with = SUPPORTS_BZLMOD, deps = [ "//tools/private/release:mock_gh", diff --git a/tests/tools/private/release/conftest.py b/tests/tools/private/release/conftest.py new file mode 100644 index 0000000000..f8798c11c7 --- /dev/null +++ b/tests/tools/private/release/conftest.py @@ -0,0 +1,50 @@ +import dataclasses +from unittest.mock import MagicMock + +import pytest + +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +@dataclasses.dataclass +class AutoPatchCmdHelpers: + """Dataclass holding mocked command helpers.""" + + run_cmd: MagicMock + run_git: MagicMock + run_gh: MagicMock + + +@pytest.fixture(name="mock_run_cmd") +def fixture_mock_run_cmd(mocker): + """Fixture to patch shell.run_cmd and its imports in git and gh modules.""" + mock = mocker.patch("tools.private.release.shell.run_cmd") + mocker.patch("tools.private.release.git.run_cmd", mock) + mocker.patch("tools.private.release.gh.run_cmd", mock) + return mock + + +@pytest.fixture(name="mock_run_git") +def fixture_mock_run_git(mocker): + """Fixture to patch Git._run_git.""" + return mocker.patch("tools.private.release.git.Git._run_git") + + +@pytest.fixture(name="mock_run_gh") +def fixture_mock_run_gh(mocker): + """Fixture to patch GitHub._run_gh.""" + return mocker.patch("tools.private.release.gh.GitHub._run_gh") + + +@pytest.fixture(name="auto_patch_cmd_helpers", autouse=True) +def fixture_auto_patch_cmd_helpers(mock_run_cmd, mock_run_git, mock_run_gh): + """Automatically patches run_cmd, Git, and GitHub CLI helpers. + + This prevents tests from executing command line tools that could have + side-effects. + """ + return AutoPatchCmdHelpers( + run_cmd=mock_run_cmd, + run_git=mock_run_git, + run_gh=mock_run_gh, + ) diff --git a/tests/tools/private/release/gh_test.py b/tests/tools/private/release/gh_test.py index ccc61e7f21..b8c346004b 100644 --- a/tests/tools/private/release/gh_test.py +++ b/tests/tools/private/release/gh_test.py @@ -1,6 +1,8 @@ import pytest +from tools.private.release import shell from tools.private.release.gh import GitHub +from tools.private.release.git import Git pytest_plugins = ["tests.tools.private.release.release_test_helper"] @@ -59,3 +61,19 @@ def test_resolve_pr_number_invalid(mocker, gh): with pytest.raises(ValueError, match="Could not resolve PR reference"): gh.resolve_pr_number("invalid-ref") mock_run_cmd.assert_not_called() + + +def test_auto_patched_helpers_prevent_real_execution(auto_patch_cmd_helpers): + # Calling run_cmd directly hits the mock + shell.run_cmd("echo", "test") + auto_patch_cmd_helpers.run_cmd.assert_called_with("echo", "test") + + # Git._run_git hits the mock + git = Git(".") + git._run_git("status") + auto_patch_cmd_helpers.run_git.assert_called_with("status") + + # GitHub._run_gh hits the mock + gh_obj = GitHub("foo/bar") + gh_obj._run_gh("issue", "list") + auto_patch_cmd_helpers.run_gh.assert_called_with("issue", "list") From c6d1c916f83fcea25cdf4c3477c3d54484c3c730 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 22 Jul 2026 19:09:08 -0700 Subject: [PATCH 097/179] tests: add workspace-compatible unified pypi hub so @pypi refs work (#3946) Currently, the unified pypi hub is bzlmod only. While this is WAI, it makes using the references in tests that would be good to run under workspace unable to run. To fix, have our workspace config create a unified hub repo with the underlying pypi hubs that it needs to map to. This is kept as an internal-only helper because it doesn't generalize well to arbitrary projects. --- WORKSPACE | 24 +++++++++++++--- python/private/pypi/BUILD.bazel | 5 +++- python/private/pypi/unified_hub_repo.bzl | 30 ++++++++++++++++++++ tests/support/pytest_test/BUILD.bazel | 5 ++-- tests/tools/private/release/BUILD.bazel | 36 ++++++++++++------------ 5 files changed, 74 insertions(+), 26 deletions(-) diff --git a/WORKSPACE b/WORKSPACE index 5a31274b21..59b8489760 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -138,7 +138,7 @@ pip_parse( requirements_lock = "//examples/wheel:requirements_server.txt", ) -load("@pypiserver//:requirements.bzl", install_pypiserver = "install_deps") +load("@pypiserver//:requirements.bzl", install_pypiserver = "install_deps", pypiserver_requirements = "all_requirements") install_pypiserver() @@ -151,7 +151,7 @@ pip_parse( requirements_lock = "//docs:requirements.txt", ) -load("@dev_pip//:requirements.bzl", docs_install_deps = "install_deps") +load("@dev_pip//:requirements.bzl", dev_pip_requirements = "all_requirements", docs_install_deps = "install_deps") docs_install_deps() @@ -164,7 +164,7 @@ pip_parse( requirements_lock = "//tests/multi_pypi/alpha:requirements.txt", ) -load("@pypi_alpha//:requirements.bzl", pypi_alpha_install_deps = "install_deps") +load("@pypi_alpha//:requirements.bzl", pypi_alpha_install_deps = "install_deps", pypi_alpha_requirements = "all_requirements") pypi_alpha_install_deps() @@ -174,6 +174,22 @@ pip_parse( requirements_lock = "//tests/multi_pypi/beta:requirements.txt", ) -load("@pypi_beta//:requirements.bzl", pypi_beta_install_deps = "install_deps") +load("@pypi_beta//:requirements.bzl", pypi_beta_install_deps = "install_deps", pypi_beta_requirements = "all_requirements") pypi_beta_install_deps() + +load( + "//python/private/pypi:unified_hub_repo.bzl", + "unified_workspace_hub_repo", +) # buildifier: disable=bzl-visibility + +unified_workspace_hub_repo( + name = "pypi", + default_hub = "dev_pip", + hubs = { + "dev_pip": dev_pip_requirements, + "pypi_alpha": pypi_alpha_requirements, + "pypi_beta": pypi_beta_requirements, + "pypiserver": pypiserver_requirements, + }, +) diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 07d2300ce7..09ba5ef135 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -402,7 +402,10 @@ bzl_library( bzl_library( name = "unified_hub_repo", srcs = ["unified_hub_repo.bzl"], - deps = ["//python/private:text_util"], + deps = [ + "//python/private:normalize_name", + "//python/private:text_util", + ], ) bzl_library( diff --git a/python/private/pypi/unified_hub_repo.bzl b/python/private/pypi/unified_hub_repo.bzl index cbe150575a..f88db75f19 100644 --- a/python/private/pypi/unified_hub_repo.bzl +++ b/python/private/pypi/unified_hub_repo.bzl @@ -1,5 +1,6 @@ """Repository rule for creating the Unified PyPI Hub.""" +load("//python/private:normalize_name.bzl", "normalize_name") load("//python/private:text_util.bzl", "render") _ROOT_BUILD_TMPL = """\ @@ -79,3 +80,32 @@ unified_hub_repo = repository_rule( }, doc = "Private repository rule creating the automatic Unified PyPI Hub.", ) + +def unified_workspace_hub_repo(name, hubs, default_hub = None, extra_aliases = {}): + """Creates a Unified PyPI Hub repository for WORKSPACE mode by loading requirements from hubs. + + Args: + name: Name of the repository rule (e.g. "pypi"). + hubs: Dict mapping hub name to its `all_requirements` list or dict. + e.g. {"dev_pip": dev_pip_requirements, "pypi_alpha": pypi_alpha_requirements} + default_hub: Optional default hub name. + extra_aliases: Dictionary mapping 'package:alias' to a list of hubs that support it. + """ + packages = {} + for hub_name, req_map in hubs.items(): + req_list = req_map.keys() if type(req_map) == type({}) else req_map + for req in req_list: + pkg_name = req.split("//")[-1].split(":")[0] + norm_pkg = normalize_name(pkg_name) + if norm_pkg not in packages: + packages[norm_pkg] = [] + if hub_name not in packages[norm_pkg]: + packages[norm_pkg].append(hub_name) + + unified_hub_repo( + name = name, + default_hub = default_hub, + extra_aliases = extra_aliases, + hubs = sorted(hubs.keys()), + packages = packages, + ) diff --git a/tests/support/pytest_test/BUILD.bazel b/tests/support/pytest_test/BUILD.bazel index 50ad59bf0e..4e6f6dd168 100644 --- a/tests/support/pytest_test/BUILD.bazel +++ b/tests/support/pytest_test/BUILD.bazel @@ -1,5 +1,4 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") -load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility package(default_visibility = ["//:__subpackages__"]) @@ -20,11 +19,11 @@ bzl_library( # These aliases are used to avoid duplicate targets in the deps list alias( name = "default_pytest", - actual = "@pypi//pytest" if BZLMOD_ENABLED else "//python/private:empty", + actual = "@pypi//pytest", ) # These aliases are used to avoid duplicate targets in the deps list alias( name = "default_pytest_bazel", - actual = "@pypi//pytest_bazel" if BZLMOD_ENABLED else "//python/private:empty", + actual = "@pypi//pytest_bazel", ) diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 317a4626c9..6a4b2f8710 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -1,5 +1,5 @@ load("//python:py_library.bzl", "py_library") -load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") +load("//tests/support:support.bzl", "NOT_WINDOWS") load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") py_library( @@ -8,7 +8,7 @@ py_library( "conftest.py", "release_test_helper.py", ], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ "//tools/private/release:mock_gh", "//tools/private/release:release_lib", @@ -19,7 +19,7 @@ py_library( pytest_test( name = "add_backports_test", srcs = ["add_backports_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -29,7 +29,7 @@ pytest_test( pytest_test( name = "changelog_news_test", srcs = ["changelog_news_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -39,7 +39,7 @@ pytest_test( pytest_test( name = "complete_sync_changelog_test", srcs = ["complete_sync_changelog_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -49,7 +49,7 @@ pytest_test( pytest_test( name = "create_release_branch_test", srcs = ["create_release_branch_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -59,7 +59,7 @@ pytest_test( pytest_test( name = "git_test", srcs = ["git_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -69,7 +69,7 @@ pytest_test( pytest_test( name = "on_pr_merged_test", srcs = ["on_pr_merged_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -79,7 +79,7 @@ pytest_test( pytest_test( name = "promote_test", srcs = ["promote_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -89,7 +89,7 @@ pytest_test( pytest_test( name = "release_issue_test", srcs = ["release_issue_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -99,7 +99,7 @@ pytest_test( pytest_test( name = "release_test", srcs = ["release_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -109,7 +109,7 @@ pytest_test( pytest_test( name = "backport_create_releases_test", srcs = ["backport_create_releases_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -119,7 +119,7 @@ pytest_test( pytest_test( name = "prepare_test", srcs = ["prepare_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -129,7 +129,7 @@ pytest_test( pytest_test( name = "gh_test", srcs = ["gh_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -139,7 +139,7 @@ pytest_test( pytest_test( name = "backport_prepare_test", srcs = ["backport_prepare_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -149,7 +149,7 @@ pytest_test( pytest_test( name = "process_backports_test", srcs = ["process_backports_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -159,7 +159,7 @@ pytest_test( pytest_test( name = "create_rc_test", srcs = ["create_rc_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -169,7 +169,7 @@ pytest_test( pytest_test( name = "utils_test", srcs = ["utils_test.py"], - target_compatible_with = SUPPORTS_BZLMOD, + target_compatible_with = NOT_WINDOWS, deps = [ ":release_test_helper", "//tools/private/release:release_lib", From cdb717fbf63e8133a8eeac596d4f08e86de2f375 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 22 Jul 2026 19:11:06 -0700 Subject: [PATCH 098/179] tests: convert private tool tests to pytest_test and pytest style (#3945) Convert private tool tests (`tests/tools/zipapp/` and `tools/private/update_deps/`) to `pytest_test` and pytest style to be more idiomatic Python tests. Also converts to use pytest fixtures. --- tests/tools/zipapp/BUILD.bazel | 12 +- tests/tools/zipapp/exe_zip_maker_test.py | 87 +-- tests/tools/zipapp/zip_main_maker_test.py | 169 +++-- tests/tools/zipapp/zipper_test.py | 588 ++++++++---------- tools/private/update_deps/BUILD.bazel | 6 +- tools/private/update_deps/update_file_test.py | 112 ++-- 6 files changed, 447 insertions(+), 527 deletions(-) diff --git a/tests/tools/zipapp/BUILD.bazel b/tests/tools/zipapp/BUILD.bazel index b71e9b2589..97c8096ce9 100644 --- a/tests/tools/zipapp/BUILD.bazel +++ b/tests/tools/zipapp/BUILD.bazel @@ -1,19 +1,23 @@ -load("//python:py_test.bzl", "py_test") +load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") +load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") -py_test( +pytest_test( name = "zipper_test", srcs = ["zipper_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = ["//tools/private/zipapp:zipper_lib"], ) -py_test( +pytest_test( name = "exe_zip_maker_test", srcs = ["exe_zip_maker_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = ["//tools/private/zipapp:exe_zip_maker_lib"], ) -py_test( +pytest_test( name = "zip_main_maker_test", srcs = ["zip_main_maker_test.py"], + target_compatible_with = SUPPORTS_BZLMOD, deps = ["//tools/private/zipapp:zip_main_maker_lib"], ) diff --git a/tests/tools/zipapp/exe_zip_maker_test.py b/tests/tools/zipapp/exe_zip_maker_test.py index 73c509bdbe..97df258e92 100644 --- a/tests/tools/zipapp/exe_zip_maker_test.py +++ b/tests/tools/zipapp/exe_zip_maker_test.py @@ -1,71 +1,46 @@ import hashlib -import pathlib -import shutil import stat -import tempfile -import unittest from tools.private.zipapp import exe_zip_maker -class ExeZipMakerTest(unittest.TestCase): - def setUp(self): - self.test_dir = pathlib.Path(tempfile.mkdtemp()) - self.preamble_path = self.test_dir / "preamble.txt" - self.zip_path = self.test_dir / "data.zip" - self.output_path = self.test_dir / "output.exe" +def test_create_exe_zip(tmp_path): + preamble_path = tmp_path / "preamble.txt" + zip_path = tmp_path / "data.zip" + output_path = tmp_path / "output.exe" - def tearDown(self): - shutil.rmtree(self.test_dir) + # Create dummy zip file + zip_content = b"PK\x03\x04dummyzipcontent" + zip_path.write_bytes(zip_content) - def assertStartsWith(self, actual, expected): - if not actual.startswith(expected): - self.fail(f"{actual!r} does not start with {expected!r}") + # Calculate expected hash + expected_hash = hashlib.sha256(zip_content).hexdigest().encode("utf-8") - def test_create_exe_zip(self): - # Create dummy zip file - zip_content = b"PK\x03\x04dummyzipcontent" - self.zip_path.write_bytes(zip_content) + # Create preamble with placeholder + preamble_text = b"#!/bin/bash\nEXPECTED_HASH='%ZIP_HASH%'\n# ... logic ...\n" + preamble_path.write_bytes(preamble_text) - # Calculate expected hash - expected_hash = hashlib.sha256(zip_content).hexdigest().encode("utf-8") + # Call create_exe_zip directly + exe_zip_maker.create_exe_zip(str(preamble_path), str(zip_path), str(output_path)) - # Create preamble with placeholder - preamble_text = b"#!/bin/bash\nEXPECTED_HASH='%ZIP_HASH%'\n# ... logic ...\n" - self.preamble_path.write_bytes(preamble_text) + # Verify output exists + assert output_path.exists(), f"Output path '{output_path}' should exist" - # Call create_exe_zip directly - exe_zip_maker.create_exe_zip( - str(self.preamble_path), str(self.zip_path), str(self.output_path) - ) + # Verify executable bit + st = output_path.stat() + assert st.st_mode & stat.S_IEXEC, ( + f"Output path '{output_path}' should be executable" + ) - # Verify output exists - self.assertTrue( - self.output_path.exists(), - msg=f"Output path '{self.output_path}' should exist", - ) + # Verify content + content = output_path.read_bytes() - # Verify executable bit - st = self.output_path.stat() - self.assertTrue( - st.st_mode & stat.S_IEXEC, - msg=f"Output path '{self.output_path}' should be executable", - ) + # Split content back into preamble and zip + # We know the preamble text length after substitution. + expected_preamble = preamble_text.replace(b"%ZIP_HASH%", expected_hash) - # Verify content - content = self.output_path.read_bytes() - - # Split content back into preamble and zip - # We know the preamble text length after substitution. - expected_preamble = preamble_text.replace(b"%ZIP_HASH%", expected_hash) - - self.assertStartsWith(content, expected_preamble) - self.assertTrue( - content.endswith(zip_content), - msg="Output content should end with the zip content", - ) - self.assertEqual(len(content), len(expected_preamble) + len(zip_content)) - - -if __name__ == "__main__": - unittest.main() + assert content.startswith(expected_preamble) + assert content.endswith(zip_content), ( + "Output content should end with the zip content" + ) + assert len(content) == len(expected_preamble) + len(zip_content) diff --git a/tests/tools/zipapp/zip_main_maker_test.py b/tests/tools/zipapp/zip_main_maker_test.py index dd8e8e8029..5c7f57c590 100644 --- a/tests/tools/zipapp/zip_main_maker_test.py +++ b/tests/tools/zipapp/zip_main_maker_test.py @@ -1,101 +1,90 @@ import hashlib import os -import tempfile -import unittest -from unittest import mock from tools.private.zipapp import zip_main_maker -class ZipMainMakerTest(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.temp_dir.cleanup) - - def test_creates_zip_main(self): - template_path = os.path.join(self.temp_dir.name, "template.py") - with open(template_path, "w", encoding="utf-8") as f: - f.write("hash=%APP_HASH%\nfoo=%FOO%\n") - - output_path = os.path.join(self.temp_dir.name, "output.py") - - file1_path = os.path.join(self.temp_dir.name, "file1.txt") - with open(file1_path, "wb") as f: - f.write(b"content1") - - file2_path = os.path.join(self.temp_dir.name, "file2.txt") - with open(file2_path, "wb") as f: - f.write(b"content2") - - # Add a symlink to test symlink hashing - symlink_path = os.path.join(self.temp_dir.name, "symlink.txt") - os.symlink(file1_path, symlink_path) - - manifest_path = os.path.join(self.temp_dir.name, "manifest.txt") - with open(manifest_path, "w", encoding="utf-8") as f: - f.write(f"rf-file|0|file1.txt|{file1_path}\n") - f.write(f"rf-file|0|file2.txt|{file2_path}\n") - f.write(f"rf-symlink|1|symlink.txt|{symlink_path}\n") - f.write("rf-empty|empty_file.txt\n") - - argv = [ - "zip_main_maker.py", - "--template", - template_path, - "--output", - output_path, - "--substitution", - "%FOO%=bar", - "--hash_files_manifest", - manifest_path, - ] - - with mock.patch("sys.argv", argv): - zip_main_maker.main() - - # Calculate expected hash - h = hashlib.sha256() - line1 = f"rf-file|0|file1.txt|{file1_path}" - line2 = f"rf-file|0|file2.txt|{file2_path}" - line3 = f"rf-symlink|1|symlink.txt|{symlink_path}" - line4 = "rf-empty|empty_file.txt" - - # Sort lines like the program does - lines = sorted([line1, line2, line3, line4]) - for line in lines: - parts = line.split("|") - if len(parts) > 1: - _, rest = line.split("|", 1) - h.update(rest.encode("utf-8")) - else: - h.update(line.encode("utf-8")) - - type_ = parts[0] - if type_ == "rf-empty": +def test_creates_zip_main(tmp_path, monkeypatch): + temp_dir = str(tmp_path) + template_path = os.path.join(temp_dir, "template.py") + with open(template_path, "w", encoding="utf-8") as f: + f.write("hash=%APP_HASH%\nfoo=%FOO%\n") + + output_path = os.path.join(temp_dir, "output.py") + + file1_path = os.path.join(temp_dir, "file1.txt") + with open(file1_path, "wb") as f: + f.write(b"content1") + + file2_path = os.path.join(temp_dir, "file2.txt") + with open(file2_path, "wb") as f: + f.write(b"content2") + + # Add a symlink to test symlink hashing + symlink_path = os.path.join(temp_dir, "symlink.txt") + os.symlink(file1_path, symlink_path) + + manifest_path = os.path.join(temp_dir, "manifest.txt") + with open(manifest_path, "w", encoding="utf-8") as f: + f.write(f"rf-file|0|file1.txt|{file1_path}\n") + f.write(f"rf-file|0|file2.txt|{file2_path}\n") + f.write(f"rf-symlink|1|symlink.txt|{symlink_path}\n") + f.write("rf-empty|empty_file.txt\n") + + argv = [ + "zip_main_maker.py", + "--template", + template_path, + "--output", + output_path, + "--substitution", + "%FOO%=bar", + "--hash_files_manifest", + manifest_path, + ] + + monkeypatch.setattr("sys.argv", argv) + zip_main_maker.main() + + # Calculate expected hash + h = hashlib.sha256() + line1 = f"rf-file|0|file1.txt|{file1_path}" + line2 = f"rf-file|0|file2.txt|{file2_path}" + line3 = f"rf-symlink|1|symlink.txt|{symlink_path}" + line4 = "rf-empty|empty_file.txt" + + # Sort lines like the program does + lines = sorted([line1, line2, line3, line4]) + for line in lines: + parts = line.split("|") + if len(parts) > 1: + _, rest = line.split("|", 1) + h.update(rest.encode("utf-8")) + else: + h.update(line.encode("utf-8")) + + type_ = parts[0] + if type_ == "rf-empty": + continue + if len(parts) >= 4: + is_symlink_str = parts[1] + path = parts[-1] + if not path: continue - if len(parts) >= 4: - is_symlink_str = parts[1] - path = parts[-1] - if not path: - continue - if is_symlink_str == "-1": - is_symlink = not os.path.exists(path) - else: - is_symlink = is_symlink_str == "1" - - if is_symlink: - h.update(os.readlink(path).encode("utf-8")) - else: - with open(path, "rb") as f: - h.update(f.read()) - - expected_hash = h.hexdigest() + if is_symlink_str == "-1": + is_symlink = not os.path.exists(path) + else: + is_symlink = is_symlink_str == "1" - with open(output_path, "r", encoding="utf-8") as f: - content = f.read() + if is_symlink: + h.update(os.readlink(path).encode("utf-8")) + else: + with open(path, "rb") as f: + h.update(f.read()) - self.assertEqual(content, f"hash={expected_hash}\nfoo=bar\n") + expected_hash = h.hexdigest() + with open(output_path, "r", encoding="utf-8") as f: + content = f.read() -if __name__ == "__main__": - unittest.main() + assert content == f"hash={expected_hash}\nfoo=bar\n" diff --git a/tests/tools/zipapp/zipper_test.py b/tests/tools/zipapp/zipper_test.py index ac70917c30..bbd85b6536 100644 --- a/tests/tools/zipapp/zipper_test.py +++ b/tests/tools/zipapp/zipper_test.py @@ -1,8 +1,5 @@ import os -import pathlib import shutil -import tempfile -import unittest import zipfile from tools.private.zipapp import zipper @@ -12,333 +9,294 @@ def symlink_target_path(p): return p.replace("/", os.sep) -class ZipperTest(unittest.TestCase): - def setUp(self): - self.test_dir = pathlib.Path(tempfile.mkdtemp()) - self.manifest_path = self.test_dir / "manifest.txt" - self.output_zip = self.test_dir / "output.zip" - - def tearDown(self): - shutil.rmtree(self.test_dir) - - def _create_zip(self, **kwargs): - defaults = { - "manifest_path": self.manifest_path, - "output_zip": self.output_zip, - "compress_level": 0, - "workspace_name": "my_ws", - "legacy_external_runfiles": False, - "runfiles_dir": "runfiles", - # We need to generate paths for the platform we're running on. - "platform_pathsep": os.sep, +def is_symlink(zip_info): + # Check upper 4 bits of external_attr for S_IFLNK + # S_IFLNK is 0o120000 = 0xA000 + attr = zip_info.external_attr >> 16 + return (attr & 0xF000) == 0xA000 + + +def assert_zip_file_content(zf, path, content=None, is_symlink_file=False, target=None): + info = zf.getinfo(path) + if is_symlink_file: + assert is_symlink(info), f"{path} should be a symlink but is not" + assert zf.read(path).decode() == target + else: + assert not is_symlink(info), f"{path} should NOT be a symlink but is" + assert zf.read(path).decode() == content + + +def create_zip(manifest_path, output_zip, **kwargs): + defaults = { + "manifest_path": manifest_path, + "output_zip": output_zip, + "compress_level": 0, + "workspace_name": "my_ws", + "legacy_external_runfiles": False, + "runfiles_dir": "runfiles", + "platform_pathsep": os.sep, + } + defaults.update(kwargs) + zipper.create_zip(**defaults) + + +def extract_zip(zip_path, extract_dir): + # Manually extract to preserve symlinks + with zipfile.ZipFile(zip_path, "r") as zf: + for info in zf.infolist(): + extract_path = extract_dir / info.filename + extract_path.parent.mkdir(parents=True, exist_ok=True) + if is_symlink(info): + target = zf.read(info).decode() + os.symlink(target, extract_path) + else: + with zf.open(info) as src, open(extract_path, "wb") as dst: + shutil.copyfileobj(src, dst) + + +def test_create_zip_with_files_and_symlinks(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" + + file1_path = tmp_path / "file1.txt" + file1_path.write_text("content1") + + link_target_path = "target.txt" + symlink_path = tmp_path / "symlink_source" + symlink_path.symlink_to(link_target_path) + + manifest_content = [ + f"regular|0|file1.txt|{file1_path}", + f"rf-file|0|foo/bar.txt|{file1_path}", + f"rf-symlink|1|link1|{symlink_path}", + f"rf-root-symlink|0|root_file|{file1_path}", + "rf-empty|empty_file", + ] + manifest_path.write_text("\n".join(manifest_content)) + + create_zip(manifest_path, output_zip) + + assert output_zip.exists() + + with zipfile.ZipFile(output_zip, "r") as zf: + assert set(zf.namelist()) == { + "file1.txt", + "runfiles/my_ws/foo/bar.txt", + "runfiles/my_ws/link1", + "runfiles/root_file", + "runfiles/my_ws/empty_file", } - defaults.update(kwargs) - zipper.create_zip(**defaults) - - def assertZipFileContent( - self, zf, path, content=None, is_symlink=False, target=None - ): - info = zf.getinfo(path) - if is_symlink: - self.assertTrue( - self.is_symlink(info), - f"{path} should be a symlink but is not", - ) - self.assertEqual(zf.read(path).decode(), target) - else: - self.assertFalse( - self.is_symlink(info), - f"{path} should NOT be a symlink but is", - ) - self.assertEqual(zf.read(path).decode(), content) - - def test_create_zip_with_files_and_symlinks(self): - file1_path = self.test_dir / "file1.txt" - file1_path.write_text("content1") - - link_target_path = "target.txt" # Relative target - symlink_path = self.test_dir / "symlink_source" - symlink_path.symlink_to(link_target_path) - - manifest_content = [ - f"regular|0|file1.txt|{file1_path}", - f"rf-file|0|foo/bar.txt|{file1_path}", - f"rf-symlink|1|link1|{symlink_path}", # Should read target 'target.txt' - f"rf-root-symlink|0|root_file|{file1_path}", - "rf-empty|empty_file", - ] - self.manifest_path.write_text("\n".join(manifest_content)) - - self._create_zip() - - self.assertTrue(self.output_zip.exists()) - - with zipfile.ZipFile(self.output_zip, "r") as zf: - self.assertEqual( - set(zf.namelist()), - { - "file1.txt", - "runfiles/my_ws/foo/bar.txt", - "runfiles/my_ws/link1", - "runfiles/root_file", - "runfiles/my_ws/empty_file", - }, - ) - - self.assertZipFileContent(zf, "file1.txt", content="content1") - self.assertZipFileContent( - zf, "runfiles/my_ws/foo/bar.txt", content="content1" - ) - self.assertZipFileContent( - zf, "runfiles/my_ws/link1", is_symlink=True, target="target.txt" - ) - self.assertZipFileContent(zf, "runfiles/root_file", content="content1") - self.assertZipFileContent(zf, "runfiles/my_ws/empty_file", content="") - - def test_create_zip_with_direct_symlink(self): - # Test the 'symlink' manifest entry type - manifest_content = [ - "symlink|path/to/link|target/path", - ] - self.manifest_path.write_text("\n".join(manifest_content)) - - self._create_zip() - - with zipfile.ZipFile(self.output_zip, "r") as zf: - self.assertEqual(zf.namelist(), ["runfiles/path/to/link"]) - self.assertZipFileContent( - zf, - "runfiles/path/to/link", - is_symlink=True, - target=symlink_target_path("../../target/path"), - ) - - def test_pathsep_normalization(self): - # Test that pathsep="\\" normalizes paths - file1_path = self.test_dir / "file1.txt" - file1_path.write_text("content1") - - manifest_content = [ - f"regular|0|dir/file.txt|{file1_path}", - "symlink|link/path|target/path", - ] - self.manifest_path.write_text("\n".join(manifest_content)) - - # Use backslash as platform_pathsep - self._create_zip(platform_pathsep="\\") - - with zipfile.ZipFile(self.output_zip, "r") as zf: - # zipfile.namelist() always returns with forward slashes - # But the content of the symlink should be normalized if it was passed through path_norm - self.assertEqual( - set(zf.namelist()), - {"dir/file.txt", "runfiles/link/path"}, - ) - # The target of the symlink should have backslashes - self.assertZipFileContent( - zf, - "runfiles/link/path", - is_symlink=True, - target="..\\target\\path", - ) - - def test_symlink_precedence(self): - # Test that 'symlink' entries take precedence over others for the same path - file1_path = self.test_dir / "file1.txt" - file1_path.write_text("content1") - - manifest_content = [ - # Same zip path: runfiles/my_ws/path/to/file - f"rf-file|0|path/to/file|{file1_path}", - "symlink|my_ws/path/to/file|symlink/target", - ] - self.manifest_path.write_text("\n".join(manifest_content)) - - self._create_zip() - - with zipfile.ZipFile(self.output_zip, "r") as zf: - self.assertEqual(zf.namelist(), ["runfiles/my_ws/path/to/file"]) - # It should be the symlink, not the file - self.assertZipFileContent( - zf, - "runfiles/my_ws/path/to/file", - is_symlink=True, - target=symlink_target_path("../../../symlink/target"), - ) - - def test_timestamps_are_deterministic(self): - # Create a content file with a specific recent timestamp - file1_path = self.test_dir / "file1.txt" - file1_path.write_text("content1") - - # Set mtime to something recent (e.g. now) - os.utime(file1_path, None) - - manifest_content = [ - f"regular|0|file1.txt|{file1_path}", - ] - self.manifest_path.write_text("\n".join(manifest_content)) + assert_zip_file_content(zf, "file1.txt", content="content1") + assert_zip_file_content(zf, "runfiles/my_ws/foo/bar.txt", content="content1") + assert_zip_file_content( + zf, "runfiles/my_ws/link1", is_symlink_file=True, target="target.txt" + ) + assert_zip_file_content(zf, "runfiles/root_file", content="content1") + assert_zip_file_content(zf, "runfiles/my_ws/empty_file", content="") - self._create_zip() - with zipfile.ZipFile(self.output_zip, "r") as zf: - info = zf.getinfo("file1.txt") - # DOS epoch is 1980-01-01 00:00:00 - expected_date_time = (1980, 1, 1, 0, 0, 0) - self.assertEqual(info.date_time, expected_date_time) +def test_create_zip_with_direct_symlink(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" - def test_runfiles_mapping_with_cross_repo_paths(self): - # Create content file - file1_path = self.test_dir / "file1.txt" - file1_path.write_text("content1") + manifest_content = ["symlink|path/to/link|target/path"] + manifest_path.write_text("\n".join(manifest_content)) - manifest_content = [ - f"rf-file|0|../other_repo/foo.txt|{file1_path}", - "rf-empty|../other_repo/empty_file", - ] + create_zip(manifest_path, output_zip) - self.manifest_path.write_text("\n".join(manifest_content)) - - self._create_zip(workspace_name="my_ws") - - with zipfile.ZipFile(self.output_zip, "r") as zf: - self.assertEqual( - set(zf.namelist()), - { - "runfiles/other_repo/foo.txt", - "runfiles/other_repo/empty_file", - }, - ) - self.assertZipFileContent( - zf, "runfiles/other_repo/foo.txt", content="content1" - ) - self.assertZipFileContent(zf, "runfiles/other_repo/empty_file", content="") - - def test_runfiles_mapping_with_legacy_external_paths(self): - file1_path = self.test_dir / "file1.txt" - file1_path.write_text("content1") - - manifest_content = [ - f"rf-file|0|external/other_repo/foo.txt|{file1_path}", - "rf-empty|external/other_repo/empty_file", - ] + with zipfile.ZipFile(output_zip, "r") as zf: + assert zf.namelist() == ["runfiles/path/to/link"] + assert_zip_file_content( + zf, + "runfiles/path/to/link", + is_symlink_file=True, + target=symlink_target_path("../../target/path"), + ) - self.manifest_path.write_text("\n".join(manifest_content)) - - self._create_zip(workspace_name="my_ws", legacy_external_runfiles=True) - - with zipfile.ZipFile(self.output_zip, "r") as zf: - self.assertEqual( - set(zf.namelist()), - { - "runfiles/other_repo/foo.txt", - "runfiles/other_repo/empty_file", - }, - ) - self.assertZipFileContent( - zf, "runfiles/other_repo/foo.txt", content="content1" - ) - self.assertZipFileContent(zf, "runfiles/other_repo/empty_file", content="") - - def test_output_deterministic(self): - # Create files - file1 = self.test_dir / "file1" - file1.write_text("1") - file2 = self.test_dir / "file2" - file2.write_text("2") - file3 = self.test_dir / "file3" - file3.write_text("3") - - # Manifest entries mixed up - # We want the final order to be: - # 1. a/regular (regular) - # 2. runfiles/a_root_link (rf-root-symlink) - # 3. runfiles/my_ws/b_rf_file (rf-file) - # 4. runfiles/my_ws/c_rf_link (rf-symlink) - # 5. runfiles/my_ws/d_rf_empty (rf-empty) - # 6. z/regular (regular) - - manifest_content = [ - f"regular|0|z/regular|{file1}", - f"rf-file|0|b_rf_file|{file2}", # -> runfiles/my_ws/b_rf_file - f"rf-root-symlink|0|a_root_link|{file3}", # -> runfiles/a_root_link - f"regular|0|a/regular|{file3}", - "rf-empty|d_rf_empty", # -> runfiles/my_ws/d_rf_empty - f"rf-symlink|0|c_rf_link|{file3}", # -> runfiles/my_ws/c_rf_link - ] - self.manifest_path.write_text("\n".join(manifest_content)) - - self._create_zip(workspace_name="my_ws") - - with zipfile.ZipFile(self.output_zip, "r") as zf: - self.assertEqual( - zf.namelist(), - [ - "a/regular", - "runfiles/a_root_link", - "runfiles/my_ws/b_rf_file", - "runfiles/my_ws/c_rf_link", - "runfiles/my_ws/d_rf_empty", - "z/regular", - ], - ) - - def _extract_zip(self, zip_path, extract_dir): - # Manually extract to preserve symlinks - with zipfile.ZipFile(zip_path, "r") as zf: - for info in zf.infolist(): - extract_path = extract_dir / info.filename - extract_path.parent.mkdir(parents=True, exist_ok=True) - if self.is_symlink(info): - target = zf.read(info).decode() - # On Windows, relative symlinks must use backslashes to be readable - os.symlink(target, extract_path) - else: - with zf.open(info) as src, open(extract_path, "wb") as dst: - shutil.copyfileobj(src, dst) - - def test_symlink_extraction(self): - # Test that 'symlink' entries extract correctly as relative symlinks - # Create a file that the symlink will point to - target_file = self.test_dir / "target_file.txt" - target_file.write_text("target content") - - manifest_content = [ - f"rf-file|0|target/path|{target_file}", - "symlink|my_ws/path/to/link|my_ws/target/path", - f"rf-file|0|same_dir_target|{target_file}", - "symlink|my_ws/same_dir_link|my_ws/same_dir_target", - ] - self.manifest_path.write_text("\n".join(manifest_content)) +def test_pathsep_normalization(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" - self._create_zip(workspace_name="my_ws") + file1_path = tmp_path / "file1.txt" + file1_path.write_text("content1") - extract_dir = self.test_dir / "extract" - extract_dir.mkdir() + manifest_content = [ + f"regular|0|dir/file.txt|{file1_path}", + "symlink|link/path|target/path", + ] + manifest_path.write_text("\n".join(manifest_content)) - self._extract_zip(self.output_zip, extract_dir) + create_zip(manifest_path, output_zip, platform_pathsep="\\") - link_path = extract_dir / "runfiles/my_ws/path/to/link" - self.assertTrue(link_path.is_symlink(), f"{link_path} should be a symlink") - self.assertEqual( - os.readlink(link_path), "../../target/path".replace("/", os.path.sep) + with zipfile.ZipFile(output_zip, "r") as zf: + assert set(zf.namelist()) == {"dir/file.txt", "runfiles/link/path"} + assert_zip_file_content( + zf, + "runfiles/link/path", + is_symlink_file=True, + target="..\\target\\path", ) - self.assertEqual(link_path.read_text(), "target content") - link2_path = extract_dir / "runfiles/my_ws/same_dir_link" - self.assertTrue(link2_path.is_symlink(), f"{link2_path} should be a symlink") - # Relative path from runfiles/my_ws/ to runfiles/my_ws/same_dir_target is just same_dir_target - self.assertEqual(os.readlink(link2_path), "same_dir_target") - self.assertEqual(link2_path.read_text(), "target content") - def is_symlink(self, zip_info): - # Check upper 4 bits of external_attr for S_IFLNK - # S_IFLNK is 0o120000 = 0xA000 - attr = zip_info.external_attr >> 16 - return (attr & 0xF000) == 0xA000 +def test_symlink_precedence(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" + + file1_path = tmp_path / "file1.txt" + file1_path.write_text("content1") + + manifest_content = [ + f"rf-file|0|path/to/file|{file1_path}", + "symlink|my_ws/path/to/file|symlink/target", + ] + manifest_path.write_text("\n".join(manifest_content)) + + create_zip(manifest_path, output_zip) + + with zipfile.ZipFile(output_zip, "r") as zf: + assert zf.namelist() == ["runfiles/my_ws/path/to/file"] + assert_zip_file_content( + zf, + "runfiles/my_ws/path/to/file", + is_symlink_file=True, + target=symlink_target_path("../../../symlink/target"), + ) + + +def test_timestamps_are_deterministic(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" + + file1_path = tmp_path / "file1.txt" + file1_path.write_text("content1") + os.utime(file1_path, None) + + manifest_content = [f"regular|0|file1.txt|{file1_path}"] + manifest_path.write_text("\n".join(manifest_content)) + + create_zip(manifest_path, output_zip) + + with zipfile.ZipFile(output_zip, "r") as zf: + info = zf.getinfo("file1.txt") + expected_date_time = (1980, 1, 1, 0, 0, 0) + assert info.date_time == expected_date_time + + +def test_runfiles_mapping_with_cross_repo_paths(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" + + file1_path = tmp_path / "file1.txt" + file1_path.write_text("content1") + + manifest_content = [ + f"rf-file|0|../other_repo/foo.txt|{file1_path}", + "rf-empty|../other_repo/empty_file", + ] + manifest_path.write_text("\n".join(manifest_content)) + + create_zip(manifest_path, output_zip, workspace_name="my_ws") + + with zipfile.ZipFile(output_zip, "r") as zf: + assert set(zf.namelist()) == { + "runfiles/other_repo/foo.txt", + "runfiles/other_repo/empty_file", + } + assert_zip_file_content(zf, "runfiles/other_repo/foo.txt", content="content1") + assert_zip_file_content(zf, "runfiles/other_repo/empty_file", content="") + + +def test_runfiles_mapping_with_legacy_external_paths(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" + + file1_path = tmp_path / "file1.txt" + file1_path.write_text("content1") + + manifest_content = [ + f"rf-file|0|external/other_repo/foo.txt|{file1_path}", + "rf-empty|external/other_repo/empty_file", + ] + manifest_path.write_text("\n".join(manifest_content)) + + create_zip( + manifest_path, output_zip, workspace_name="my_ws", legacy_external_runfiles=True + ) + + with zipfile.ZipFile(output_zip, "r") as zf: + assert set(zf.namelist()) == { + "runfiles/other_repo/foo.txt", + "runfiles/other_repo/empty_file", + } + assert_zip_file_content(zf, "runfiles/other_repo/foo.txt", content="content1") + assert_zip_file_content(zf, "runfiles/other_repo/empty_file", content="") + + +def test_output_deterministic(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" + + file1 = tmp_path / "file1" + file1.write_text("1") + file2 = tmp_path / "file2" + file2.write_text("2") + file3 = tmp_path / "file3" + file3.write_text("3") + + manifest_content = [ + f"regular|0|z/regular|{file1}", + f"rf-file|0|b_rf_file|{file2}", + f"rf-root-symlink|0|a_root_link|{file3}", + f"regular|0|a/regular|{file3}", + "rf-empty|d_rf_empty", + f"rf-symlink|0|c_rf_link|{file3}", + ] + + manifest_path.write_text("\n".join(manifest_content)) + + create_zip(manifest_path, output_zip, workspace_name="my_ws") + + with zipfile.ZipFile(output_zip, "r") as zf: + assert zf.namelist() == [ + "a/regular", + "runfiles/a_root_link", + "runfiles/my_ws/b_rf_file", + "runfiles/my_ws/c_rf_link", + "runfiles/my_ws/d_rf_empty", + "z/regular", + ] + + +def test_symlink_extraction(tmp_path): + manifest_path = tmp_path / "manifest.txt" + output_zip = tmp_path / "output.zip" + + target_file = tmp_path / "target_file.txt" + target_file.write_text("target content") + + manifest_content = [ + f"rf-file|0|target/path|{target_file}", + "symlink|my_ws/path/to/link|my_ws/target/path", + f"rf-file|0|same_dir_target|{target_file}", + "symlink|my_ws/same_dir_link|my_ws/same_dir_target", + ] + manifest_path.write_text("\n".join(manifest_content)) + + create_zip(manifest_path, output_zip, workspace_name="my_ws") + + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + + extract_zip(output_zip, extract_dir) + link_path = extract_dir / "runfiles/my_ws/path/to/link" + assert link_path.is_symlink(), f"{link_path} should be a symlink" + assert os.readlink(link_path) == "../../target/path".replace("/", os.path.sep) + assert link_path.read_text() == "target content" -if __name__ == "__main__": - unittest.main() + link2_path = extract_dir / "runfiles/my_ws/same_dir_link" + assert link2_path.is_symlink(), f"{link2_path} should be a symlink" + assert os.readlink(link2_path) == "same_dir_target" + assert link2_path.read_text() == "target content" diff --git a/tools/private/update_deps/BUILD.bazel b/tools/private/update_deps/BUILD.bazel index c3f94c9814..8746d82c17 100644 --- a/tools/private/update_deps/BUILD.bazel +++ b/tools/private/update_deps/BUILD.bazel @@ -13,7 +13,8 @@ # limitations under the License. load("//python:py_binary.bzl", "py_binary") load("//python:py_library.bzl", "py_library") -load("//python:py_test.bzl", "py_test") +load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") +load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") licenses(["notice"]) @@ -65,10 +66,11 @@ py_binary( ], ) -py_test( +pytest_test( name = "update_file_test", srcs = ["update_file_test.py"], imports = ["../../.."], + target_compatible_with = SUPPORTS_BZLMOD, deps = [ ":update_file", ], diff --git a/tools/private/update_deps/update_file_test.py b/tools/private/update_deps/update_file_test.py index a3cb1c0a6d..b1d6545ead 100644 --- a/tools/private/update_deps/update_file_test.py +++ b/tools/private/update_deps/update_file_test.py @@ -12,14 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest +import pytest from tools.private.update_deps.update_file import replace_snippet, unified_diff -class TestReplaceSnippet(unittest.TestCase): - def test_replace_simple(self): - current = """\ +def test_replace_simple(): + current = """\ Before the snippet # Start marker @@ -30,15 +29,14 @@ def test_replace_simple(self): After the snippet """ - snippet = "Replaced" # noqa: F841 - got = replace_snippet( - current=current, - snippet="Replaced", - start_marker="# Start marker", - end_marker="# End marker", - ) - - want = """\ + got = replace_snippet( + current=current, + snippet="Replaced", + start_marker="# Start marker", + end_marker="# End marker", + ) + + want = """\ Before the snippet # Start marker @@ -47,10 +45,11 @@ def test_replace_simple(self): After the snippet """ - self.assertEqual(want, got) + assert got == want - def test_replace_indented(self): - current = """\ + +def test_replace_indented(): + current = """\ Before the snippet # Start marker @@ -59,14 +58,14 @@ def test_replace_indented(self): After the snippet """ - got = replace_snippet( - current=current, - snippet=" Replaced", - start_marker="# Start marker", - end_marker="# End marker", - ) - - want = """\ + got = replace_snippet( + current=current, + snippet=" Replaced", + start_marker="# Start marker", + end_marker="# End marker", + ) + + want = """\ Before the snippet # Start marker @@ -75,45 +74,42 @@ def test_replace_indented(self): After the snippet """ - self.assertEqual(want, got) - - def test_raises_if_start_is_not_found(self): - with self.assertRaises(RuntimeError) as exc: - replace_snippet( - current="foo", - snippet="", - start_marker="start", - end_marker="end", - ) - - self.assertEqual(exc.exception.args[0], "Start marker 'start' was not found") - - def test_raises_if_end_is_not_found(self): - with self.assertRaises(RuntimeError) as exc: - replace_snippet( - current="start", - snippet="", - start_marker="start", - end_marker="end", - ) - - self.assertEqual(exc.exception.args[0], "End marker 'end' was not found") - - -class TestUnifiedDiff(unittest.TestCase): - def test_diff(self): - give_a = """\ + assert got == want + + +def test_raises_if_start_is_not_found(): + with pytest.raises(RuntimeError, match="Start marker 'start' was not found"): + replace_snippet( + current="foo", + snippet="", + start_marker="start", + end_marker="end", + ) + + +def test_raises_if_end_is_not_found(): + with pytest.raises(RuntimeError, match="End marker 'end' was not found"): + replace_snippet( + current="start", + snippet="", + start_marker="start", + end_marker="end", + ) + + +def test_diff(): + give_a = """\ First line second line Third line """ - give_b = """\ + give_b = """\ First line Second line Third line """ - got = unified_diff("filename", give_a, give_b) - want = """\ + got = unified_diff("filename", give_a, give_b) + want = """\ --- a/filename +++ b/filename @@ -1,3 +1,3 @@ @@ -121,8 +117,4 @@ def test_diff(self): -second line +Second line Third line""" - self.assertEqual(want, got) - - -if __name__ == "__main__": - unittest.main() + assert got == want From 4f231ac5baf7949a7477c86e91550471ebc8d5b6 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 23 Jul 2026 01:40:22 -0700 Subject: [PATCH 099/179] refactor(tests): create conftest py_library target (#3947) Refactor test/tools/private/release tests such that conftest.py is its own py_library target, and add it as a dependency to targets that directly need it. --- tests/tools/private/release/BUILD.bazel | 34 +++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 6a4b2f8710..b666cf8b54 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -3,16 +3,24 @@ load("//tests/support:support.bzl", "NOT_WINDOWS") load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") py_library( - name = "release_test_helper", - srcs = [ - "conftest.py", - "release_test_helper.py", + name = "conftest", + testonly = True, + srcs = ["conftest.py"], + target_compatible_with = NOT_WINDOWS, + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + "@pypi//pytest_mock", ], +) + +py_library( + name = "release_test_helper", + srcs = ["release_test_helper.py"], target_compatible_with = NOT_WINDOWS, deps = [ "//tools/private/release:mock_gh", "//tools/private/release:release_lib", - "@pypi//pytest_mock", ], ) @@ -21,6 +29,7 @@ pytest_test( srcs = ["add_backports_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -31,6 +40,7 @@ pytest_test( srcs = ["changelog_news_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -41,6 +51,7 @@ pytest_test( srcs = ["complete_sync_changelog_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -51,6 +62,7 @@ pytest_test( srcs = ["create_release_branch_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -61,6 +73,7 @@ pytest_test( srcs = ["git_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -71,6 +84,7 @@ pytest_test( srcs = ["on_pr_merged_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -81,6 +95,7 @@ pytest_test( srcs = ["promote_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -91,6 +106,7 @@ pytest_test( srcs = ["release_issue_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -101,6 +117,7 @@ pytest_test( srcs = ["release_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -111,6 +128,7 @@ pytest_test( srcs = ["backport_create_releases_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -121,6 +139,7 @@ pytest_test( srcs = ["prepare_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -131,6 +150,7 @@ pytest_test( srcs = ["gh_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -141,6 +161,7 @@ pytest_test( srcs = ["backport_prepare_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -151,6 +172,7 @@ pytest_test( srcs = ["process_backports_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -161,6 +183,7 @@ pytest_test( srcs = ["create_rc_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], @@ -171,6 +194,7 @@ pytest_test( srcs = ["utils_test.py"], target_compatible_with = NOT_WINDOWS, deps = [ + ":conftest", ":release_test_helper", "//tools/private/release:release_lib", ], From c6b667177fe907db14d976b8e3631801bf55efd3 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:45:08 +0900 Subject: [PATCH 100/179] refactor(pypi): make whl_library a macro and split impl into whl_archive and pip_archive (#3948) Before this PR the `whl_library` would be a do-all repository rule. Whilst it is convenient to reuse the code, it is actually really difficult to maintain and make it more performant. Side effect here is that the python dependencies (like `setuptools`, etc) will no longer be downloaded for whl-only extracts, it makes it a tiny bit faster. With this split we can drop certain dependencies from the whl extraction and optimize the common path - whl extraction where the URL for downloading the wheel is known. This also allows us to start handling the sdists in an entirely different way. In a followup PR I plan to split the part which just extracts the wheel to lay a more surgical foundation to #3856. Foundation work for #2410. Split out of #3856. Work towards #2948. --------- Co-authored-by: Richard Levasseur --- python/private/pypi/attrs.bzl | 73 ----- python/private/pypi/pip_repository_attrs.bzl | 73 +++++ python/private/pypi/whl_library.bzl | 324 ++++++++++++------- 3 files changed, 284 insertions(+), 186 deletions(-) diff --git a/python/private/pypi/attrs.bzl b/python/private/pypi/attrs.bzl index 57bd93f40a..271c17e35c 100644 --- a/python/private/pypi/attrs.bzl +++ b/python/private/pypi/attrs.bzl @@ -64,79 +64,6 @@ here do not cause packages to be re-fetched. Don't fetch different things based on the value of these variables. """, ), - "experimental_requirement_cycles": attr.string_list_dict( - default = {}, - doc = """\ -A mapping of dependency cycle names to a list of requirements which form that cycle. - -Requirements which form cycles will be installed together and taken as -dependencies together in order to ensure that the cycle is always satisified. - -Example: - `sphinx` depends on `sphinxcontrib-serializinghtml` - When listing both as requirements, ala - - ``` - py_binary( - name = "doctool", - ... - deps = [ - "@pypi//sphinx:pkg", - "@pypi//sphinxcontrib_serializinghtml", - ] - ) - ``` - - Will produce a Bazel error such as - - ``` - ERROR: .../external/pypi_sphinxcontrib_serializinghtml/BUILD.bazel:44:6: in alias rule @pypi_sphinxcontrib_serializinghtml//:pkg: cycle in dependency graph: - //:doctool (...) - @pypi//sphinxcontrib_serializinghtml:pkg (...) - .-> @pypi_sphinxcontrib_serializinghtml//:pkg (...) - | @pypi_sphinxcontrib_serializinghtml//:_pkg (...) - | @pypi_sphinx//:pkg (...) - | @pypi_sphinx//:_pkg (...) - `-- @pypi_sphinxcontrib_serializinghtml//:pkg (...) - ``` - - Which we can resolve by configuring these two requirements to be installed together as a cycle - - ``` - pip_parse( - ... - experimental_requirement_cycles = { - "sphinx": [ - "sphinx", - "sphinxcontrib-serializinghtml", - ] - }, - ) - ``` - -Warning: - If a dependency participates in multiple cycles, all of those cycles must be - collapsed down to one. For instance `a <-> b` and `a <-> c` cannot be listed - as two separate cycles. -""", - ), - "extra_hub_aliases": attr.string_list_dict( - doc = """\ -Extra aliases to make for specific wheels in the hub repo. This is useful when -paired with the {attr}`whl_modifications`. - -:::{versionadded} 0.38.0 - -For `pip.parse` with bzlmod -::: - -:::{versionadded} 1.0.0 - -For `pip_parse` with workspace. -::: -""", - mandatory = False, - ), "extra_pip_args": attr.string_list( doc = """Extra arguments to pass on to pip. Must not contain spaces. diff --git a/python/private/pypi/pip_repository_attrs.bzl b/python/private/pypi/pip_repository_attrs.bzl index 23000869e9..4f0b26448b 100644 --- a/python/private/pypi/pip_repository_attrs.bzl +++ b/python/private/pypi/pip_repository_attrs.bzl @@ -21,6 +21,79 @@ repositories.""" load(":attrs.bzl", COMMON_ATTRS = "ATTRS") ATTRS = { + "experimental_requirement_cycles": attr.string_list_dict( + default = {}, + doc = """\ +A mapping of dependency cycle names to a list of requirements which form that cycle. + +Requirements which form cycles will be installed together and taken as +dependencies together in order to ensure that the cycle is always satisified. + +Example: + `sphinx` depends on `sphinxcontrib-serializinghtml` + When listing both as requirements, ala + + ``` + py_binary( + name = "doctool", + ... + deps = [ + "@pypi//sphinx:pkg", + "@pypi//sphinxcontrib_serializinghtml", + ] + ) + ``` + + Will produce a Bazel error such as + + ``` + ERROR: .../external/pypi_sphinxcontrib_serializinghtml/BUILD.bazel:44:6: in alias rule @pypi_sphinxcontrib_serializinghtml//:pkg: cycle in dependency graph: + //:doctool (...) + @pypi//sphinxcontrib_serializinghtml:pkg (...) + .-> @pypi_sphinxcontrib_serializinghtml//:pkg (...) + | @pypi_sphinxcontrib_serializinghtml//:_pkg (...) + | @pypi_sphinx//:pkg (...) + | @pypi_sphinx//:_pkg (...) + `-- @pypi_sphinxcontrib_serializinghtml//:pkg (...) + ``` + + Which we can resolve by configuring these two requirements to be installed together as a cycle + + ``` + pip_parse( + ... + experimental_requirement_cycles = { + "sphinx": [ + "sphinx", + "sphinxcontrib-serializinghtml", + ] + }, + ) + ``` + +Warning: + If a dependency participates in multiple cycles, all of those cycles must be + collapsed down to one. For instance `a <-> b` and `a <-> c` cannot be listed + as two separate cycles. +""", + ), + "extra_hub_aliases": attr.string_list_dict( + doc = """\ +Extra aliases to make for specific wheels in the hub repo. This is useful when +paired with the {attr}`whl_modifications`. + +:::{versionadded} 0.38.0 + +For `pip.parse` with bzlmod +::: + +:::{versionadded} 1.0.0 + +For `pip_parse` with workspace. +::: +""", + mandatory = False, + ), "requirements_by_platform": attr.label_keyed_string_dict( doc = """\ The requirements files and the comma delimited list of target platforms as values. diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl index 1e75d69c8d..b66e502681 100644 --- a/python/private/pypi/whl_library.bzl +++ b/python/private/pypi/whl_library.bzl @@ -308,110 +308,8 @@ def _to_purl(*, index, metadata, filename): return "pkg:pypi/{}@{}?{}".format(name, metadata.version, "&".join(["{}={}".format(key, val) for key, val in qualifiers.items()])) -def _whl_library_impl(rctx): - logger = repo_utils.logger(rctx) - - whl_path = None - sdist_filename = None - extra_pip_args = [] - extra_pip_args.extend(rctx.attr.extra_pip_args) - if rctx.attr.whl_file: - rctx.watch(rctx.attr.whl_file) - whl_path = rctx.path(rctx.attr.whl_file) - - # Simulate the behaviour where the whl is present in the current directory. - rctx.symlink(whl_path, whl_path.basename) - whl_path = rctx.path(whl_path.basename) - elif rctx.attr.urls and rctx.attr.filename: - filename = rctx.attr.filename - urls = rctx.attr.urls - urls = [ - urllib.absolute_url( - rctx.attr.index_url, - url, - envsubst = rctx.attr.envsubst, - getenv = rctx.getenv, - ) - for url in urls - ] - result = rctx.download( - url = urls, - output = filename, - sha256 = rctx.attr.sha256, - auth = get_auth(rctx, urls), - ) - if not rctx.attr.sha256: - # this is only seen when there is a direct URL reference without sha256 - logger.warn("Please update the requirement line to include the hash:\n{} \\\n --hash=sha256:{}".format( - rctx.attr.requirement, - result.sha256, - )) - - if not result.success: - fail("could not download the '{}' from {}:\n{}".format(filename, urls, result)) - - if filename.endswith(".whl"): - whl_path = rctx.path(filename) - else: - sdist_filename = filename - - # It is an sdist and we need to tell PyPI to use a file in this directory - # and, allow getting build dependencies from PYTHONPATH, which we - # setup in this repository rule, but still download any necessary - # build deps from PyPI (e.g. `flit_core`) if they are missing. - extra_pip_args.extend(["--find-links", "."]) - - # When we already have a wheel, Python isn't used, - # so there's no need to setup env vars to run Python, unless we need to - # build an sdist or resolve a requirement. - if whl_path: - environment = {} - args = [] - python_interpreter = None - else: - python_interpreter = pypi_repo_utils.resolve_python_interpreter( - rctx, - python_interpreter = rctx.attr.python_interpreter, - python_interpreter_target = rctx.attr.python_interpreter_target, - ) - args = [ - "-m", - "python.private.pypi.whl_installer.wheel_installer", - "--requirement", - rctx.attr.requirement, - ] - args = _parse_optional_attrs(rctx, args, extra_pip_args) - - # Manually construct the PYTHONPATH since we cannot use the toolchain here - environment = _create_repository_execution_environment(rctx, python_interpreter, logger = logger) - - if not whl_path: - if rctx.attr.urls: - op_tmpl = "whl_library.BuildWheelFromSource({name}, {requirement})" - elif rctx.attr.download_only: - op_tmpl = "whl_library.DownloadWheel({name}, {requirement})" - else: - op_tmpl = "whl_library.ResolveRequirement({name}, {requirement})" - - pypi_repo_utils.execute_checked( - rctx, - # truncate the requirement value when logging it / reporting - # progress since it may contain several ' --hash=sha256:... - # --hash=sha256:...' substrings that fill up the console - python = python_interpreter, - op = op_tmpl.format(name = rctx.attr.name, requirement = rctx.attr.requirement.split(" ", 1)[0]), - arguments = args, - environment = environment, - srcs = rctx.attr._python_srcs, - quiet = rctx.attr.quiet, - timeout = rctx.attr.timeout, - logger = logger, - ) - - whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"]) - if not rctx.delete("whl_file.json"): - fail("failed to delete the whl_file.json file") - +def _whl_extract(rctx, *, whl_path, logger, sdist_filename = None): + """Extract the wheel, apply patches and generate BUILD.bazel files.""" if rctx.attr.whl_patches: patches = {} for patch_file, json_args in rctx.attr.whl_patches.items(): @@ -441,10 +339,10 @@ def _whl_library_impl(rctx): build_file_contents = generate_whl_library_build_bazel( name = whl_path.basename, - sdist_filename = sdist_filename, dep_template = rctx.attr.dep_template or "@{}{{name}}//:{{target}}".format( rctx.attr.repo_prefix, ), + sdist_filename = sdist_filename, config_load = rctx.attr.config_load, metadata_name = metadata.name, metadata_version = metadata.version, @@ -488,6 +386,144 @@ repo( return None +def _whl_archive_impl(rctx): + logger = repo_utils.logger(rctx) + + whl_path = None + if rctx.attr.whl_file: + rctx.watch(rctx.attr.whl_file) + whl_path = rctx.path(rctx.attr.whl_file) + + # Simulate the behaviour where the whl is present in the current directory. + rctx.symlink(whl_path, whl_path.basename) + whl_path = rctx.path(whl_path.basename) + elif rctx.attr.urls and rctx.attr.filename: + filename = rctx.attr.filename + urls = rctx.attr.urls + urls = [ + urllib.absolute_url( + rctx.attr.index_url, + url, + envsubst = rctx.attr.envsubst, + getenv = rctx.getenv, + ) + for url in urls + ] + result = rctx.download( + url = urls, + output = filename, + sha256 = rctx.attr.sha256, + auth = get_auth(rctx, urls), + ) + if not rctx.attr.sha256: + # this is only seen when there is a direct URL reference without sha256 + logger.warn("Please update the requirement line to include the hash:\n{} \\\n --hash=sha256:{}".format( + rctx.attr.requirement, + result.sha256, + )) + + if not result.success: + fail("could not download the '{}' from {}:\n{}".format(filename, urls, result)) + + if filename.endswith(".whl"): + whl_path = rctx.path(filename) + else: + fail("Only wheels are supported") + + return _whl_extract(rctx, whl_path = whl_path, logger = logger) + +def _pip_archive_impl(rctx): + logger = repo_utils.logger(rctx) + + sdist_filename = None + extra_pip_args = [] + extra_pip_args.extend(rctx.attr.extra_pip_args) + if rctx.attr.urls and rctx.attr.filename: + filename = rctx.attr.filename + urls = rctx.attr.urls + urls = [ + urllib.absolute_url( + rctx.attr.index_url, + url, + envsubst = rctx.attr.envsubst, + getenv = rctx.getenv, + ) + for url in urls + ] + result = rctx.download( + url = urls, + output = filename, + sha256 = rctx.attr.sha256, + auth = get_auth(rctx, urls), + ) + if not rctx.attr.sha256: + # this is only seen when there is a direct URL reference without sha256 + logger.warn("Please update the requirement line to include the hash:\n{} \\\n --hash=sha256:{}".format( + rctx.attr.requirement, + result.sha256, + )) + + if not result.success: + fail("could not download the '{}' from {}:\n{}".format(filename, urls, result)) + + if filename.endswith(".whl"): + fail("Only sdists are supported") + else: + sdist_filename = filename + + # It is an sdist and we need to tell PyPI to use a file in this directory + # and, allow getting build dependencies from PYTHONPATH, which we + # setup in this repository rule, but still download any necessary + # build deps from PyPI (e.g. `flit_core`) if they are missing. + extra_pip_args.extend(["--find-links", "."]) + + # When we already have a wheel, Python isn't used, + # so there's no need to setup env vars to run Python, unless we need to + # build an sdist or resolve a requirement. + python_interpreter = pypi_repo_utils.resolve_python_interpreter( + rctx, + python_interpreter = rctx.attr.python_interpreter, + python_interpreter_target = rctx.attr.python_interpreter_target, + ) + args = [ + "-m", + "python.private.pypi.whl_installer.wheel_installer", + "--requirement", + rctx.attr.requirement, + ] + args = _parse_optional_attrs(rctx, args, extra_pip_args) + + # Manually construct the PYTHONPATH since we cannot use the toolchain here + environment = _create_repository_execution_environment(rctx, python_interpreter, logger = logger) + + if rctx.attr.urls: + op_tmpl = "whl_library.BuildWheelFromSource({name}, {requirement})" + elif rctx.attr.download_only: + op_tmpl = "whl_library.DownloadWheel({name}, {requirement})" + else: + op_tmpl = "whl_library.ResolveRequirement({name}, {requirement})" + + pypi_repo_utils.execute_checked( + rctx, + # truncate the requirement value when logging it / reporting + # progress since it may contain several ' --hash=sha256:... + # --hash=sha256:...' substrings that fill up the console + python = python_interpreter, + op = op_tmpl.format(name = rctx.attr.name, requirement = rctx.attr.requirement.split(" ", 1)[0]), + arguments = args, + environment = environment, + srcs = rctx.attr._python_srcs, + quiet = rctx.attr.quiet, + timeout = rctx.attr.timeout, + logger = logger, + ) + + whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"]) + if not rctx.delete("whl_file.json"): + fail("failed to delete the whl_file.json file") + + return _whl_extract(rctx, whl_path = whl_path, logger = logger, sdist_filename = sdist_filename) + def _remove_files(rctx, *basenames): paths = list(rctx.path(".").readdir()) for _ in range(10000000): @@ -501,7 +537,7 @@ def _remove_files(rctx, *basenames): paths.extend(path.readdir()) # NOTE @aignas 2024-03-21: The usage of dict({}, **common) ensures that all args to `dict` are unique -whl_library_attrs = dict({ +_pip_archive_attrs = dict({ "annotation": attr.label( doc = ( "Optional json encoded file containing annotation to apply to the extracted wheel. " + @@ -557,9 +593,6 @@ DEPRECATED. Only left for people who vendor requirements.bzl. The list of urls of the whl to be downloaded using bazel downloader. Using this attr makes `extra_pip_args` and `download_only` ignored.""", ), - "whl_file": attr.label( - doc = "The whl file that should be used instead of downloading or building the whl.", - ), "whl_patches": attr.label_keyed_string_dict( doc = """ A label-keyed-string dict with patch files as keys and json-strings as values. @@ -607,10 +640,10 @@ way to define whl_library and move whl patching to a separate place. INTERNAL US ), "_rule_name": attr.string(default = "whl_library"), }, **ATTRS) -whl_library_attrs.update(AUTH_ATTRS) +_pip_archive_attrs.update(AUTH_ATTRS) -whl_library = repository_rule( - attrs = whl_library_attrs, +pip_archive = repository_rule( + attrs = _pip_archive_attrs, doc = """ Download and extracts a single wheel based into a bazel repo based on the requirement string passed in. Instantiated from pip_repository and inherits config options from there. @@ -619,10 +652,75 @@ Instantiated from pip_repository and inherits config options from there. The `whl_library` is marked as reproducible if using starlark to extract and parse the wheel contents without building an `sdist` first. ::: + +:::{versionchanged} VERSION_NEXT_FEATURE +The whl-only pure Starlark operations have been refactored into {obj}`whl_archive` and the +previously named {obj}`whl_library` repository became renamed to `pip_archive`. +::: """, - implementation = _whl_library_impl, + implementation = _pip_archive_impl, environ = [ "RULES_PYTHON_PIP_ISOLATED", REPO_DEBUG_ENV_VAR, ], ) + +whl_archive = repository_rule( + attrs = { + k: _pip_archive_attrs[k] + for k in [ + "annotation", + "config_load", + "dep_template", + "filename", + "group_deps", + "group_name", + "index_url", + "repo", + "repo_prefix", + "requirement", + "sha256", + "urls", + "whl_patches", + # common attrs + "enable_implicit_namespace_pkgs", + "envsubst", + "pip_data_exclude", + ] + } | { + "whl_file": attr.label( + doc = "The whl file that should be used instead of downloading or building the whl.", + ), + } | AUTH_ATTRS, + doc = """ +Download and extracts a single wheel based into a bazel repo based on the requirement string passed in. + +Does not depend on any python. +""", + implementation = _whl_archive_impl, + environ = [ + REPO_DEBUG_ENV_VAR, + ], +) + +def whl_library(name, **kwargs): + """Create a whl_library. + + This proxies to one of the underlying implementations: + * {obj}`whl_archive` + * {obj}`pip_archive` + + Args: + name: {type}`str` The name of the repo. + **kwargs: The args passed to the underlying implementation. + + Returns: + the repo metadata. + """ + whl_file = kwargs.get("whl_file") + urls = kwargs.get("urls", []) + filename = kwargs.get("filename") + if whl_file or (urls and filename and filename.endswith(".whl")): + return whl_archive(name = name, **kwargs) + + return pip_archive(name = name, **kwargs) From e44e24dcac7d428392456913b6f335ebf964a22b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:29:40 -0700 Subject: [PATCH 101/179] build(deps): bump myst-parser from 4.0.1 to 5.1.0 in /docs (#3942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [myst-parser](https://github.com/executablebooks/MyST-Parser) from 4.0.1 to 5.1.0.
Release notes

Sourced from myst-parser's releases.

v5.1.0

✨ New Features

👌 Improvements

  • 👌 Update myst_gfm_only mode to use the unified gfm_plugin, which now includes GFM autolinks, alerts, and improved strikethrough/tasklist handling by @​chrisjsewell in #1128
  • 👌 Improve MathJax 4 compatibility for Sphinx 9 by @​chrisjsewell in #1110
  • 👌 Stop directive-option parsing at colon fences, fixing nested colon fence directives by @​chrisjsewell in #1133

🐛 Bug Fixes

⬆️ Dependency Upgrades

New Contributors

Full Changelog: https://github.com/executablebooks/MyST-Parser/compare/v5.0.0...v5.1.0

v5.0.0

MyST-Parser 5.0.0

Release Date: 2026-01-15

This release significantly bumps the supported versions of core dependencies:

‼️ Breaking Changes

This release updates the minimum supported versions:

  • Python: >=3.11 (dropped Python 3.10, tests up to 3.14)
  • Sphinx: >=8,<10 (dropped Sphinx 7, added Sphinx 9)
  • Docutils: >=0.20,<0.23 (dropped docutils 0.19, added docutils 0.22)
  • markdown-it-py: ~=4.0 (upgraded from v3)

... (truncated)

Changelog

Sourced from myst-parser's changelog.

5.1.0 - 2026-05-13

✨ New Features

👌 Improvements

🐛 Bug Fixes

⬆️ Dependency Upgrades

Full Changelog: v5.0.0...v5.1.0

5.0.0 - 2026-01-15

This release significantly bumps the supported versions of core dependencies:

‼️ Breaking Changes

This release updates the minimum supported versions:

  • Python: >=3.11 (dropped Python 3.10, tests up to 3.14)
  • Sphinx: >=8,<10 (dropped Sphinx 7, added Sphinx 9)
  • Docutils: >=0.20,<0.23 (dropped docutils 0.19, added docutils 0.22)
  • markdown-it-py: ~=4.0 (upgraded from v3)

⬆️ Dependency Upgrades

... (truncated)

Commits
  • 2871eb9 🚀 Release v5.1.0 (#1135)
  • cc5db37 🐛 FIX: Pin mdit-py-plugins>=0.6.1 for nested field list fix (#1134)
  • 4ce57f9 👌 Stop directive-option parsing at colon fences (#1133)
  • cfcc327 ⬆️ Bump mypy from 2.0.0 to 2.1.0 (#1131)
  • 691738c ⬆️ Bump ruff from 0.15.10 to 0.15.12 (#1132)
  • 0fb1ae9 👌 IMPROVE: MathJax 4 compatibility (Sphinx 9) (#1110)
  • f153b4b ⬆️ Bump actions/setup-python from 5 to 6 (#1092)
  • 93acf8d [pre-commit.ci] pre-commit autoupdate (#1095)
  • a5f1d69 ⬆️ Update pygments requirement from <2.20 to <2.21 (#1117)
  • 8381296 🐛 FIX: Use docname instead of source path in warning locations (#1114)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=myst-parser&package-manager=pip&previous-version=4.0.1&new-version=5.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 5ac2d2b323..9a266e9644 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -309,9 +309,9 @@ myst-parser==3.0.1 ; python_full_version < '3.10' \ --hash=sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1 \ --hash=sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87 # via rules-python-docs (docs/pyproject.toml) -myst-parser==4.0.1 ; python_full_version == '3.10.*' \ - --hash=sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4 \ - --hash=sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d +myst-parser==5.1.0 ; python_full_version == '3.10.*' \ + --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ + --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 # via rules-python-docs (docs/pyproject.toml) myst-parser==5.1.0 ; python_full_version >= '3.11' \ --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ From 87a6f0c77d5d18a9c9ce5d6e1b19aa53cbda7bcb Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:52:35 +0900 Subject: [PATCH 102/179] refactor(pypi): a seam for source vs library targets (#3949) With this we are starting to separate whl_library_targets into 2 parts - one for sources only (without deps) and another one is just the deps parts. Next PR I'll create a way to create 2 separate instances. Split out of #3856 Work towards #2948 --- python/private/pypi/labels.bzl | 2 + python/private/pypi/whl_library.bzl | 1 - python/private/pypi/whl_library_targets.bzl | 299 ++++++++++++------ .../whl_library_targets_tests.bzl | 28 +- 4 files changed, 217 insertions(+), 113 deletions(-) diff --git a/python/private/pypi/labels.bzl b/python/private/pypi/labels.bzl index 8f91a03b4c..c77167b593 100644 --- a/python/private/pypi/labels.bzl +++ b/python/private/pypi/labels.bzl @@ -17,8 +17,10 @@ EXTRACTED_WHEEL_FILES = "extracted_whl_files" WHEEL_FILE_PUBLIC_LABEL = "whl" WHEEL_FILE_IMPL_LABEL = "_whl" +WHEEL_FILE = "whl_file" PY_LIBRARY_PUBLIC_LABEL = "pkg" PY_LIBRARY_IMPL_LABEL = "_pkg" +PY_SRCS_LABEL = "srcs" DATA_LABEL = "data" DIST_INFO_LABEL = "dist_info" NODEPS_LABEL = "no_deps" diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl index b66e502681..9a150db9e8 100644 --- a/python/private/pypi/whl_library.bzl +++ b/python/private/pypi/whl_library.bzl @@ -682,7 +682,6 @@ whl_archive = repository_rule( "sha256", "urls", "whl_patches", - # common attrs "enable_implicit_namespace_pkgs", "envsubst", "pip_data_exclude", diff --git a/python/private/pypi/whl_library_targets.bzl b/python/private/pypi/whl_library_targets.bzl index 933b529053..217210e782 100644 --- a/python/private/pypi/whl_library_targets.bzl +++ b/python/private/pypi/whl_library_targets.bzl @@ -26,6 +26,8 @@ load( "EXTRACTED_WHEEL_FILES", "PY_LIBRARY_IMPL_LABEL", "PY_LIBRARY_PUBLIC_LABEL", + "PY_SRCS_LABEL", + "WHEEL_FILE", "WHEEL_FILE_IMPL_LABEL", "WHEEL_FILE_PUBLIC_LABEL", ) @@ -57,6 +59,19 @@ def whl_library_targets_from_requires( entry_points = {}, include = [], group_deps = [], + group_name = None, + dep_template = None, + data_exclude = [], + enable_implicit_namespace_pkgs = False, + sdist_filename = None, + namespace_package_files = [], + filegroups = None, + copy_files = {}, + copy_executables = {}, + srcs_exclude = [], + data = [], + visibility = ["//visibility:public"], + tags = [], **kwargs): """The macro to create whl targets from the METADATA. @@ -73,8 +88,44 @@ def whl_library_targets_from_requires( extras: {type}`list[str]` The list of requested extras. This essentially includes extra transitive dependencies in the final targets depending on the wheel `METADATA`. entry_points: {type}`list[dict]` A list of parsed entry point definitions. include: {type}`list[str]` The list of packages to include. - **kwargs: Extra args passed to the {obj}`whl_library_targets` + group_name: {type}`str | None` name of the dependency group (if any). + dep_template: {type}`str | None` The dep_template to use. + data_exclude: {type}`list[str]` The globs for data attribute exclusion. + enable_implicit_namespace_pkgs: {type}`boolean` generate __init__.py files for namespace pkgs. + sdist_filename: {type}`str | None` The filename of the sdist. + namespace_package_files: {type}`list[str]` A list of labels of files whose directories are namespace packages. + filegroups: {type}`dict[str, list[str]] | None` A dictionary of the target names and the glob matches. + copy_files: {type}`dict[str, str]` The mapping between src and dest locations. + copy_executables: {type}`dict[str, str]` The mapping between src and dest locations for executables. + srcs_exclude: {type}`list[str]` The globs for srcs attribute exclusion. + data: {type}`list[str]` A list of labels to include as part of the `data` attribute. + visibility: {type}`list[str]` The visibility of the targets. + tags: {type}`list[str]` The tags set on the targets. + **kwargs: Extra args passed to the {obj}`whl_library_targets` and {obj}`whl_library_srcs`. """ + pypi_tags = [ + "pypi_name={}".format(metadata_name), + "pypi_version={}".format(metadata_version), + ] + all_tags = sorted(tags + pypi_tags) + + whl_library_srcs( + name = name, + sdist_filename = sdist_filename, + data_exclude = data_exclude, + srcs_exclude = srcs_exclude, + tags = all_tags, + filegroups = filegroups, + entry_points = entry_points, + visibility = visibility, + data = data, + copy_files = copy_files, + copy_executables = copy_executables, + enable_implicit_namespace_pkgs = enable_implicit_namespace_pkgs, + namespace_package_files = namespace_package_files, + **kwargs + ) + package_deps = _parse_requires_dist( name = metadata_name, requires_dist = requires_dist, @@ -87,42 +138,22 @@ def whl_library_targets_from_requires( name = name, dependencies = package_deps.deps, dependencies_with_markers = package_deps.deps_select, - entry_points = entry_points, - tags = [ - "pypi_name={}".format(metadata_name), - "pypi_version={}".format(metadata_version), - ], + group_name = group_name, + dep_template = dep_template, + tags = all_tags, **kwargs ) -def _parse_requires_dist( +def whl_library_srcs( *, name, - requires_dist, - excludes, - include, - extras): - return deps( - name = normalize_name(name), - requires_dist = requires_dist, - excludes = excludes, - include = include, - extras = extras, - ) - -def whl_library_targets( - *, - name, - dep_template, sdist_filename = None, data_exclude = [], srcs_exclude = [], tags = [], - dependencies = [], filegroups = None, - dependencies_with_markers = {}, entry_points = {}, - group_name = "", + visibility = ["//visibility:public"], data = [], copy_files = {}, copy_executables = {}, @@ -143,21 +174,13 @@ def whl_library_targets( Args: name: {type}`str` The file to match for including it into the `whl` filegroup. This may be also parsed to generate extra metadata. - dep_template: {type}`str` The dep_template to use for dependency - interpolation. sdist_filename: {type}`str | None` If the wheel was built from an sdist, the filename of the sdist. + visibility: {type}`list[str]` The visibility of the source targets. tags: {type}`list[str]` The tags set on the `py_library`. - dependencies: {type}`list[str]` A list of dependencies. - dependencies_with_markers: {type}`dict[str, str]` A marker to evaluate - in order for the dep to be included. entry_points: {type}`list[dict]` A list of parsed entry point definitions. filegroups: {type}`dict[str, list[str]] | None` A dictionary of the target names and the glob matches. If `None`, defaults will be used. - group_name: {type}`str` name of the dependency group (if any) which - contains this library. If set, this library will behave as a shim - to group implementation rules which will provide simultaneously - installed dependencies which would otherwise form a cycle. copy_executables: {type}`dict[str, str]` The mapping between src and dest locations for the targets. copy_files: {type}`dict[str, str]` The mapping between src and @@ -174,7 +197,6 @@ def whl_library_targets( directories are namespace packages. rules: {type}`struct` A struct with references to rules for creating targets. """ - dependencies = sorted([normalize_name(d) for d in dependencies]) tags = sorted(tags) data = [] + data @@ -233,7 +255,7 @@ def whl_library_targets( native.filegroup( name = filegroup_name, srcs = srcs, - visibility = ["//visibility:public"], + visibility = visibility, ) for src, dest in copy_files.items(): @@ -241,7 +263,7 @@ def whl_library_targets( name = dest + ".copy", src = src, out = dest, - visibility = ["//visibility:public"], + visibility = visibility, ) data.append(dest) for src, dest in copy_executables.items(): @@ -250,10 +272,137 @@ def whl_library_targets( src = src, out = dest, is_executable = True, - visibility = ["//visibility:public"], + visibility = visibility, ) data.append(dest) + if hasattr(native, "filegroup"): + native.filegroup( + name = WHEEL_FILE, + srcs = [name], + visibility = visibility, + ) + + if hasattr(rules, "py_library"): + srcs = native.glob( + ["site-packages/**/*.py"], + exclude = srcs_exclude, + # Empty sources are allowed to support wheels that don't have any + # pure-Python code, e.g. pymssql, which is written in Cython. + allow_empty = True, + ) + + # NOTE: pyi files should probably be excluded because they're carried + # by the pyi_srcs attribute. However, historical behavior included + # them in data and some tools currently rely on that. + _data_exclude = [ + "**/*.py", + "**/*.pyc", + "**/*.pyc.*", # During pyc creation, temp files named *.pyc.NNNN are created + ] + if sdist_filename: + _data_exclude.append("**/*.dist-info/RECORD") + for item in data_exclude: + if item not in _data_exclude: + _data_exclude.append(item) + + data = data + native.glob( + ["site-packages/**/*"], + exclude = _data_exclude, + allow_empty = True, + ) + + pyi_srcs = native.glob( + ["site-packages/**/*.pyi"], + allow_empty = True, + ) + + if not enable_implicit_namespace_pkgs: + generated_namespace_package_files = select({ + _IS_VENV_SITE_PACKAGES_YES: [], + "//conditions:default": rules.create_inits( + srcs = srcs + data + pyi_srcs, + ignored_dirnames = [], # If you need to ignore certain folders, you can patch rules_python here to do so. + root = "site-packages", + ), + }) + namespace_package_files += generated_namespace_package_files + srcs = srcs + generated_namespace_package_files + + # This is done after create_inits() is called so that the data scheme + # files don't have such files created in their directories. + data = data + [DATA_LABEL] + + rules.py_library( + name = PY_SRCS_LABEL, + srcs = srcs, + pyi_srcs = pyi_srcs, + data = data, + # This makes this directory a top-level in the python import + # search path for anything that depends on this. + imports = ["site-packages"], + tags = tags, + visibility = visibility, + experimental_venvs_site_packages = _VENV_SITE_PACKAGES_FLAG, + namespace_package_files = namespace_package_files, + ) + +def _parse_requires_dist( + *, + name, + requires_dist, + excludes, + include, + extras): + return deps( + name = normalize_name(name), + requires_dist = requires_dist, + excludes = excludes, + include = include, + extras = extras, + ) + +def whl_library_targets( + *, + name, + dep_template, + tags = [], + dependencies = [], + dependencies_with_markers = {}, + group_name = "", + native = native, + rules = struct( + copy_file = copy_file, + py_binary = py_binary, + py_library = py_library, + venv_entry_point = venv_entry_point, + venv_rewrite_shebang = venv_rewrite_shebang, + env_marker_setting = env_marker_setting, + create_inits = _create_inits, + ), + **_kwargs): + """Create all of the whl_library targets. + + Args: + name: {type}`str` The file to match for including it into the `whl` + filegroup. This may be also parsed to generate extra metadata. + dep_template: {type}`str` The dep_template to use for dependency + interpolation. + tags: {type}`list[str]` The tags set on the `py_library`. + dependencies: {type}`list[str]` A list of dependencies. + dependencies_with_markers: {type}`dict[str, str]` A marker to evaluate + in order for the dep to be included. + group_name: {type}`str` name of the dependency group (if any) which + contains this library. If set, this library will behave as a shim + to group implementation rules which will provide simultaneously + installed dependencies which would otherwise form a cycle. + native: {type}`native` The native struct for overriding in tests. + rules: {type}`struct` A struct with references to rules for creating targets. + **_kwargs: ignored args that are not needed. + """ + dependencies = sorted([normalize_name(d) for d in dependencies]) + tags = sorted(tags) + _config_settings( dependencies_with_markers = dependencies_with_markers, rules = rules, @@ -315,8 +464,9 @@ def whl_library_targets( if hasattr(native, "filegroup"): native.filegroup( name = whl_file_label, - srcs = [name], - data = _deps( + data = [ + WHEEL_FILE, + ] + _deps( deps = dependencies, deps_conditional = deps_conditional, tmpl = dep_template.format(name = "{}", target = WHEEL_FILE_PUBLIC_LABEL), @@ -325,72 +475,23 @@ def whl_library_targets( ) if hasattr(rules, "py_library"): - srcs = native.glob( - ["site-packages/**/*.py"], - exclude = srcs_exclude, - # Empty sources are allowed to support wheels that don't have any - # pure-Python code, e.g. pymssql, which is written in Cython. - allow_empty = True, - ) - - # NOTE: pyi files should probably be excluded because they're carried - # by the pyi_srcs attribute. However, historical behavior included - # them in data and some tools currently rely on that. - _data_exclude = [ - "**/*.py", - "**/*.pyc", - "**/*.pyc.*", # During pyc creation, temp files named *.pyc.NNNN are created - ] - if sdist_filename: - _data_exclude.append("**/*.dist-info/RECORD") - for item in data_exclude: - if item not in _data_exclude: - _data_exclude.append(item) - - data = data + native.glob( - ["site-packages/**/*"], - exclude = _data_exclude, - allow_empty = True, - ) - - pyi_srcs = native.glob( - ["site-packages/**/*.pyi"], - allow_empty = True, - ) - - if not enable_implicit_namespace_pkgs: - generated_namespace_package_files = select({ - _IS_VENV_SITE_PACKAGES_YES: [], - "//conditions:default": rules.create_inits( - srcs = srcs + data + pyi_srcs, - ignored_dirnames = [], # If you need to ignore certain folders, you can patch rules_python here to do so. - root = "site-packages", - ), - }) - namespace_package_files += generated_namespace_package_files - srcs = srcs + generated_namespace_package_files - - # This is done after create_inits() is called so that the data scheme - # files don't have such files created in their directories. - data = data + [DATA_LABEL] - rules.py_library( name = py_library_label, - srcs = srcs, - pyi_srcs = pyi_srcs, - data = data, - # This makes this directory a top-level in the python import - # search path for anything that depends on this. - imports = ["site-packages"], - deps = _deps( + srcs = [ + # We include as srcs to ensure that the (locations :pkg) works as expected. + PY_SRCS_LABEL, + ], + deps = [ + # We include as deps, so that `PyInfo` and friends get propagated as deps. + # not sure if just including it as `srcs` is enough. + PY_SRCS_LABEL, + ] + _deps( deps = dependencies, deps_conditional = deps_conditional, tmpl = dep_template.format(name = "{}", target = PY_LIBRARY_PUBLIC_LABEL), ), tags = tags, visibility = impl_vis, - experimental_venvs_site_packages = _VENV_SITE_PACKAGES_FLAG, - namespace_package_files = namespace_package_files, ) def _config_settings(dependencies_with_markers, rules, **kwargs): diff --git a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl index 5bd1d1f549..1f4aac1ba8 100644 --- a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl +++ b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl @@ -17,7 +17,7 @@ load("@rules_testing//lib:test_suite.bzl", "test_suite") load( "//python/private/pypi:whl_library_targets.bzl", - "whl_library_targets", + "whl_library_srcs", "whl_library_targets_from_requires", ) # buildifier: disable=bzl-visibility load("//tests/support/mocks:mocks.bzl", "mocks") @@ -34,9 +34,8 @@ def _test_filegroups(env): return [] return include - whl_library_targets( + whl_library_srcs( name = "", - dep_template = "", native = struct( filegroup = lambda **kwargs: calls.append(kwargs), glob = glob, @@ -63,9 +62,8 @@ def _test_filegroups(env): "visibility": ["//visibility:public"], }, { - "name": "whl", + "name": "whl_file", "srcs": [""], - "data": [], "visibility": ["//visibility:public"], }, ]) # buildifier: @unsorted-dict-items @@ -75,9 +73,8 @@ _tests.append(_test_filegroups) def _test_copy(env): calls = [] - whl_library_targets( + whl_library_srcs( name = "", - dep_template = None, filegroups = {}, copy_files = {"file_src": "file_dest"}, copy_executables = {"exec_src": "exec_dest"}, @@ -151,17 +148,23 @@ def _test_whl_and_library_deps_from_requires(env): env.expect.that_collection(filegroup_calls).contains_exactly([ { - "name": "whl", + "name": "whl_file", "srcs": ["foo-0-py3-none-any.whl"], - "data": ["@pypi//bar:whl"] + select({ + "visibility": ["//visibility:public"], + }, + { + "name": "whl", + # NOTE @aignas 2026-07-25: depending on the brackets position one may get different + # results in the expectation. + "data": ["whl_file"] + (["@pypi//bar:whl"] + select({ ":is_include_bar_baz_true": ["@pypi//bar_baz:whl"], "//conditions:default": [], - }), + })), "visibility": ["//visibility:public"], }, ]) # buildifier: @unsorted-dict-items - env.expect.that_collection(py_library_calls).has_size(1) + env.expect.that_collection(py_library_calls).has_size(2) if len(py_library_calls) != 1: return py_library_call = py_library_calls[0] @@ -238,9 +241,8 @@ def _test_sdist_excludes_record(env): m_glob.results.append([]) # data m_glob.results.append([]) # pyi - whl_library_targets( + whl_library_srcs( name = "foo.whl", - dep_template = "@pypi_{name}//:{target}", sdist_filename = "foo.tar.gz", filegroups = {}, native = struct( From 2ab469ed3936dc314f7c9246705163fc631eb9d4 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 26 Jul 2026 19:18:38 -0700 Subject: [PATCH 103/179] docs(pypi): add versionadded 2.2.0 directive to uv_lock attribute (#3953) The uv_lock argument for pip.parse was introduced in version 2.2.0, but its attribute documentation did not indicate when it was added. To fix this, add the :::{versionadded} 2.2.0::: directive to the uv_lock attribute docstring in python/private/pypi/extension.bzl. --- python/private/pypi/extension.bzl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index b79d04c075..412b03c21f 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -991,9 +991,12 @@ a string `"{os}_{arch}"` as the value here. You could also use `"{os}_{arch}_fre """, ), "uv_lock": attr.label( - doc = """\ + doc = """ (label, optional): A label pointing to the uv.lock file. If provided, the uv.lock file will be used as the primary source for package metadata. + +:::{versionadded} 2.2.0 +::: """, ), "whl_modifications": attr.label_keyed_string_dict( From 0b6c475b35b2991e7f5d3b86f4e3b078df122fd0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 26 Jul 2026 19:34:03 -0700 Subject: [PATCH 104/179] agents: clarify fast-tests config flag usage for building (#3954) Currently, AGENTS.md instructs agents to add --config=fast-tests when building. However, --config=fast-tests enables --build_tests_only=true, which silently ignores non-test targets like //docs:docs and reports 0 targets found. To fix this, clarify that --config=fast-tests should only be used when building test targets, and must not be used when building non-test targets. --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 170aa49c8e..d66e18ca11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,10 @@ Tests are under the `tests/` directory. When testing, add `--config=fast-tests`. -When building, add `--config=fast-tests`. +When building test targets, add `--config=fast-tests`. Do NOT use +`--config=fast-tests` when building non-test targets (such as `//docs:docs`), +because `--config=fast-tests` enables `--build_tests_only=true`, which causes +non-test targets to be silently ignored (finding 0 targets). The `--config=fast-tests` flag avoids running expensive and slow tests can that freeze the host machine or cause flakiness. From 44a0ff793c272d549ef04f3de4e636bd5378e099 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 26 Jul 2026 19:35:13 -0700 Subject: [PATCH 105/179] sphinxdocs: create sphinxdocs-specific pyproject and requirements (#3951) To further separate sphinxdocs from rules_python, give it its own pyproject and requirements files. Also switch it to using uv.lock instead of requirements.lock --- sphinxdocs/.bazelignore | 8 + sphinxdocs/BUILD.bazel | 3 + sphinxdocs/MODULE.bazel | 5 +- sphinxdocs/dev/BUILD.bazel | 30 + sphinxdocs/dev/requirements.txt | 556 +++++++++ sphinxdocs/dev/uv.lock | 1073 +++++++++++++++++ sphinxdocs/integration_tests/bcr/MODULE.bazel | 2 +- .../persistent_worker/workspace/MODULE.bazel | 2 +- sphinxdocs/pyproject.toml | 15 + 9 files changed, 1690 insertions(+), 4 deletions(-) create mode 100644 sphinxdocs/dev/BUILD.bazel create mode 100644 sphinxdocs/dev/requirements.txt create mode 100644 sphinxdocs/dev/uv.lock create mode 100644 sphinxdocs/pyproject.toml diff --git a/sphinxdocs/.bazelignore b/sphinxdocs/.bazelignore index 80f2d3514f..48237cb876 100644 --- a/sphinxdocs/.bazelignore +++ b/sphinxdocs/.bazelignore @@ -2,3 +2,11 @@ bazel-bin bazel-out bazel-testlogs bazel-sphinxdocs +integration_tests/bcr/bazel-bin +integration_tests/bcr/bazel-bcr +integration_tests/bcr/bazel-out +integration_tests/bcr/bazel-testlogs +integration_tests/persistent_worker/workspace/bazel-bin +integration_tests/persistent_worker/workspace/bazel-out +integration_tests/persistent_worker/workspace/bazel-testlogs +integration_tests/persistent_worker/workspace/bazel-workspace diff --git a/sphinxdocs/BUILD.bazel b/sphinxdocs/BUILD.bazel index 8ff9bc956f..d1eec30a09 100644 --- a/sphinxdocs/BUILD.bazel +++ b/sphinxdocs/BUILD.bazel @@ -1,5 +1,7 @@ package(default_visibility = ["//visibility:public"]) +exports_files(["pyproject.toml"]) + filegroup( name = "distribution", srcs = glob( @@ -10,6 +12,7 @@ filegroup( "tests/**", ], ) + [ + "//dev:distribution", "//sphinxdocs:distribution", ], visibility = ["//visibility:public"], diff --git a/sphinxdocs/MODULE.bazel b/sphinxdocs/MODULE.bazel index 3d4caf2bb2..51de5e94f9 100644 --- a/sphinxdocs/MODULE.bazel +++ b/sphinxdocs/MODULE.bazel @@ -8,7 +8,7 @@ bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "stardoc", version = "0.7.2", repo_name = "io_bazel_stardoc") bazel_dep(name = "platforms", version = "0.0.11") bazel_dep(name = "protobuf", version = "29.0-rc2", repo_name = "com_google_protobuf") -bazel_dep(name = "rules_python", version = "1.8.5") +bazel_dep(name = "rules_python", version = "2.2.0") dev_pip = use_extension( "@rules_python//python/extensions:pip.bzl", @@ -18,7 +18,8 @@ dev_pip = use_extension( dev_pip.parse( hub_name = "dev_pip", python_version = "3.11", - requirements_lock = "@rules_python//docs:requirements.txt", + requirements_lock = "//dev:requirements.txt", + uv_lock = "//dev:uv.lock", ) use_repo(dev_pip, "dev_pip") diff --git a/sphinxdocs/dev/BUILD.bazel b/sphinxdocs/dev/BUILD.bazel new file mode 100644 index 0000000000..41e88f2c59 --- /dev/null +++ b/sphinxdocs/dev/BUILD.bazel @@ -0,0 +1,30 @@ +load("@rules_python//python/uv:lock.bzl", "lock") + +package(default_visibility = ["//:__subpackages__"]) + +filegroup( + name = "distribution", + srcs = glob(["**/*"]), + visibility = ["//:__pkg__"], +) + +# Run bazel run //dev:uv_lock.update +lock( + name = "uv_lock", + srcs = ["//:pyproject.toml"], + out = "uv.lock", + python_version = "3.11", +) + +# Run bazel run //dev:requirements.update +lock( + name = "requirements", + srcs = ["//:pyproject.toml"], + out = "requirements.txt", + args = [ + "--emit-index-url", + "--universal", + "--upgrade", + ], + python_version = "3.11", +) diff --git a/sphinxdocs/dev/requirements.txt b/sphinxdocs/dev/requirements.txt new file mode 100644 index 0000000000..e24834fe1e --- /dev/null +++ b/sphinxdocs/dev/requirements.txt @@ -0,0 +1,556 @@ +# This file was autogenerated by uv via the following command: +# bazel run //dev:requirements.update +--index-url https://pypi.org/simple + +absl-py==2.3.1 ; python_full_version < '3.10' \ + --hash=sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9 \ + --hash=sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +absl-py==2.5.0 ; python_full_version >= '3.10' \ + --hash=sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f \ + --hash=sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +alabaster==0.7.16 ; python_full_version < '3.10' \ + --hash=sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65 \ + --hash=sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92 + # via sphinx +alabaster==1.0.0 ; python_full_version >= '3.10' \ + --hash=sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e \ + --hash=sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b + # via sphinx +astroid==3.3.11 \ + --hash=sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce \ + --hash=sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec + # via sphinx-autodoc2 +babel==2.18.0 \ + --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 + # via sphinx +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via requests +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 + # via requests +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via sphinx +docutils==0.21.2 ; python_full_version < '3.11' \ + --hash=sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f \ + --hash=sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2 + # via + # myst-parser + # sphinx + # sphinx-rtd-theme +docutils==0.22.4 ; python_full_version >= '3.11' \ + --hash=sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968 \ + --hash=sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de + # via + # myst-parser + # sphinx + # sphinx-rtd-theme +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via requests +imagesize==1.5.0 ; python_full_version < '3.10' \ + --hash=sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899 \ + --hash=sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f + # via sphinx +imagesize==2.0.0 ; python_full_version >= '3.10' \ + --hash=sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96 \ + --hash=sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3 + # via sphinx +importlib-metadata==8.7.1 ; python_full_version < '3.10' \ + --hash=sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb \ + --hash=sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151 + # via sphinx +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via + # myst-parser + # readthedocs-sphinx-ext + # sphinx +markdown-it-py==3.0.0 ; python_full_version < '3.11' \ + --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ + --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb + # via + # mdit-py-plugins + # myst-parser +markdown-it-py==4.2.0 ; python_full_version >= '3.11' \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via + # mdit-py-plugins + # myst-parser +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via + # rules-python-sphinxdocs-dev (dev/pyproject.toml) + # jinja2 +mdit-py-plugins==0.4.2 ; python_full_version < '3.10' \ + --hash=sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636 \ + --hash=sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5 + # via myst-parser +mdit-py-plugins==0.6.1 ; python_full_version >= '3.10' \ + --hash=sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d \ + --hash=sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0 + # via myst-parser +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +myst-parser==3.0.1 ; python_full_version < '3.10' \ + --hash=sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1 \ + --hash=sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87 + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +myst-parser==4.0.1 ; python_full_version == '3.10.*' \ + --hash=sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4 \ + --hash=sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +myst-parser==5.1.0 ; python_full_version >= '3.11' \ + --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ + --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via + # readthedocs-sphinx-ext + # sphinx +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via sphinx +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via myst-parser +readthedocs-sphinx-ext==2.2.5 \ + --hash=sha256:ee5fd5b99db9f0c180b2396cbce528aa36671951b9526bb0272dbfce5517bd27 \ + --hash=sha256:f8c56184ea011c972dd45a90122568587cc85b0127bc9cf064d17c68bc809daa + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +requests==2.32.5 ; python_full_version < '3.10' \ + --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ + --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf + # via + # readthedocs-sphinx-ext + # sphinx +requests==2.34.2 ; python_full_version >= '3.10' \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via + # readthedocs-sphinx-ext + # sphinx +roman-numerals==4.1.0 ; python_full_version >= '3.11' \ + --hash=sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2 \ + --hash=sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7 + # via sphinx +snowballstemmer==3.1.1 \ + --hash=sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752 \ + --hash=sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260 + # via sphinx +sphinx==7.4.7 ; python_full_version < '3.10' \ + --hash=sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe \ + --hash=sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239 + # via + # rules-python-sphinxdocs-dev (dev/pyproject.toml) + # myst-parser + # sphinx-reredirects + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx==8.1.3 ; python_full_version == '3.10.*' \ + --hash=sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2 \ + --hash=sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927 + # via + # rules-python-sphinxdocs-dev (dev/pyproject.toml) + # myst-parser + # sphinx-reredirects + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx==9.0.4 ; python_full_version == '3.11.*' \ + --hash=sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3 \ + --hash=sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb + # via + # rules-python-sphinxdocs-dev (dev/pyproject.toml) + # myst-parser + # sphinx-reredirects + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx==9.1.0 ; python_full_version >= '3.12' \ + --hash=sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb \ + --hash=sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978 + # via + # rules-python-sphinxdocs-dev (dev/pyproject.toml) + # myst-parser + # sphinx-reredirects + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx-autodoc2==0.5.0 \ + --hash=sha256:7d76044aa81d6af74447080182b6868c7eb066874edc835e8ddf810735b6565a \ + --hash=sha256:e867013b1512f9d6d7e6f6799f8b537d6884462acd118ef361f3f619a60b5c9e + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +sphinx-reredirects==0.1.6 ; python_full_version < '3.11' \ + --hash=sha256:c491cba545f67be9697508727818d8626626366245ae64456fe29f37e9bbea64 \ + --hash=sha256:efd50c766fbc5bf40cd5148e10c00f2c00d143027de5c5e48beece93cc40eeea + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +sphinx-reredirects==1.1.0 ; python_full_version >= '3.11' \ + --hash=sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9 \ + --hash=sha256:fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29 + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +sphinx-rtd-theme==3.1.0 \ + --hash=sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89 \ + --hash=sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c + # via rules-python-sphinxdocs-dev (dev/pyproject.toml) +sphinxcontrib-applehelp==2.0.0 \ + --hash=sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1 \ + --hash=sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5 + # via sphinx +sphinxcontrib-devhelp==2.0.0 \ + --hash=sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad \ + --hash=sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 \ + --hash=sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8 \ + --hash=sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9 + # via sphinx +sphinxcontrib-jquery==4.1 \ + --hash=sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a \ + --hash=sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 \ + --hash=sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 \ + --hash=sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8 + # via sphinx +sphinxcontrib-qthelp==2.0.0 \ + --hash=sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab \ + --hash=sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 \ + --hash=sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331 \ + --hash=sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d + # via sphinx +tomli==2.4.1 ; python_full_version < '3.11' \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via + # sphinx + # sphinx-autodoc2 +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # rules-python-sphinxdocs-dev (dev/pyproject.toml) + # astroid + # sphinx-autodoc2 +urllib3==2.6.3 ; python_full_version < '3.10' \ + --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ + --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 + # via requests +urllib3==2.7.0 ; python_full_version >= '3.10' \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via requests +zipp==3.23.1 ; python_full_version < '3.10' \ + --hash=sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc \ + --hash=sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110 + # via importlib-metadata diff --git a/sphinxdocs/dev/uv.lock b/sphinxdocs/dev/uv.lock new file mode 100644 index 0000000000..39ce81556d --- /dev/null +++ b/sphinxdocs/dev/uv.lock @@ -0,0 +1,1073 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "absl-py" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, +] + +[[package]] +name = "absl-py" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/4f/d79676ab82f2e42fc3611618139f13a9c4c31d0cff4b486982047679a802/absl_py-2.5.0.tar.gz", hash = "sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f", size = 118119, upload-time = "2026-07-03T10:57:48.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410, upload-time = "2026-07-03T10:57:46.735Z" }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "astroid" +version = "3.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", size = 306122, upload-time = "2026-07-07T14:34:36.607Z" }, + { url = "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", size = 206284, upload-time = "2026-07-07T14:34:38.166Z" }, + { url = "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", size = 226837, upload-time = "2026-07-07T14:34:39.77Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", size = 222199, upload-time = "2026-07-07T14:34:41.391Z" }, + { url = "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", size = 214344, upload-time = "2026-07-07T14:34:42.986Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", size = 199988, upload-time = "2026-07-07T14:34:44.685Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", size = 211908, upload-time = "2026-07-07T14:34:46.227Z" }, + { url = "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", size = 209320, upload-time = "2026-07-07T14:34:47.753Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", size = 200980, upload-time = "2026-07-07T14:34:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", size = 216545, upload-time = "2026-07-07T14:34:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", size = 146256, upload-time = "2026-07-07T14:34:52.509Z" }, + { url = "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", size = 156413, upload-time = "2026-07-07T14:34:54.117Z" }, + { url = "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", size = 147887, upload-time = "2026-07-07T14:34:55.51Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/59/4b0dd64676aa6fb4986a755790cb6fc558559cf0084effad516820208ec3/imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f", size = 1281127, upload-time = "2026-03-03T01:59:54.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/b1/a0662b03103c66cf77101a187f396ea91167cd9b7d5d3a2e465ad2c7ee9b/imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899", size = 5763, upload-time = "2026-03-03T01:59:52.343Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "myst-parser" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pyyaml", marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "readthedocs-sphinx-ext" +version = "2.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "packaging" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/ce/38130d8dec600bf5413eb89a3413dd38f204c7c728c4947e12ff8cb793b7/readthedocs-sphinx-ext-2.2.5.tar.gz", hash = "sha256:ee5fd5b99db9f0c180b2396cbce528aa36671951b9526bb0272dbfce5517bd27", size = 12303, upload-time = "2023-12-19T10:00:49.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/71/c89e7709a0d4f93af1848e9855112299a820b470d84f917b4dd5998bdd07/readthedocs_sphinx_ext-2.2.5-py2.py3-none-any.whl", hash = "sha256:f8c56184ea011c972dd45a90122568587cc85b0127bc9cf064d17c68bc809daa", size = 11332, upload-time = "2023-12-19T10:00:43.972Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "rules-python-sphinxdocs-dev" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "absl-py", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "absl-py", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markupsafe" }, + { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "readthedocs-sphinx-ext" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc2" }, + { name = "sphinx-reredirects", version = "0.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-reredirects", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-rtd-theme" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "absl-py" }, + { name = "markupsafe" }, + { name = "myst-parser" }, + { name = "readthedocs-sphinx-ext" }, + { name = "sphinx" }, + { name = "sphinx-autodoc2" }, + { name = "sphinx-reredirects" }, + { name = "sphinx-rtd-theme", specifier = ">=2.0" }, + { name = "typing-extensions" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "babel", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-autodoc2" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/5f/5350046d1aa1a56b063ae08b9ad871025335c9d55fe2372896ea48711da9/sphinx_autodoc2-0.5.0.tar.gz", hash = "sha256:7d76044aa81d6af74447080182b6868c7eb066874edc835e8ddf810735b6565a", size = 115077, upload-time = "2023-11-27T07:27:51.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/e6/48d47961bbdae755ba9c17dfc65d89356312c67668dcb36c87cfadfa1964/sphinx_autodoc2-0.5.0-py3-none-any.whl", hash = "sha256:e867013b1512f9d6d7e6f6799f8b537d6884462acd118ef361f3f619a60b5c9e", size = 43385, upload-time = "2023-11-27T07:27:49.929Z" }, +] + +[[package]] +name = "sphinx-reredirects" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/6b/bcca2785de4071f604a722444d4d7ba8a9d40de3c14ad52fce93e6d92694/sphinx_reredirects-0.1.6.tar.gz", hash = "sha256:c491cba545f67be9697508727818d8626626366245ae64456fe29f37e9bbea64", size = 7080, upload-time = "2025-03-22T10:52:30.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/6f/0b3625be30a1a50f9e4c2cb2ec147b08f15ed0e9f8444efcf274b751300b/sphinx_reredirects-0.1.6-py3-none-any.whl", hash = "sha256:efd50c766fbc5bf40cd5148e10c00f2c00d143027de5c5e48beece93cc40eeea", size = 5675, upload-time = "2025-03-22T10:52:29.113Z" }, +] + +[[package]] +name = "sphinx-reredirects" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/8d/0e39fe2740d7d71417edf9a6424aa80ca2c27c17fc21282cdc39f90d5a40/sphinx_reredirects-1.1.0.tar.gz", hash = "sha256:fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29", size = 614624, upload-time = "2025-12-22T08:28:02.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/81/b5dd07067f3daac6d23687ec737b2d593740671ebcd145830c8f92d381c5/sphinx_reredirects-1.1.0-py3-none-any.whl", hash = "sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9", size = 6351, upload-time = "2025-12-22T08:27:59.724Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] diff --git a/sphinxdocs/integration_tests/bcr/MODULE.bazel b/sphinxdocs/integration_tests/bcr/MODULE.bazel index ae1c752513..451f2e920d 100644 --- a/sphinxdocs/integration_tests/bcr/MODULE.bazel +++ b/sphinxdocs/integration_tests/bcr/MODULE.bazel @@ -19,7 +19,7 @@ dev_pip = use_extension( dev_pip.parse( hub_name = "dev_pip", python_version = "3.11", - requirements_lock = "@rules_python//docs:requirements.txt", + requirements_lock = "@sphinxdocs//dev:requirements.txt", ) use_repo(dev_pip, "dev_pip") diff --git a/sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel b/sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel index 4e2c25e8e4..976718f312 100644 --- a/sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel +++ b/sphinxdocs/integration_tests/persistent_worker/workspace/MODULE.bazel @@ -16,7 +16,7 @@ dev_pip = use_extension( dev_pip.parse( hub_name = "dev_pip", python_version = "3.11", - requirements_lock = "@rules_python//docs:requirements.txt", + requirements_lock = "@sphinxdocs//dev:requirements.txt", ) use_repo(dev_pip, "dev_pip") diff --git a/sphinxdocs/pyproject.toml b/sphinxdocs/pyproject.toml new file mode 100644 index 0000000000..89330debc6 --- /dev/null +++ b/sphinxdocs/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "sphinxdocs" +version = "0.0.0" + +dependencies = [ + "absl-py", + "markupsafe", + "myst-parser", + "readthedocs-sphinx-ext", + "sphinx", + "sphinx-autodoc2", + "sphinx-reredirects", + "sphinx_rtd_theme >=2.0", # uv insists on downgrading for some reason + "typing-extensions", +] From 109866a0f06ac93b48c93b40e68e7e6199aefcc0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 26 Jul 2026 20:44:51 -0700 Subject: [PATCH 106/179] agents: emphasize trailing ampersand in CI monitor skill invocation (#3956) When launching `monitor_remote_ci.py` via agent tool calls without a trailing `&`, the script remains attached as a child process wrapper and blocks foreground execution. To fix, update `SKILL.md` with an explicit note emphasizing that tool invocations must always include the trailing `&` to ensure the monitoring script runs as a detached background task. --- .agents/skills/monitor-ci-results/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/skills/monitor-ci-results/SKILL.md b/.agents/skills/monitor-ci-results/SKILL.md index 8f6429d7ce..c5f8de309f 100644 --- a/.agents/skills/monitor-ci-results/SKILL.md +++ b/.agents/skills/monitor-ci-results/SKILL.md @@ -18,3 +18,4 @@ When any CI job completes with errors or returns a non-zero exit code: ```bash ./scripts/monitor_remote_ci.py 3812 "0be435bd-96aa-4e1b-9c6f-727b31e80fa0" & ``` +*Note: Always include the trailing `&` when launching the monitoring script via tool calls to ensure it runs as a detached background task without blocking foreground execution.* From 28d9fca949d0df2f3f4856eecef0d50eaafe79a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:39:04 +0000 Subject: [PATCH 107/179] build(deps): bump charset-normalizer from 3.4.3 to 3.4.9 in /tools/publish (#3928) Bumps [charset-normalizer](https://github.com/jawah/charset_normalizer) from 3.4.3 to 3.4.9.
Release notes

Sourced from charset-normalizer's releases.

Version 3.4.9

3.4.9 (2026-07-07)

Fixed

  • Regression in our fallback path leading to a decode error. (#771) We've yanked 3.4.8 as a result of that bug.

Version 3.4.8

3.4.8 (2026-07-06)

Fixed

  • Wall import time due to cascade codec imports for our multibyte first sort of iana supported codecs (#742)
  • Unnecessary json import at runtime (#753)
  • Inverse capitalization not seen by noise detector (#731)

Changed

  • No longer holding a global cache for our noise / coherence measurements. Relax RSS memory usage.
  • Micro-optimizations in our noise / coherence measurements.
  • No longer using regex search by default for our preemptive charset mark algorithm.
  • Raised upperbound of setuptools to v83.
  • Raised upperbound of mypy(c) to v2.1.

Removed

  • Redundant UTF7 BOM marker (#730)

Version 3.4.7

3.4.7 (2026-04-02)

Changed

  • Pre-built optimized version using mypy[c] v1.20.
  • Relax setuptools constraint to setuptools>=68,<82.1.

Fixed

  • Correctly remove SIG remnant in utf-7 decoded string. (#718) (#716)

Version 3.4.6

3.4.6 (2026-03-15)

Changed

  • Flattened the logic in charset_normalizer.md for higher performance. Removed eligible(..) and feed(...) in favor of feed_info(...).
  • Raised upper bound for mypy[c] to 1.20, for our optimized version.
  • Updated UNICODE_RANGES_COMBINED using Unicode blocks v17.

Fixed

  • Edge case where noise difference between two candidates can be almost insignificant. (#672)
  • CLI --normalize writing to wrong path when passing multiple files in. (#702)

Misc

  • Freethreaded pre-built wheels now shipped in PyPI starting with 3.14t. (#616)

... (truncated)

Changelog

Sourced from charset-normalizer's changelog.

3.4.9 (2026-07-07)

Fixed

  • Regression in our fallback path leading to a decode error. (#771) We've yanked 3.4.8 as a result of that bug.

3.4.8 (2026-07-06)

Fixed

  • Wall import time due to cascade codec imports for our multibyte first sort of iana supported codecs (#742)
  • Unnecessary json import at runtime (#753)
  • Inverse capitalization not seen by noise detector (#731)

Changed

  • No longer holding a global cache for our noise / coherence measurements. Relax RSS memory usage.
  • Micro-optimizations in our noise / coherence measurements.
  • No longer using regex search by default for our preemptive charset mark algorithm.
  • Raised upperbound of setuptools to v83.
  • Raised upperbound of mypy(c) to v2.1.

Removed

  • Redundant UTF7 BOM marker (#730)

3.4.7 (2026-04-02)

Changed

  • Pre-built optimized version using mypy[c] v1.20.
  • Relax setuptools constraint to setuptools>=68,<82.1.

Fixed

  • Correctly remove SIG remnant in utf-7 decoded string. (#718) (#716)

3.4.6 (2026-03-15)

Changed

  • Flattened the logic in charset_normalizer.md for higher performance. Removed eligible(..) and feed(...) in favor of feed_info(...).
  • Raised upper bound for mypy[c] to 1.20, for our optimized version.
  • Updated UNICODE_RANGES_COMBINED using Unicode blocks v17.

Fixed

  • Edge case where noise difference between two candidates can be almost insignificant. (#672)
  • CLI --normalize writing to wrong path when passing multiple files in. (#702)

Misc

  • Freethreaded pre-built wheels now shipped in PyPI starting with 3.14t. (#616)

3.4.5 (2026-03-06)

Changed

... (truncated)

Commits
  • cc68407 Merge pull request #772 from jawah/fix-regression-fallback-path
  • 152b923 chore: release 3.4.9
  • 2bc2607 fix: unicodedecodeerror in fallback path
  • be252d7 chore(deps): bump docker/setup-qemu-action from 4.1.0 to 4.2.0 (#767)
  • 71c7bdd chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#768)
  • aeea391 chore(deps): bump pypa/cibuildwheel from 3.4.1 to 4.1.0 (#758)
  • a6f8feb Merge pull request #770 from jawah/unblock-ci
  • 528e16c chore: add osv-scanner.toml
  • 5993498 chore: ast_serialize musl missing prebuilt riscv,s390x,ppc64le
  • aa2ddd8 Release 3.4.8 (#766)
  • Additional commits viewable in compare view

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Richard Levasseur --- .../bzlmod_lockfile/MODULE.bazel.lock | 172 +++++++++-------- tools/publish/requirements_darwin.txt | 174 ++++++++++-------- tools/publish/requirements_linux.txt | 174 ++++++++++-------- tools/publish/requirements_universal.txt | 174 ++++++++++-------- tools/publish/requirements_windows.txt | 174 ++++++++++-------- 5 files changed, 469 insertions(+), 399 deletions(-) diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index fd161a3965..92d1f6be7b 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -380,85 +380,99 @@ "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, "charset-normalizer": { - "https://files.pythonhosted.org/packages/00/bd/ef9c88464b126fa176f4ef4a317ad9b6f4d30b2cffbc43386062367c3e2c/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", - "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl": "ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", - "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", - "https://files.pythonhosted.org/packages/05/35/bb59b1cd012d7196fc81c2f5879113971efc226a63812c9cf7f89fe97c40/charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl": "5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", - "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl": "42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", - "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl": "30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", - "https://files.pythonhosted.org/packages/0c/52/8b0c6c3e53f7e546a5e49b9edb876f379725914e1130297f3b423c7b71c5/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", - "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", - "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", - "https://files.pythonhosted.org/packages/1a/79/ae516e678d6e32df2e7e740a7be51dc80b700e2697cb70054a0f1ac2c955/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", - "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl": "2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", - "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", - "https://files.pythonhosted.org/packages/22/82/63a45bfc36f73efe46731a3a71cb84e2112f7e0b049507025ce477f0f052/charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl": "0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", - "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl": "d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", - "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl": "1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", - "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl": "cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", - "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl": "78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", - "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", - "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", - "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl": "86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", - "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", - "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl": "4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", - "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl": "88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", - "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl": "939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", - "https://files.pythonhosted.org/packages/50/10/c117806094d2c956ba88958dab680574019abc0c02bcf57b32287afca544/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl": "a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", - "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl": "fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", - "https://files.pythonhosted.org/packages/59/c0/a74f3bd167d311365e7973990243f32c35e7a94e45103125275b9e6c479f/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", - "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", - "https://files.pythonhosted.org/packages/61/c5/dc3ba772489c453621ffc27e8978a98fe7e41a93e787e5e5bde797f1dddb/charset_normalizer-3.4.3-cp38-cp38-win32.whl": "ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", - "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl": "96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", - "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl": "16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", - "https://files.pythonhosted.org/packages/64/d1/f9d141c893ef5d4243bc75c130e95af8fd4bc355beff06e9b1e941daad6e/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl": "5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", - "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl": "6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", - "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", - "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl": "14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", - "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl": "18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", - "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", - "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl": "d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", - "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl": "fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", - "https://files.pythonhosted.org/packages/7a/03/cbb6fac9d3e57f7e07ce062712ee80d80a5ab46614684078461917426279/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl": "d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", - "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", - "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl": "53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", - "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", - "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl": "b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", - "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", - "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz": "6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", - "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", - "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", - "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", - "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl": "ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", - "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl": "3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", - "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl": "fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", - "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl": "cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", - "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", - "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl": "6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", - "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl": "02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", - "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl": "027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", - "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", - "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl": "c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", - "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl": "320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", - "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl": "6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", - "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl": "70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", - "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl": "1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", - "https://files.pythonhosted.org/packages/c5/35/9c99739250742375167bc1b1319cd1cec2bf67438a70d84b2e1ec4c9daa3/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl": "b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", - "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", - "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl": "d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", - "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", - "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl": "fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", - "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl": "511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", - "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl": "c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", - "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", - "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", - "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl": "e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", - "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl": "73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", - "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl": "c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", - "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", - "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl": "31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", - "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl": "bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", - "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018" + "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl": "609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", + "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", + "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl": "375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", + "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl": "0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", + "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", + "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl": "65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", + "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl": "78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", + "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", + "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", + "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl": "93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", + "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl": "f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", + "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl": "e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", + "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl": "40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", + "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl": "476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", + "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", + "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl": "60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", + "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl": "bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", + "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl": "8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", + "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", + "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl": "19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", + "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", + "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", + "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl": "a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", + "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl": "c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", + "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl": "416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", + "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", + "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl": "16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", + "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", + "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", + "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl": "1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", + "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", + "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl": "f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", + "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", + "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl": "ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", + "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl": "79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", + "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", + "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl": "69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", + "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", + "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl": "611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", + "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl": "45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", + "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl": "cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", + "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl": "0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", + "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", + "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", + "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl": "9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", + "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl": "4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", + "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl": "ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", + "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl": "03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", + "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl": "75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", + "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl": "90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", + "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", + "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", + "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl": "68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", + "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl": "c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", + "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl": "a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", + "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl": "ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", + "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", + "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl": "3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", + "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl": "253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", + "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl": "cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", + "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl": "280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", + "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl": "5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", + "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl": "0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", + "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl": "440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", + "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", + "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz": "673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", + "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", + "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl": "432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", + "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", + "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", + "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", + "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", + "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl": "51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", + "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", + "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", + "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl": "67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", + "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", + "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl": "83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", + "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", + "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl": "fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", + "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", + "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl": "43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", + "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", + "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl": "231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", + "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl": "0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", + "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl": "6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", + "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", + "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl": "78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", + "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", + "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl": "d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", + "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", + "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl": "898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", + "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl": "ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" }, "cryptography": { "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", diff --git a/tools/publish/requirements_darwin.txt b/tools/publish/requirements_darwin.txt index 6d8de8b4c9..dd292fbaee 100644 --- a/tools/publish/requirements_darwin.txt +++ b/tools/publish/requirements_darwin.txt @@ -10,86 +10,100 @@ certifi==2025.10.5 \ --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 # via requests -charset-normalizer==3.4.3 \ - --hash=sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91 \ - --hash=sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0 \ - --hash=sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154 \ - --hash=sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601 \ - --hash=sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884 \ - --hash=sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07 \ - --hash=sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c \ - --hash=sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64 \ - --hash=sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe \ - --hash=sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f \ - --hash=sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432 \ - --hash=sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc \ - --hash=sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa \ - --hash=sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9 \ - --hash=sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae \ - --hash=sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19 \ - --hash=sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d \ - --hash=sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e \ - --hash=sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4 \ - --hash=sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7 \ - --hash=sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312 \ - --hash=sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92 \ - --hash=sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31 \ - --hash=sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c \ - --hash=sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f \ - --hash=sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99 \ - --hash=sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b \ - --hash=sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15 \ - --hash=sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392 \ - --hash=sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f \ - --hash=sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8 \ - --hash=sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491 \ - --hash=sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0 \ - --hash=sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc \ - --hash=sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0 \ - --hash=sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f \ - --hash=sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a \ - --hash=sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40 \ - --hash=sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927 \ - --hash=sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849 \ - --hash=sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce \ - --hash=sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14 \ - --hash=sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05 \ - --hash=sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c \ - --hash=sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c \ - --hash=sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a \ - --hash=sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc \ - --hash=sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34 \ - --hash=sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9 \ - --hash=sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096 \ - --hash=sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14 \ - --hash=sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30 \ - --hash=sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b \ - --hash=sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b \ - --hash=sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942 \ - --hash=sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db \ - --hash=sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5 \ - --hash=sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b \ - --hash=sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce \ - --hash=sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669 \ - --hash=sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0 \ - --hash=sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018 \ - --hash=sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93 \ - --hash=sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe \ - --hash=sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049 \ - --hash=sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a \ - --hash=sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef \ - --hash=sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2 \ - --hash=sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca \ - --hash=sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16 \ - --hash=sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f \ - --hash=sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb \ - --hash=sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1 \ - --hash=sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557 \ - --hash=sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37 \ - --hash=sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7 \ - --hash=sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72 \ - --hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \ - --hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9 +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests docutils==0.22.2 \ --hash=sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d \ diff --git a/tools/publish/requirements_linux.txt b/tools/publish/requirements_linux.txt index 8865eb3ecb..cfba2aceea 100644 --- a/tools/publish/requirements_linux.txt +++ b/tools/publish/requirements_linux.txt @@ -96,86 +96,100 @@ cffi==2.0.0 \ --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf # via cryptography -charset-normalizer==3.4.3 \ - --hash=sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91 \ - --hash=sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0 \ - --hash=sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154 \ - --hash=sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601 \ - --hash=sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884 \ - --hash=sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07 \ - --hash=sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c \ - --hash=sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64 \ - --hash=sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe \ - --hash=sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f \ - --hash=sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432 \ - --hash=sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc \ - --hash=sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa \ - --hash=sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9 \ - --hash=sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae \ - --hash=sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19 \ - --hash=sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d \ - --hash=sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e \ - --hash=sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4 \ - --hash=sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7 \ - --hash=sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312 \ - --hash=sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92 \ - --hash=sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31 \ - --hash=sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c \ - --hash=sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f \ - --hash=sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99 \ - --hash=sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b \ - --hash=sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15 \ - --hash=sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392 \ - --hash=sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f \ - --hash=sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8 \ - --hash=sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491 \ - --hash=sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0 \ - --hash=sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc \ - --hash=sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0 \ - --hash=sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f \ - --hash=sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a \ - --hash=sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40 \ - --hash=sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927 \ - --hash=sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849 \ - --hash=sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce \ - --hash=sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14 \ - --hash=sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05 \ - --hash=sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c \ - --hash=sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c \ - --hash=sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a \ - --hash=sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc \ - --hash=sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34 \ - --hash=sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9 \ - --hash=sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096 \ - --hash=sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14 \ - --hash=sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30 \ - --hash=sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b \ - --hash=sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b \ - --hash=sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942 \ - --hash=sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db \ - --hash=sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5 \ - --hash=sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b \ - --hash=sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce \ - --hash=sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669 \ - --hash=sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0 \ - --hash=sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018 \ - --hash=sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93 \ - --hash=sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe \ - --hash=sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049 \ - --hash=sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a \ - --hash=sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef \ - --hash=sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2 \ - --hash=sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca \ - --hash=sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16 \ - --hash=sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f \ - --hash=sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb \ - --hash=sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1 \ - --hash=sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557 \ - --hash=sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37 \ - --hash=sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7 \ - --hash=sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72 \ - --hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \ - --hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9 +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests cryptography==46.0.7 \ --hash=sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65 \ diff --git a/tools/publish/requirements_universal.txt b/tools/publish/requirements_universal.txt index 4dbcc92abc..4ab1911981 100644 --- a/tools/publish/requirements_universal.txt +++ b/tools/publish/requirements_universal.txt @@ -79,86 +79,100 @@ cffi==2.0.0 ; platform_python_implementation != 'PyPy' and sys_platform == 'linu --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b # via cryptography -charset-normalizer==3.4.3 \ - --hash=sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91 \ - --hash=sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0 \ - --hash=sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154 \ - --hash=sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601 \ - --hash=sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884 \ - --hash=sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07 \ - --hash=sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c \ - --hash=sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64 \ - --hash=sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe \ - --hash=sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f \ - --hash=sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432 \ - --hash=sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc \ - --hash=sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa \ - --hash=sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9 \ - --hash=sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae \ - --hash=sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19 \ - --hash=sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d \ - --hash=sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e \ - --hash=sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4 \ - --hash=sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7 \ - --hash=sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312 \ - --hash=sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92 \ - --hash=sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31 \ - --hash=sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c \ - --hash=sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f \ - --hash=sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99 \ - --hash=sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b \ - --hash=sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15 \ - --hash=sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392 \ - --hash=sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f \ - --hash=sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8 \ - --hash=sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491 \ - --hash=sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0 \ - --hash=sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc \ - --hash=sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0 \ - --hash=sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f \ - --hash=sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a \ - --hash=sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40 \ - --hash=sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927 \ - --hash=sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849 \ - --hash=sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce \ - --hash=sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14 \ - --hash=sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05 \ - --hash=sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c \ - --hash=sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c \ - --hash=sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a \ - --hash=sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc \ - --hash=sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34 \ - --hash=sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9 \ - --hash=sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096 \ - --hash=sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14 \ - --hash=sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30 \ - --hash=sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b \ - --hash=sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b \ - --hash=sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942 \ - --hash=sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db \ - --hash=sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5 \ - --hash=sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b \ - --hash=sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce \ - --hash=sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669 \ - --hash=sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0 \ - --hash=sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018 \ - --hash=sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93 \ - --hash=sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe \ - --hash=sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049 \ - --hash=sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a \ - --hash=sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef \ - --hash=sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2 \ - --hash=sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca \ - --hash=sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16 \ - --hash=sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f \ - --hash=sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb \ - --hash=sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1 \ - --hash=sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557 \ - --hash=sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37 \ - --hash=sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7 \ - --hash=sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72 \ - --hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \ - --hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9 +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests cryptography==46.0.7 ; sys_platform == 'linux' \ --hash=sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65 \ diff --git a/tools/publish/requirements_windows.txt b/tools/publish/requirements_windows.txt index 029c6bef4d..1cbd8197fe 100644 --- a/tools/publish/requirements_windows.txt +++ b/tools/publish/requirements_windows.txt @@ -10,86 +10,100 @@ certifi==2025.10.5 \ --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 # via requests -charset-normalizer==3.4.3 \ - --hash=sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91 \ - --hash=sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0 \ - --hash=sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154 \ - --hash=sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601 \ - --hash=sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884 \ - --hash=sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07 \ - --hash=sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c \ - --hash=sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64 \ - --hash=sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe \ - --hash=sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f \ - --hash=sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432 \ - --hash=sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc \ - --hash=sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa \ - --hash=sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9 \ - --hash=sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae \ - --hash=sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19 \ - --hash=sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d \ - --hash=sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e \ - --hash=sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4 \ - --hash=sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7 \ - --hash=sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312 \ - --hash=sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92 \ - --hash=sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31 \ - --hash=sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c \ - --hash=sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f \ - --hash=sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99 \ - --hash=sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b \ - --hash=sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15 \ - --hash=sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392 \ - --hash=sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f \ - --hash=sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8 \ - --hash=sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491 \ - --hash=sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0 \ - --hash=sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc \ - --hash=sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0 \ - --hash=sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f \ - --hash=sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a \ - --hash=sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40 \ - --hash=sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927 \ - --hash=sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849 \ - --hash=sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce \ - --hash=sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14 \ - --hash=sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05 \ - --hash=sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c \ - --hash=sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c \ - --hash=sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a \ - --hash=sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc \ - --hash=sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34 \ - --hash=sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9 \ - --hash=sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096 \ - --hash=sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14 \ - --hash=sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30 \ - --hash=sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b \ - --hash=sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b \ - --hash=sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942 \ - --hash=sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db \ - --hash=sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5 \ - --hash=sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b \ - --hash=sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce \ - --hash=sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669 \ - --hash=sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0 \ - --hash=sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018 \ - --hash=sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93 \ - --hash=sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe \ - --hash=sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049 \ - --hash=sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a \ - --hash=sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef \ - --hash=sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2 \ - --hash=sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca \ - --hash=sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16 \ - --hash=sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f \ - --hash=sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb \ - --hash=sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1 \ - --hash=sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557 \ - --hash=sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37 \ - --hash=sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7 \ - --hash=sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72 \ - --hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \ - --hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9 +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests docutils==0.22.2 \ --hash=sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d \ From 989776774f1ddcd200017ac455e79c0e8b996c85 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 26 Jul 2026 21:31:16 -0700 Subject: [PATCH 108/179] fix(pip.parse): allow uv_lock to be set without requirements_lock (#3952) Currently, calling `pip.parse(uv_lock = ...)` without setting `requirements_lock` causes repository generation to fail during platform lockfile validation because mandatory requirement files are enforced. This prevents using `uv_lock` as a standalone lockfile in `pip.parse`. To fix, accept `uv_lock` in platform requirement validation and exempt callers from mandatory requirement file checks when a `uv_lock` is present. Related #1975 Related #2787 --- news/3952.fixed.md | 3 +++ python/private/pypi/hub_builder.bzl | 1 + .../pypi/requirements_files_by_platform.bzl | 16 ++++++++---- tests/pypi/hub_builder/hub_builder_tests.bzl | 25 +++++++++++++++++++ .../requirements_files_by_platform_tests.bzl | 21 +++++++++++++++- 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 news/3952.fixed.md diff --git a/news/3952.fixed.md b/news/3952.fixed.md new file mode 100644 index 0000000000..70558dd986 --- /dev/null +++ b/news/3952.fixed.md @@ -0,0 +1,3 @@ +(pypi) Allow `uv_lock` to be specified in `pip.parse` without requiring +`requirements_lock` (or other os-specific requirement file attributes) to be +set. diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl index 01dc8494d6..d7974d39ad 100644 --- a/python/private/pypi/hub_builder.bzl +++ b/python/private/pypi/hub_builder.bzl @@ -498,6 +498,7 @@ def _create_whl_repos( requirements_lock = pip_attr.requirements_lock, requirements_osx = pip_attr.requirements_darwin, requirements_windows = pip_attr.requirements_windows, + uv_lock = pip_attr.uv_lock, extra_pip_args = pip_attr.extra_pip_args, platforms = sorted(platforms), # here we only need keys python_version = full_version( diff --git a/python/private/pypi/requirements_files_by_platform.bzl b/python/private/pypi/requirements_files_by_platform.bzl index dcdb6128a7..c6f53ae611 100644 --- a/python/private/pypi/requirements_files_by_platform.bzl +++ b/python/private/pypi/requirements_files_by_platform.bzl @@ -73,6 +73,7 @@ def requirements_files_by_platform( requirements_linux = None, requirements_lock = None, requirements_windows = None, + uv_lock = None, platforms, extra_pip_args = None, python_version = None, @@ -88,6 +89,7 @@ def requirements_files_by_platform( requirements_linux (label): The requirements file for the linux OS. requirements_lock (label): The requirements file for all OSes, or used as a fallback. requirements_windows (label): The requirements file for windows OS. + uv_lock (label): The uv.lock file, or used as primary source. extra_pip_args (string list): Extra pip arguments to perform extra validations and to be joined with args fined in files. python_version: str or None. This is needed when the get_index_urls is @@ -106,10 +108,11 @@ def requirements_files_by_platform( requirements_linux or requirements_osx or requirements_windows or - requirements_by_platform + requirements_by_platform or + uv_lock ): fail_fn( - "A 'requirements_lock' attribute must be specified, a platform-specific lockfiles " + + "A 'requirements_lock' or 'uv_lock' attribute must be specified, a platform-specific lockfiles " + "via 'requirements_by_platform' or an os-specific lockfiles must be specified " + "via 'requirements_*' attributes", ) @@ -143,9 +146,12 @@ def requirements_files_by_platform( fail_fn("only a single 'requirements_lock' file can be used when using '--platform' pip argument, consider specifying it via 'requirements_lock' attribute") return None - files_by_platform = [ - (lock_files[0], platforms_from_args), - ] + if not lock_files: + files_by_platform = [] + else: + files_by_platform = [ + (lock_files[0], platforms_from_args), + ] if logger: logger.debug(lambda: "Files by platform with the platform set in the args: {}".format(files_by_platform)) else: diff --git a/tests/pypi/hub_builder/hub_builder_tests.bzl b/tests/pypi/hub_builder/hub_builder_tests.bzl index 60017593fb..4651342dd4 100644 --- a/tests/pypi/hub_builder/hub_builder_tests.bzl +++ b/tests/pypi/hub_builder/hub_builder_tests.bzl @@ -79,6 +79,7 @@ def hub_builder( }, netrc = None, auth_patterns = None, + toml_decode = json.decode, ), whl_overrides = whl_overrides, minor_mapping = minor_mapping or {"3.15": "3.15.19"}, @@ -151,6 +152,30 @@ def _test_simple(env): _tests.append(_test_simple) +def _test_uv_lock_only(env): + builder = hub_builder(env) + builder.pip_parse( + _mock_mctx( + os_name = "osx", + arch_name = "aarch64", + mock_files = { + "uv.lock": """{"package":[{"name":"simple","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/simple-0.0.1-py3-none-any.whl"}]}]}""", + }, + ), + _parse( + hub_name = "pypi", + python_version = "3.15", + requirements_lock = None, + uv_lock = "uv.lock", + ), + ) + pypi = builder.build() + + pypi.exposed_packages().contains_exactly(["simple"]) + pypi.group_map().contains_exactly({}) + +_tests.append(_test_uv_lock_only) + def _test_simple_multiple_requirements(env): sub_tests = { ("osx", "aarch64"): "simple==0.0.2 --hash=sha256:deadb00f", diff --git a/tests/pypi/requirements_files_by_platform/requirements_files_by_platform_tests.bzl b/tests/pypi/requirements_files_by_platform/requirements_files_by_platform_tests.bzl index b1176e6a15..7f4260081b 100644 --- a/tests/pypi/requirements_files_by_platform/requirements_files_by_platform_tests.bzl +++ b/tests/pypi/requirements_files_by_platform/requirements_files_by_platform_tests.bzl @@ -43,7 +43,7 @@ def _test_fail_no_requirements(env): fail_fn = errors.append, ) env.expect.that_str(errors[0]).equals("""\ -A 'requirements_lock' attribute must be specified, a platform-specific lockfiles via 'requirements_by_platform' or an os-specific lockfiles must be specified via 'requirements_*' attributes""") +A 'requirements_lock' or 'uv_lock' attribute must be specified, a platform-specific lockfiles via 'requirements_by_platform' or an os-specific lockfiles must be specified via 'requirements_*' attributes""") _tests.append(_test_fail_no_requirements) @@ -319,6 +319,25 @@ def _test_host_only_os_with_fallback(env): _tests.append(_test_host_only_os_with_fallback) +def _test_uv_lock_only(env): + """Verify that using only uv_lock without requirements_lock succeeds.""" + got = requirements_files_by_platform( + uv_lock = "uv.lock", + ) + env.expect.that_dict(got).contains_exactly({}) + +_tests.append(_test_uv_lock_only) + +def _test_uv_lock_with_platform_arg(env): + """Verify that using uv_lock with --platform argument succeeds.""" + got = requirements_files_by_platform( + uv_lock = "uv.lock", + extra_pip_args = ["--platform", "linux_x86_64"], + ) + env.expect.that_dict(got).contains_exactly({}) + +_tests.append(_test_uv_lock_with_platform_arg) + def requirements_files_by_platform_test_suite(name): """Create the test suite. From e3211ac0f77e68cbec4cb9a9dcb1dbda228c1056 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 26 Jul 2026 23:39:46 -0700 Subject: [PATCH 109/179] agents: add rule for PR descriptions and news entries (#3959) Currently, AI agents may draft pull request descriptions or news entries without consulting repository style guides, leading to format regressions such as verbose diff lists or markdown section headers. To prevent this, add an agent rule that mandates reading and adhering to `CONTRIBUTING.md` before drafting pull request descriptions or news entries. --- .agents/rules/pr_and_news.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .agents/rules/pr_and_news.md diff --git a/.agents/rules/pr_and_news.md b/.agents/rules/pr_and_news.md new file mode 100644 index 0000000000..0eb7726efc --- /dev/null +++ b/.agents/rules/pr_and_news.md @@ -0,0 +1,12 @@ +--- +trigger: model_decision +description: Apply when drafting pull request descriptions or news entries. +--- + +# PR Descriptions and News Entries + +Before drafting any pull request description or news entry, you MUST read +`CONTRIBUTING.md` (specifically the sections on **Commit messages and PR +descriptions** and **Documenting changes**) and strictly adhere to its style, +formatting, and structure rules. Do not generate PR descriptions or news +entries without consulting `CONTRIBUTING.md` first. From c1b85e92fd0762b60e816d3b71a1482afa98e612 Mon Sep 17 00:00:00 2001 From: Joshua Yanchar Date: Sun, 26 Jul 2026 23:27:20 -0700 Subject: [PATCH 110/179] fix(coverage): warn about a missing coverage wheel only for the selected runtime (#3955) Previously, configuring coverage checked every registered platform in `PLATFORMS` for a bundled `coverage.py` wheel and warned once per platform lacking one, even for platforms never used by the build and when coverage was not being collected. This change moves the warning to `py_runtime` during analysis so that: - It is only checked for the runtime actually selected by toolchain resolution. - It is only emitted when coverage is enabled (`ctx.configuration.coverage_enabled`). As a result, `coverage_dep` now silently returns `None` when no wheel is found, and `module_ctx` is no longer needed in `python_register_toolchains` for logging. Fixes #3950. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Richard Levasseur --- docs/coverage.md | 13 +++-- news/3950.fixed.md | 3 ++ python/private/BUILD.bazel | 2 - python/private/coverage_deps.bzl | 28 +++-------- python/private/py_runtime_rule.bzl | 48 ++++++++++++++++++ python/private/python.bzl | 1 - python/private/python_register_toolchains.bzl | 15 ------ tests/coverage_deps/coverage_deps_test.bzl | 49 ++++--------------- tests/py_runtime/py_runtime_tests.bzl | 41 ++++++++++++++++ 9 files changed, 119 insertions(+), 81 deletions(-) create mode 100644 news/3950.fixed.md diff --git a/docs/coverage.md b/docs/coverage.md index 41c95a1b75..8c162d86fd 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -39,9 +39,16 @@ need to manually configure coverage (see below). :::{note} The bundled `coverage` wheel set covers CPython 3.9 through 3.14 (with -freethreaded variants for 3.13+). For Python versions outside that range, -`configure_coverage_tool = True` is a silent no-op and `bazel coverage` will -produce empty lcov data; manually configure coverage (see below) instead. +freethreaded variants for 3.13+), and not every platform within that range. +When the interpreter that `bazel coverage` actually selects has no bundled +wheel, `configure_coverage_tool = True` produces no coverage tool and +`bazel coverage` emits empty lcov data; manually configure coverage (see +below) instead. + +`py_runtime` warns when this happens. The warning is emitted during analysis, +for the runtime toolchain resolution selected and only when coverage is being +collected, so it does not fire for the many platforms toolchains are +registered for but a given build never uses. ::: ## Manually configuring coverage diff --git a/news/3950.fixed.md b/news/3950.fixed.md new file mode 100644 index 0000000000..4684beb890 --- /dev/null +++ b/news/3950.fixed.md @@ -0,0 +1,3 @@ +(coverage) The warning about a missing bundled `coverage.py` wheel is only +emitted once per affected and used toolchain when coverage is being collected +([#3950](https://github.com/bazel-contrib/rules_python/issues/3950)). diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index 17d18fef6f..a5f4d2da3e 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -358,7 +358,6 @@ bzl_library( name = "coverage_deps", srcs = ["coverage_deps.bzl"], deps = [ - ":repo_utils", ":version_label", "//python/private:bazel_tools", ], @@ -776,7 +775,6 @@ bzl_library( ":coverage_deps", ":full_version", ":python_repository", - ":repo_utils", ":toolchains_repo", "//python:versions", ], diff --git a/python/private/coverage_deps.bzl b/python/private/coverage_deps.bzl index a32b2e3f97..a3f211ba2a 100644 --- a/python/private/coverage_deps.bzl +++ b/python/private/coverage_deps.bzl @@ -17,7 +17,6 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe") -load("//python/private:repo_utils.bzl", "repo_utils") load("//python/private:version_label.bzl", "version_label") # START: maintained by 'bazel run //tools/private/update_deps:update_coverage_deps ' @@ -167,7 +166,7 @@ _coverage_deps = { _coverage_patch = Label("//python/private:coverage.patch") -def coverage_dep(name, python_version, platform, visibility, logger = None): +def coverage_dep(name, python_version, platform, visibility): """Register a single coverage dependency based on the python version and platform. Args: @@ -175,37 +174,24 @@ def coverage_dep(name, python_version, platform, visibility, logger = None): python_version: The full python version. platform: The platform, which can be found in //python:versions.bzl PLATFORMS dict. visibility: The visibility of the coverage tool. - logger: {type}`repo_utils.logger | None` Optional logger used to emit a - warning when no wheel is available for the (python_version, - platform) pair. If not supplied, a default logger is constructed. Returns: The label of the coverage tool if the platform is supported, otherwise - None. """ - if logger == None: - logger = repo_utils.logger( - struct(getenv = lambda _: None), - name = "coverage_dep", - ) - if "windows" in platform: # NOTE @aignas 2023-01-19: currently we do not support windows as the - # upstream coverage wrapper is written in shell. Do not log any warning - # for now as it is not actionable. + # upstream coverage wrapper is written in shell. return None abi = "cp" + version_label(python_version) url, sha256 = _coverage_deps.get(abi, {}).get(platform, (None, "")) if url == None: - logger.warn(lambda: ( - "rules_python's bundled coverage tool has no wheel for " + - "python_version={}, platform={}. `bazel coverage` will produce " + - "empty lcov for py_test targets in this configuration. Either " + - "pin python_version to a version in the bundled set (see " + - "python/private/coverage_deps.bzl), or configure coverage " + - "manually via py_runtime.coverage_tool. See docs/coverage.md." - ).format(python_version, platform)) + # Toolchains are registered for every platform in PLATFORMS, most of + # which a given build never resolves, so warning here is noise. The + # empty-lcov outcome is reported by py_runtime instead, which is + # analyzed only for the runtime actually selected. See + # https://github.com/bazel-contrib/rules_python/issues/3950. return None maybe( diff --git a/python/private/py_runtime_rule.bzl b/python/private/py_runtime_rule.bzl index 48637389bf..b3399a24fb 100644 --- a/python/private/py_runtime_rule.bzl +++ b/python/private/py_runtime_rule.bzl @@ -25,6 +25,41 @@ load(":version.bzl", "version") _py_builtins = py_internal +def coverage_tool_missing_message(*, coverage_enabled, coverage_tool, label): + """Build the warning for a selected runtime that cannot produce coverage. + + Kept separate from the rule implementation so the decision is unit testable + without having to capture analysis-phase output. + + Args: + coverage_enabled: {type}`bool` whether the build is collecting coverage. + coverage_tool: {type}`File | None` the runtime's coverage entry point. + label: {type}`Label` the `py_runtime` being analyzed. + + Returns: + {type}`str | None` the message to print, or `None` when no warning is + warranted. + """ + if not coverage_enabled or coverage_tool: + return None + + return """ +====================================================================== +WARNING: Python runtime {label} has no coverage_tool. + `bazel coverage` will produce empty lcov data for py_test targets that + resolve to this runtime. + + For rules_python's hermetic toolchains, enable the bundled coverage.py: + python.toolchain(configure_coverage_tool = True) # bzlmod + python_register_toolchains(register_coverage_tool = True) # WORKSPACE + A bundled wheel must exist for this interpreter's version and platform; + python/private/coverage_deps.bzl lists what ships with rules_python. + + Otherwise, set py_runtime.coverage_tool directly. See + https://rules-python.readthedocs.io/en/latest/coverage.html +====================================================================== +""".format(label = label) + def _py_runtime_impl(ctx): interpreter_path = ctx.attr.interpreter_path or None # Convert empty string to None interpreter = ctx.attr.interpreter @@ -108,6 +143,19 @@ def _py_runtime_impl(ctx): coverage_tool = None coverage_files = None + # Reported here rather than where the toolchains are registered: this rule is + # analyzed once per configuration, and only for the runtime that toolchain + # resolution actually selected, so the empty-lcov outcome is real rather than + # hypothetical. See https://github.com/bazel-contrib/rules_python/issues/3950. + coverage_warning = coverage_tool_missing_message( + coverage_enabled = ctx.configuration.coverage_enabled, + coverage_tool = coverage_tool, + label = ctx.label, + ) + if coverage_warning: + # buildifier: disable=print + print(coverage_warning) + python_version = ctx.attr.python_version interpreter_version_info = ctx.attr.interpreter_version_info diff --git a/python/private/python.bzl b/python/private/python.bzl index f098a626fb..0d7d2baf40 100644 --- a/python/private/python.bzl +++ b/python/private/python.bzl @@ -278,7 +278,6 @@ def _python_impl(module_ctx): register_result = python_register_toolchains( name = toolchain_info.name, _internal_bzlmod_toolchain_call = True, - _internal_module_ctx = module_ctx, **kwargs ) if not register_result.impl_repos: diff --git a/python/private/python_register_toolchains.bzl b/python/private/python_register_toolchains.bzl index 5a2f96857b..c6f827be22 100644 --- a/python/private/python_register_toolchains.bzl +++ b/python/private/python_register_toolchains.bzl @@ -26,7 +26,6 @@ load( load(":coverage_deps.bzl", "coverage_dep") load(":full_version.bzl", "full_version") load(":python_repository.bzl", "python_repository") -load(":repo_utils.bzl", "repo_utils") load( ":toolchains_repo.bzl", "host_compatible_python_repo", @@ -90,19 +89,6 @@ def python_register_toolchains( if bzlmod_toolchain_call: register_toolchains = False - # When invoked from the bzlmod python extension, a module_ctx is plumbed in - # so the coverage_dep logger can attribute warnings to the right module and - # honor module-root filtering. In the WORKSPACE/macro path no module_ctx is - # available; a minimal stand-in struct gives the logger what it needs. - module_ctx = kwargs.pop("_internal_module_ctx", None) - if module_ctx != None: - coverage_logger = repo_utils.logger(module_ctx, name = "coverage_dep") - else: - coverage_logger = repo_utils.logger( - struct(getenv = lambda _: None), - name = "coverage_dep", - ) - base_urls = kwargs.pop("base_urls", DEFAULT_RELEASE_BASE_URLS) tool_versions = tool_versions or TOOL_VERSIONS minor_mapping = minor_mapping or MINOR_MAPPING @@ -140,7 +126,6 @@ def python_register_toolchains( ), python_version = python_version, platform = platform, - logger = coverage_logger, visibility = ["@{name}_{platform}//:__subpackages__".format( name = name, platform = platform, diff --git a/tests/coverage_deps/coverage_deps_test.bzl b/tests/coverage_deps/coverage_deps_test.bzl index 12351affde..2d84d7b919 100644 --- a/tests/coverage_deps/coverage_deps_test.bzl +++ b/tests/coverage_deps/coverage_deps_test.bzl @@ -12,68 +12,39 @@ # See the License for the specific language governing permissions and # limitations under the License. -"Tests for the warning emitted by coverage_dep when no wheel is available." +"Tests for coverage_dep's handling of platforms with no bundled wheel." load("@rules_testing//lib:test_suite.bzl", "test_suite") load("//python/private:coverage_deps.bzl", "coverage_dep") # buildifier: disable=bzl-visibility -load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "REPO_VERBOSITY_ENV_VAR", "repo_utils") # buildifier: disable=bzl-visibility _tests = [] -def _capturing_logger(): - """Build a (logger, captured_messages_list) pair. - - The logger has its verbosity set to INFO so WARN messages are captured but - nothing noisier than necessary is emitted. The printer collects the second - positional argument from each printer invocation (the formatted message). - """ - captured = [] - logger = repo_utils.logger( - struct( - getenv = { - REPO_DEBUG_ENV_VAR: None, - REPO_VERBOSITY_ENV_VAR: "INFO", - }.get, - ), - name = "unit-test", - printer = lambda _key, message: captured.append(message), - ) - return logger, captured - -def _test_unsupported_python_version_warns(env): - # cp37 is not in the bundled wheel set; coverage_dep should return None - # and emit a warning describing the misconfiguration. - logger, captured = _capturing_logger() +def _test_unsupported_python_version_returns_none(env): + # cp37 is not in the bundled wheel set, so there is no coverage tool to + # attach to the runtime. Reporting that is py_runtime's job -- registration + # covers every platform in PLATFORMS, most of which are never selected. result = coverage_dep( name = "unused_for_test", python_version = "3.7", platform = "aarch64-apple-darwin", visibility = ["//visibility:public"], - logger = logger, ) env.expect.that_bool(result == None).equals(True) - env.expect.that_int(len(captured)).equals(1) - env.expect.that_str(captured[0]).contains("no wheel for") - env.expect.that_str(captured[0]).contains("python_version=3.7") - env.expect.that_str(captured[0]).contains("platform=aarch64-apple-darwin") -_tests.append(_test_unsupported_python_version_warns) +_tests.append(_test_unsupported_python_version_returns_none) -def _test_windows_platform_is_silent(env): - # Windows is intentionally unsupported and not actionable; coverage_dep - # must return None without logging anything. - logger, captured = _capturing_logger() +def _test_windows_platform_returns_none(env): + # Windows is intentionally unsupported: the upstream coverage wrapper is + # written in shell. result = coverage_dep( name = "unused_for_test", python_version = "3.10", platform = "x86_64-pc-windows-msvc", visibility = ["//visibility:public"], - logger = logger, ) env.expect.that_bool(result == None).equals(True) - env.expect.that_int(len(captured)).equals(0) -_tests.append(_test_windows_platform_is_silent) +_tests.append(_test_windows_platform_returns_none) # NOTE: there is intentionally no unit test for the supported-wheel path # (where coverage_dep returns a non-None label and emits no warning). diff --git a/tests/py_runtime/py_runtime_tests.bzl b/tests/py_runtime/py_runtime_tests.bzl index ac80c8556d..47cd97f142 100644 --- a/tests/py_runtime/py_runtime_tests.bzl +++ b/tests/py_runtime/py_runtime_tests.bzl @@ -20,9 +20,49 @@ load("@rules_testing//lib:util.bzl", rt_util = "util") load("//python:py_runtime.bzl", "py_runtime") load("//python:py_runtime_info.bzl", "PyRuntimeInfo") load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility +load("//python/private:py_runtime_rule.bzl", "coverage_tool_missing_message") # buildifier: disable=bzl-visibility load("//tests/support:py_runtime_info_subject.bzl", "py_runtime_info_subject") _tests = [] +_basic_tests = [] + +def _test_coverage_warning_when_enabled_and_no_tool(env): + # The only case that warrants a warning: coverage is being collected and the + # runtime that was selected cannot produce any. + msg = coverage_tool_missing_message( + coverage_enabled = True, + coverage_tool = None, + label = Label("//fake:runtime"), + ) + env.expect.that_bool(msg == None).equals(False) + env.expect.that_str(msg).contains("has no coverage_tool") + env.expect.that_str(msg).contains("//fake:runtime") + +_basic_tests.append(_test_coverage_warning_when_enabled_and_no_tool) + +def _test_no_coverage_warning_when_coverage_disabled(env): + # Without `bazel coverage` the missing tool has no observable effect, so + # saying anything would be noise on every ordinary build. + msg = coverage_tool_missing_message( + coverage_enabled = False, + coverage_tool = None, + label = Label("//fake:runtime"), + ) + env.expect.that_bool(msg == None).equals(True) + +_basic_tests.append(_test_no_coverage_warning_when_coverage_disabled) + +def _test_no_coverage_warning_when_tool_present(env): + # A configured coverage tool is the working case, whether it came from the + # bundled wheel set or from py_runtime.coverage_tool directly. + msg = coverage_tool_missing_message( + coverage_enabled = True, + coverage_tool = "some-coverage-tool-file", + label = Label("//fake:runtime"), + ) + env.expect.that_bool(msg == None).equals(True) + +_basic_tests.append(_test_no_coverage_warning_when_tool_present) def _simple_binary_impl(ctx): executable = ctx.actions.declare_file(ctx.label.name) @@ -557,5 +597,6 @@ _tests.append(_test_version_info_from_flag) def py_runtime_test_suite(name): test_suite( name = name, + basic_tests = _basic_tests, tests = _tests, ) From c0fec11c63a13b864cfba0038d43575af7adbee4 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 27 Jul 2026 10:00:31 -0700 Subject: [PATCH 111/179] build: move requirements and lock files out of docs/ to dev/ (#3957) build(deps): move root requirements and lock files out of docs/ to dev/ Currently, the root development requirements and lock files reside in the `docs/` directory, which is confusing as they contain development and testing dependencies for the entire repository, not just documentation. To fix this, create a `dev/` directory and move `pyproject.toml`, `requirements.txt`, `uv.lock`, and their associated lock build rules into it. Update repository references and regenerate the lockfiles accordingly. --- CONTRIBUTING.md | 2 +- MODULE.bazel | 4 +-- WORKSPACE | 4 +-- dev/BUILD.bazel | 28 +++++++++++++++ {docs => dev}/pyproject.toml | 2 +- {docs => dev}/requirements.txt | 62 +++++++++++++++++----------------- {docs => dev}/uv.lock | 2 +- docs/BUILD.bazel | 26 -------------- private/BUILD.bazel | 2 +- 9 files changed, 67 insertions(+), 65 deletions(-) create mode 100644 dev/BUILD.bazel rename {docs => dev}/pyproject.toml (95%) rename {docs => dev}/requirements.txt (96%) rename {docs => dev}/uv.lock (99%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73ab6d6220..9efce5a148 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -276,7 +276,7 @@ merged: * **requirements lock files**: These are usually generated by a `compile_pip_requirements` update target, which is usually in the same directory. - e.g. `bazel run //docs:requirements.update` + e.g. `bazel run //dev:requirements.update` ## Binary artifacts diff --git a/MODULE.bazel b/MODULE.bazel index 2df354fe61..1c2042152e 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -180,13 +180,13 @@ dev_pip = use_extension( hub_name = "dev_pip", parallel_download = False, python_version = python_version, - requirements_lock = "//docs:requirements.txt", + requirements_lock = "//dev:requirements.txt", # Ensure that we are setting up the following platforms target_platforms = [ "{os}_{arch}", "{os}_{arch}_freethreaded", ], - uv_lock = "//docs:uv.lock", + uv_lock = "//dev:uv.lock", ) for python_version in [ "3.9", diff --git a/WORKSPACE b/WORKSPACE index 59b8489760..a692f1b289 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -143,12 +143,12 @@ load("@pypiserver//:requirements.bzl", install_pypiserver = "install_deps", pypi install_pypiserver() ##################### -# Install sphinx for doc generation. +# Install dev dependencies. pip_parse( name = "dev_pip", python_interpreter_target = interpreter, - requirements_lock = "//docs:requirements.txt", + requirements_lock = "//dev:requirements.txt", ) load("@dev_pip//:requirements.bzl", dev_pip_requirements = "all_requirements", docs_install_deps = "install_deps") diff --git a/dev/BUILD.bazel b/dev/BUILD.bazel new file mode 100644 index 0000000000..91b2a9f566 --- /dev/null +++ b/dev/BUILD.bazel @@ -0,0 +1,28 @@ +load("//python/uv:lock.bzl", "lock") # buildifier: disable=bzl-visibility + +licenses(["notice"]) + +# Run bazel run //dev:requirements.update +lock( + name = "requirements", + srcs = ["pyproject.toml"], + out = "requirements.txt", + args = [ + "--emit-index-url", + "--universal", + "--upgrade", + ], + # NOTE @aignas 2025-08-17: here we select the lowest actively supported version so that the + # requirements file is generated to be compatible with Python version 3.9 or greater. + python_version = "3.9", + visibility = ["//:__subpackages__"], +) + +# Run bazel run //dev:uv_lock.update +lock( + name = "uv_lock", + srcs = ["pyproject.toml"], + out = "uv.lock", + python_version = "3.9", + visibility = ["//:__subpackages__"], +) diff --git a/docs/pyproject.toml b/dev/pyproject.toml similarity index 95% rename from docs/pyproject.toml rename to dev/pyproject.toml index e54fd53504..cdc00b902c 100644 --- a/docs/pyproject.toml +++ b/dev/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "rules_python_docs" +name = "rules_python_dev" version = "0.0.0" dependencies = [ diff --git a/docs/requirements.txt b/dev/requirements.txt similarity index 96% rename from docs/requirements.txt rename to dev/requirements.txt index 9a266e9644..943fce2dcf 100644 --- a/docs/requirements.txt +++ b/dev/requirements.txt @@ -1,15 +1,15 @@ # This file was autogenerated by uv via the following command: -# bazel run //docs:requirements.update +# bazel run //dev:requirements.update --index-url https://pypi.org/simple absl-py==2.3.1 ; python_full_version < '3.10' \ --hash=sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9 \ --hash=sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) absl-py==2.5.0 ; python_full_version >= '3.10' \ --hash=sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f \ --hash=sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) alabaster==0.7.16 ; python_full_version < '3.10' \ --hash=sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65 \ --hash=sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92 @@ -30,9 +30,9 @@ babel==2.18.0 \ --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 # via sphinx -certifi==2026.6.17 \ - --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ - --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ @@ -187,7 +187,7 @@ jinja2==3.1.6 \ macholib==1.16.4 \ --hash=sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea \ --hash=sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362 - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) markdown-it-py==3.0.0 ; python_full_version < '3.11' \ --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb @@ -291,7 +291,7 @@ markupsafe==3.0.3 \ --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # jinja2 mdit-py-plugins==0.4.2 ; python_full_version < '3.10' \ --hash=sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636 \ @@ -308,15 +308,15 @@ mdurl==0.1.2 \ myst-parser==3.0.1 ; python_full_version < '3.10' \ --hash=sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1 \ --hash=sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87 - # via rules-python-docs (docs/pyproject.toml) -myst-parser==5.1.0 ; python_full_version == '3.10.*' \ - --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ - --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) +myst-parser==4.0.1 ; python_full_version == '3.10.*' \ + --hash=sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4 \ + --hash=sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d + # via rules-python-dev (dev/pyproject.toml) myst-parser==5.1.0 ; python_full_version >= '3.11' \ --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 @@ -327,7 +327,7 @@ packaging==26.2 \ pefile==2024.8.26 \ --hash=sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632 \ --hash=sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 @@ -335,11 +335,11 @@ pluggy==1.6.0 \ pyelftools==0.32 ; python_full_version < '3.10' \ --hash=sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738 \ --hash=sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5 - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) pyelftools==0.33 ; python_full_version >= '3.10' \ --hash=sha256:660d82dcbeb8e83d1702bd97f223f761625da06111c0cc988eac6b8ab0c1b61f \ --hash=sha256:f215ad5f47d3f1373a21496a6c9e0707c622840d0622f23ff7ce08678b020036 - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 @@ -350,23 +350,23 @@ pytest==8.4.2 ; python_full_version < '3.10' \ --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \ --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79 # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # pytest-bazel # pytest-mock pytest==9.1.1 ; python_full_version >= '3.10' \ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # pytest-bazel # pytest-mock pytest-bazel==0.1.6 \ --hash=sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21 - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) pytest-mock==3.15.1 \ --hash=sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d \ --hash=sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ @@ -445,7 +445,7 @@ pyyaml==6.0.3 \ readthedocs-sphinx-ext==2.2.5 \ --hash=sha256:ee5fd5b99db9f0c180b2396cbce528aa36671951b9526bb0272dbfce5517bd27 \ --hash=sha256:f8c56184ea011c972dd45a90122568587cc85b0127bc9cf064d17c68bc809daa - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) requests==2.32.5 ; python_full_version < '3.10' \ --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf @@ -470,7 +470,7 @@ sphinx==7.4.7 ; python_full_version < '3.10' \ --hash=sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe \ --hash=sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239 # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # myst-parser # sphinx-reredirects # sphinx-rtd-theme @@ -479,7 +479,7 @@ sphinx==8.1.3 ; python_full_version == '3.10.*' \ --hash=sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2 \ --hash=sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927 # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # myst-parser # sphinx-reredirects # sphinx-rtd-theme @@ -488,7 +488,7 @@ sphinx==9.0.4 ; python_full_version == '3.11.*' \ --hash=sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3 \ --hash=sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # myst-parser # sphinx-reredirects # sphinx-rtd-theme @@ -497,7 +497,7 @@ sphinx==9.1.0 ; python_full_version >= '3.12' \ --hash=sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb \ --hash=sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978 # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # myst-parser # sphinx-reredirects # sphinx-rtd-theme @@ -505,19 +505,19 @@ sphinx==9.1.0 ; python_full_version >= '3.12' \ sphinx-autodoc2==0.5.0 \ --hash=sha256:7d76044aa81d6af74447080182b6868c7eb066874edc835e8ddf810735b6565a \ --hash=sha256:e867013b1512f9d6d7e6f6799f8b537d6884462acd118ef361f3f619a60b5c9e - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) sphinx-reredirects==0.1.6 ; python_full_version < '3.11' \ --hash=sha256:c491cba545f67be9697508727818d8626626366245ae64456fe29f37e9bbea64 \ --hash=sha256:efd50c766fbc5bf40cd5148e10c00f2c00d143027de5c5e48beece93cc40eeea - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) sphinx-reredirects==1.1.0 ; python_full_version >= '3.11' \ --hash=sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9 \ --hash=sha256:fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29 - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) sphinx-rtd-theme==3.1.0 \ --hash=sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89 \ --hash=sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c - # via rules-python-docs (docs/pyproject.toml) + # via rules-python-dev (dev/pyproject.toml) sphinxcontrib-applehelp==2.0.0 \ --hash=sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1 \ --hash=sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5 @@ -602,7 +602,7 @@ typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via - # rules-python-docs (docs/pyproject.toml) + # rules-python-dev (dev/pyproject.toml) # astroid # exceptiongroup # sphinx-autodoc2 diff --git a/docs/uv.lock b/dev/uv.lock similarity index 99% rename from docs/uv.lock rename to dev/uv.lock index d01b79b784..842e831dec 100644 --- a/docs/uv.lock +++ b/dev/uv.lock @@ -868,7 +868,7 @@ wheels = [ ] [[package]] -name = "rules-python-docs" +name = "rules-python-dev" version = "0.0.0" source = { virtual = "." } dependencies = [ diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 9a9327aadf..dd304c410a 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -20,7 +20,6 @@ load("@sphinxdocs//sphinxdocs:sphinx_stardoc.bzl", "sphinx_stardoc", "sphinx_sta load("//python:defs.bzl", "py_binary") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility -load("//python/uv:lock.bzl", "lock") # buildifier: disable=bzl-visibility package(default_visibility = ["//:__subpackages__"]) @@ -208,28 +207,3 @@ sphinx_build_binary( "@sphinxdocs//sphinxdocs/src/sphinx_bzl", ], ) - -# Run bazel run //docs:requirements.update -lock( - name = "requirements", - srcs = ["pyproject.toml"], - out = "requirements.txt", - args = [ - "--emit-index-url", - "--universal", - "--upgrade", - ], - # NOTE @aignas 2025-08-17: here we select the lowest actively supported version so that the - # requirements file is generated to be compatible with Python version 3.9 or greater. - python_version = "3.9", - visibility = ["//:__subpackages__"], -) - -# Run bazel run //docs:uv_lock.update -lock( - name = "uv_lock", - srcs = ["pyproject.toml"], - out = "uv.lock", - python_version = "3.9", - visibility = ["//:__subpackages__"], -) diff --git a/private/BUILD.bazel b/private/BUILD.bazel index ef5652b826..c6bfe6bd9f 100644 --- a/private/BUILD.bazel +++ b/private/BUILD.bazel @@ -13,7 +13,7 @@ multirun( "requirements_linux", ] ] + [ - "//docs:requirements.update", + "//dev:requirements.update", ], tags = ["manual"], ) From 872da66a0884612b40442628e48f1601937627ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:39 +0000 Subject: [PATCH 112/179] build(deps): bump actions/download-artifact from 4 to 8 (#3963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
Release notes

Sourced from actions/download-artifact's releases.

v8.0.0

v8 - What's new

[!IMPORTANT] actions/download-artifact@v8 has been migrated to an ESM module. This should be transparent to the caller but forks might need to make significant changes.

[!IMPORTANT] Hash mismatches will now error by default. Users can override this behavior with a setting change (see below).

Direct downloads

To support direct uploads in actions/upload-artifact, the action will no longer attempt to unzip all downloaded files. Instead, the action checks the Content-Type header ahead of unzipping and skips non-zipped files. Callers wishing to download a zipped file as-is can also set the new skip-decompress parameter to true.

Enforced checks (breaking)

A previous release introduced digest checks on the download. If a download hash didn't match the expected hash from the server, the action would log a warning. Callers can now configure the behavior on mismatch with the digest-mismatch parameter. To be secure by default, we are now defaulting the behavior to error which will fail the workflow run.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

Full Changelog: https://github.com/actions/download-artifact/compare/v7...v8.0.0

v7.0.0

v7 - What's new

[!IMPORTANT] actions/download-artifact@v7 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v6 had preliminary support for Node 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/download-artifact/compare/v6.0.0...v7.0.0

v6.0.0

... (truncated)

Commits
  • 3e5f45b Add regression tests for CJK characters (#471)
  • e6d03f6 Add a regression test for artifact name + content-type mismatches (#472)
  • 70fc10c Merge pull request #461 from actions/danwkennedy/digest-mismatch-behavior
  • f258da9 Add change docs
  • ccc058e Fix linting issues
  • bd7976b Add a setting to specify what to do on hash mismatch and default it to error
  • ac21fcf Merge pull request #460 from actions/danwkennedy/download-no-unzip
  • 15999bf Add note about package bumps
  • 974686e Bump the version to v8 and add release notes
  • fbe48b1 Update test names to make it clearer what they do
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=4&new-version=8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/automated_pr_review.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index 3fe93db7f3..179b1dcd4b 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Diff Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: pr_diff From 3f396ddf2dfaa188f7fd1ba080656240cd210643 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:41:31 +0900 Subject: [PATCH 113/179] feat(coverage): fallback to pure Python wheel (#3961) This is an alternative way to solve the warning issue that was reported in the ticket. This means that `rules_python` from now on actually can register `coverage` for any UNIXy toolchain no matter the toolchain build. Fixes #3950 --- news/3950.fixed.md | 4 ++-- python/private/coverage_deps.bzl | 15 +++++++-------- tests/coverage_deps/coverage_deps_test.bzl | 14 -------------- .../private/update_deps/update_coverage_deps.py | 17 ++++++++++++++--- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/news/3950.fixed.md b/news/3950.fixed.md index 4684beb890..87e0bd91a6 100644 --- a/news/3950.fixed.md +++ b/news/3950.fixed.md @@ -1,3 +1,3 @@ -(coverage) The warning about a missing bundled `coverage.py` wheel is only -emitted once per affected and used toolchain when coverage is being collected +(coverage) The warning about a missing bundled `coverage.py` wheel is no longer +emitted as we are now falling back to a pure python wheel ([#3950](https://github.com/bazel-contrib/rules_python/issues/3950)). diff --git a/python/private/coverage_deps.bzl b/python/private/coverage_deps.bzl index a3f211ba2a..001146c3d4 100644 --- a/python/private/coverage_deps.bzl +++ b/python/private/coverage_deps.bzl @@ -20,6 +20,10 @@ load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe") load("//python/private:version_label.bzl", "version_label") # START: maintained by 'bazel run //tools/private/update_deps:update_coverage_deps ' +_default = ( + "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", + "f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", +) _coverage_deps = { "cp310": { "aarch64-apple-darwin": ( @@ -184,15 +188,10 @@ def coverage_dep(name, python_version, platform, visibility): return None abi = "cp" + version_label(python_version) - url, sha256 = _coverage_deps.get(abi, {}).get(platform, (None, "")) + url, sha256 = _coverage_deps.get(abi, {}).get(platform, _default) - if url == None: - # Toolchains are registered for every platform in PLATFORMS, most of - # which a given build never resolves, so warning here is noise. The - # empty-lcov outcome is reported by py_runtime instead, which is - # analyzed only for the runtime actually selected. See - # https://github.com/bazel-contrib/rules_python/issues/3950. - return None + # NOTE @aignas 2026-07-27: if the default is matched, then the same file may be extracted + # multiple times. The wheel is small enough to not matter in most cases. maybe( http_archive, diff --git a/tests/coverage_deps/coverage_deps_test.bzl b/tests/coverage_deps/coverage_deps_test.bzl index 2d84d7b919..ddb82cd6c5 100644 --- a/tests/coverage_deps/coverage_deps_test.bzl +++ b/tests/coverage_deps/coverage_deps_test.bzl @@ -19,20 +19,6 @@ load("//python/private:coverage_deps.bzl", "coverage_dep") # buildifier: disabl _tests = [] -def _test_unsupported_python_version_returns_none(env): - # cp37 is not in the bundled wheel set, so there is no coverage tool to - # attach to the runtime. Reporting that is py_runtime's job -- registration - # covers every platform in PLATFORMS, most of which are never selected. - result = coverage_dep( - name = "unused_for_test", - python_version = "3.7", - platform = "aarch64-apple-darwin", - visibility = ["//visibility:public"], - ) - env.expect.that_bool(result == None).equals(True) - -_tests.append(_test_unsupported_python_version_returns_none) - def _test_windows_platform_returns_none(env): # Windows is intentionally unsupported: the upstream coverage wrapper is # written in shell. diff --git a/tools/private/update_deps/update_coverage_deps.py b/tools/private/update_deps/update_coverage_deps.py index fcb44fcc7c..8a4ccb41ba 100755 --- a/tools/private/update_deps/update_coverage_deps.py +++ b/tools/private/update_deps/update_coverage_deps.py @@ -115,12 +115,12 @@ def _map( platform: str, **kwargs: Any, ): - if platform not in _supported_platforms: + if platform and platform not in _supported_platforms: return None return Dep( name=name, - platform=_supported_platforms[platform], + platform=_supported_platforms[platform] if platform else "", python=python_version, url=url, sha256=digests["sha256"], @@ -170,6 +170,7 @@ def main(): data = json.loads(response.read().decode("utf-8")) urls = [] + default_url = None for u in data["urls"]: if u["yanked"]: continue @@ -177,6 +178,10 @@ def main(): if not u["filename"].endswith(".whl"): continue + if u["filename"].endswith("py3-none-any.whl"): + default_url = _map(name=args.name, platform="", **u) + continue + if u["python_version"] not in args.py: continue @@ -196,7 +201,13 @@ def main(): # Update the coverage_deps, which are used to register deps update_file( path=args.update_file, - snippet=f"_coverage_deps = {repr(Deps(urls))}\n", + snippet="\n".join( + [ + f"_default = {repr(default_url)}", + f"_coverage_deps = {repr(Deps(urls))}", + "", + ] + ), start_marker="# START: maintained by 'bazel run //tools/private/update_deps:update_coverage_deps '", end_marker="# END: maintained by 'bazel run //tools/private/update_deps:update_coverage_deps '", dry_run=args.dry_run, From 758c8e2db2dd2c95a0872809b7aef7bbee38615a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 28 Jul 2026 12:21:44 -0700 Subject: [PATCH 114/179] agents: add merge-pr skill for shepherding pull request merges (#3969) Add a standalone `merge-pr` skill that orchestrates pull request merges and merge queue monitoring. When invoked, the skill deploys a background `Merge PR Shepherd` subagent that uses `monitor-ci-results` to watch checks and analyze failure logs, automatically retries transient network flakes via `retry_buildkite_jobs.py`, and re-enqueues the pull request if it is ever ejected from the merge queue. --- .agents/skills/merge-pr/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .agents/skills/merge-pr/SKILL.md diff --git a/.agents/skills/merge-pr/SKILL.md b/.agents/skills/merge-pr/SKILL.md new file mode 100644 index 0000000000..73086759ed --- /dev/null +++ b/.agents/skills/merge-pr/SKILL.md @@ -0,0 +1,14 @@ +--- +name: merge-pr +description: Merge a pull request into main, monitoring the merge queue, retrying CI flakes, and re-enqueuing if necessary +--- + +When the user asks to merge a pull request (e.g., "merge PR ", "merge this PR", or monitor its merge): + +1. **Enqueue for Merge**: Run `gh pr merge --auto --squash` to enable auto-merge or add the pull request to the merge queue. +2. **Invoke a Background Shepherd**: Launch a background subagent with the role `Merge PR Shepherd` to continuously watch the PR until it merges. +3. **Leverage Existing CI Skills**: + - Have the subagent use the **`monitor-ci-results`** skill to watch for CI check failures and generate analysis reports. + - Have the subagent use the **`buildkite-retry-job`** skill (`retry_buildkite_jobs.py `) to automatically retry any transient network flakes (e.g., HTTP 504 gateway timeouts, downloader errors). +4. **Queue Shepherding**: Periodically check `gh pr view --json state,autoMergeRequest`. While `state` is `"OPEN"`, ensure auto-merge is enabled / queued by running `gh pr merge --auto --squash`. If `autoMergeRequest` is null (e.g., ejected from the merge queue due to a CI flake in the temporary queue branch), re-enqueue it for merge by running `gh pr merge --auto --squash` once checks are retried or green. +5. **Completion Notification**: Once `state` becomes `"MERGED"`, send a high-priority message back to the parent conversation. From 7868f5d2f92b5d82bc999b3583f64e673b8a407f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:16:51 -0700 Subject: [PATCH 115/179] build(deps): bump actions/upload-artifact from 4 to 7 (#3964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
Release notes

Sourced from actions/upload-artifact's releases.

v7.0.0

v7 What's new

Direct Uploads

Adds support for uploading single files directly (unzipped). Callers can set the new archive parameter to false to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The name parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v6...v7.0.0

v6.0.0

v6 - What's new

[!IMPORTANT] actions/upload-artifact@v6 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v5 had preliminary support for Node.js 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

... (truncated)

Commits
  • 043fb46 Merge pull request #797 from actions/yacaovsnc/update-dependency
  • 634250c Include changes in typespec/ts-http-runtime 0.3.5
  • e454baa Readme: bump all the example versions to v7 (#796)
  • 74fad66 Update the readme with direct upload details (#795)
  • bbbca2d Support direct file uploads (#764)
  • 589182c Upgrade the module to ESM and bump dependencies (#762)
  • 47309c9 Merge pull request #754 from actions/Link-/add-proxy-integration-tests
  • 02a8460 Add proxy integration test
  • b7c566a Merge pull request #745 from actions/upload-artifact-v6-release
  • e516bc8 docs: correct description of Node.js 24 support in README
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/automated_pr_review.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index 179b1dcd4b..dbfffa9083 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -62,7 +62,7 @@ jobs: # Upload extracted diff as artifact to pass to Job 2 safely as text data. - name: Upload Diff Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: pr_diff path: pr_diff.txt From ca783803a869425b95111f5f7290ec74442fec68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:11:20 -0700 Subject: [PATCH 116/179] build(deps): bump bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml from 1.4.1 to 1.4.2 (#3925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml](https://github.com/bazel-contrib/publish-to-bcr) from 1.4.1 to 1.4.2.
Release notes

Sourced from bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml's releases.

v1.4.2

What's Changed

New Contributors

Full Changelog: https://github.com/bazel-contrib/publish-to-bcr/compare/v1.4.1...v1.4.2

Commits
  • ad6879f ci: make CI pass for fork PRs (#405)
  • 005a51c ci: pin actions in publish.yaml to full-length commit SHAs (#404)
  • 63d4406 chore(deps): update dependency @​types/nodemailer to v7.0.12 (#402)
  • 6b034ce chore(deps): update dependency globby to v16 (#355)
  • d4e8c21 chore(deps): update dependency typescript-eslint to v8.62.1 (#400)
  • 3650e3c chore(deps): update dependency buildifier_prebuilt to v8.5.1.2 (#399)
  • 832b5d7 chore(deps): update dependency mailparser to v3.9.3 [security] (#371)
  • ab8074b fix(deps): update dependency nodemailer to v8 [security] (#382)
  • 1c91c56 fix: update bazel lockfile when autopatching (#398)
  • 7fccf69 chore(deps): update dependency yaml to v2.8.3 [security] (#381)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml&package-manager=github_actions&previous-version=1.4.1&new-version=1.4.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Richard Levasseur --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c5db587f18..7b358956d9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ on: type: string jobs: publish: - uses: bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml@v1.4.1 + uses: bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml@v1.4.2 with: tag_name: ${{ inputs.tag_name }} # GitHub repository which is a fork of the upstream where the Pull Request will be opened. From f87a4a7b7d6b29c0e2695028cee5c2f40a05bbb7 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 28 Jul 2026 21:20:15 -0700 Subject: [PATCH 117/179] build(agents): handle blocked Buildkite jobs in CI monitoring and update create-pr skill (#3975) build(agents): handle blocked Buildkite jobs in CI monitoring and update create-pr skill When remote Buildkite CI jobs enter a blocked state waiting for user confirmation, the CI monitoring script did not explicitly track or notify about blocked jobs, leading to unhandled paused builds. In addition, the `create-pr` skill lacked instructions for handling proposal-only workflows where subagents should draft PR information without executing `gh pr create`. To address this: * Updated `monitor_remote_ci.py` to identify `blocked` and `blocked_failed` job states, track blocked job counts separately, and send real-time notifications via `agentapi send-message` to alert users when a job is waiting for approval. * Documented blocked job orchestration in `.agents/skills/monitor-ci-results/SKILL.md`. * Updated `.agents/skills/create-pr/SKILL.md` to guide subagents on drafting PR proposals via `pr_info.md` artifacts when running in proposal mode rather than calling `gh pr create`. --- .agents/skills/create-pr/SKILL.md | 34 ++++++++ .agents/skills/monitor-ci-results/SKILL.md | 30 ++++--- .../scripts/monitor_remote_ci.py | 83 ++++++++++++++++--- 3 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 .agents/skills/create-pr/SKILL.md diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md new file mode 100644 index 0000000000..010d112fb8 --- /dev/null +++ b/.agents/skills/create-pr/SKILL.md @@ -0,0 +1,34 @@ +--- +name: create-pr +description: Create a pull request by delegating to a subagent +--- + +When creating a Pull Request for local changes or a branch, invoke a subagent +to handle PR creation or description drafting. + +### Instructions + +1. Launch a subagent using `invoke_subagent` with `TypeName: "self"` (or + `agentapi new-conversation`). +2. Provide a prompt to the subagent directing it to: + - Read `CONTRIBUTING.md` (specifically the sections on **Commit messages + and PR descriptions** and **Documenting changes**) before drafting. + - Strictly adhere to `CONTRIBUTING.md` rules for: + - **PR Title**: Follow conventional commit style and title formatting. + - **PR Body**: Include rationale, high-level summary, and structure. + - **Formatting**: Follow repository style guidelines and structure. + - Create a Markdown artifact (`pr_info.md`) containing the PR title, body, + and link/metadata so the user can review and comment on it. + - **Propose vs. Create**: If the user requested to propose or draft a PR + description, **do not** run `gh pr create`—just create the `pr_info.md` + artifact for the user to review. Otherwise, execute `gh pr create` with + the formatted title and body. +3. **Return Status**: Direct the subagent to communicate the PR number or draft + status back using `send_message` (or `agentapi send-message`) with the + parent conversation ID, or include it in its final completion response. +4. **Publish Artifact**: Upon receiving the subagent completion message, the + main agent must publish `pr_info.md` to display the artifact directly in + the primary user UI. +5. **Interactive Actions**: To present custom action choices to the user + (e.g., "Create PR", "Create Draft PR"), the main agent can use the + `ask_question` tool with custom options. diff --git a/.agents/skills/monitor-ci-results/SKILL.md b/.agents/skills/monitor-ci-results/SKILL.md index c5f8de309f..2b4bc5a460 100644 --- a/.agents/skills/monitor-ci-results/SKILL.md +++ b/.agents/skills/monitor-ci-results/SKILL.md @@ -1,21 +1,31 @@ --- name: monitor-ci-results -description: Monitor remote CI results for a PR and autonomously trigger log analysis upon failures +description: Monitor CI for PRs and notify of status --- -When the user requests to monitor remote CI results or watch a pull request, invoke `scripts/monitor_remote_ci.py `. +When the user requests to monitor remote CI results or watch a pull request, +invoke `scripts/monitor_remote_ci.py `. -This long-running monitoring service runs in the background and continuously polls both GitHub PR checks and Buildkite workflow executions. +This long-running monitoring service runs in the background and continuously +polls both GitHub PR checks and Buildkite workflow executions. -### ✨ Autonomous Failure Orchestration -When any CI job completes with errors or returns a non-zero exit code: -1. It automatically downloads the raw CI log file to `ci_logs/`. -2. It launches an independent background analyzer script (`analyze_ci_failure.py`). -3. It authors a beautifully structured Markdown suggested plan for how to fix the failure. -4. It natively dispatches a high-priority notification message back to your active agent conversation (containing the downloaded log path and fix plan) using `agentapi send-message`! +### ✨ Autonomous Failure and Blocked Job Orchestration +1. **Blocked Jobs**: When a Buildkite job or GitHub check is in a blocked + state waiting for user confirmation, it dispatches a notification via + `agentapi send-message` so the user is alerted to confirm running the job. +2. **Failure Analysis**: When any CI job completes with errors or returns a + non-zero exit code: + - It automatically downloads the raw CI log file to `ci_logs/`. + - It launches an independent background analyzer script + (`analyze_ci_failure.py`). + - It authors a structured Markdown plan to fix the failure. + - It natively dispatches a high-priority notification back to your active + agent conversation using `agentapi send-message`! ### Example Invocation ```bash ./scripts/monitor_remote_ci.py 3812 "0be435bd-96aa-4e1b-9c6f-727b31e80fa0" & ``` -*Note: Always include the trailing `&` when launching the monitoring script via tool calls to ensure it runs as a detached background task without blocking foreground execution.* +*Note: Always include the trailing `&` when launching the monitoring script via +tool calls to ensure it runs as a detached background task without blocking +foreground execution.* diff --git a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py index fc1f8955d3..40be4d5d26 100755 --- a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py +++ b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py @@ -111,24 +111,32 @@ def main(): passed = 0 failed = 0 running = 0 + blocked = 0 other = 0 for job in jobs: jstate = job.get("state", "unknown") exit_status = job.get("exit_status") is_soft_failed = job.get("soft_failed") is True + is_blocked = jstate in ["blocked", "blocked_failed"] is_failed = ( - jstate in ["failed", "failing"] - or (exit_status != 0 and exit_status is not None) - ) and not is_soft_failed + ( + jstate in ["failed", "failing"] + or (exit_status != 0 and exit_status is not None) + ) + and not is_soft_failed + and not is_blocked + ) is_passed = ( jstate in ["passed", "success"] or (jstate == "finished" and exit_status == 0) or is_soft_failed - ) - is_running = jstate in ["running", "scheduled"] + ) and not is_blocked + is_running = jstate in ["running", "scheduled"] and not is_blocked - if is_failed: + if is_blocked: + blocked += 1 + elif is_failed: failed += 1 elif is_passed: passed += 1 @@ -140,7 +148,7 @@ def main(): build_id = link.split("/")[-1].split("#")[0] print( f"Buildkite #{build_id}: {len(jobs)} total jobs " - f"(Passed: {passed}, Failed: {failed}, Running: {running}, Other: {other})" + f"(Passed: {passed}, Failed: {failed}, Running: {running}, Blocked: {blocked}, Other: {other})" ) for job in jobs: @@ -151,12 +159,42 @@ def main(): exit_status = job.get("exit_status") is_soft_failed = job.get("soft_failed") is True + is_blocked = jstate in ["blocked", "blocked_failed"] is_failed = ( - jstate in ["failed", "failing"] - or (exit_status != 0 and exit_status is not None) - ) and not is_soft_failed + ( + jstate in ["failed", "failing"] + or (exit_status != 0 and exit_status is not None) + ) + and not is_soft_failed + and not is_blocked + ) - if is_failed and jkey not in monitored: + if is_blocked: + jkey_blocked = f"bk_blocked_{jid}" + if jkey_blocked not in monitored: + print( + f"⏸️ Notifying blocked state for Buildkite job '{jname}' (ID: {jid})..." + ) + msg = ( + f"⚠️ Remote CI Buildkite Job '{jname}' is blocked!\n\n" + f"Build ID: {build_id} | Job ID: {jid}\n" + f"Log URL: {job.get('log_url', link)}\n\n" + f"The user must confirm whether to run the CI jobs." + ) + subprocess.run( + [ + "agentapi", + "send-message", + "--title=CI Job Blocked", + args.conv_id, + msg, + ] + ) + monitored[jkey_blocked] = time.time() + with open(state_file, "w") as f: + json.dump(monitored, f) + + elif is_failed and jkey not in monitored: print( f"🚨 Notifying failure for Buildkite job '{jname}' (ID: {jid})..." ) @@ -179,6 +217,29 @@ def main(): with open(state_file, "w") as f: json.dump(monitored, f) + elif ( + state.upper() in ["ACTION_REQUIRED", "BLOCKED"] + and f"gh_blocked_{name}" not in monitored + ): + print(f"⏸️ Notifying blocked state for GitHub check '{name}'...") + msg = ( + f"⚠️ Remote CI GitHub Check '{name}' is blocked!\n\n" + f"Link: {link}\n\n" + f"The user must confirm whether to run the CI jobs." + ) + subprocess.run( + [ + "agentapi", + "send-message", + "--title=CI Check Blocked", + args.conv_id, + msg, + ] + ) + monitored[f"gh_blocked_{name}"] = time.time() + with open(state_file, "w") as f: + json.dump(monitored, f) + elif state in ["FAILURE", "failed"] and name not in monitored: print(f"🚨 Notifying failure for GitHub check '{name}'...") msg = ( From b16f7267140be0e889625044e1bbf836415e7a97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:36:55 +0000 Subject: [PATCH 118/179] build(deps): bump nh3 from 0.3.0 to 0.3.6 in /tools/publish (#3966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [nh3](https://github.com/messense/nh3) from 0.3.0 to 0.3.6.
Release notes

Sourced from nh3's releases.

v0.3.6

What's Changed

Full Changelog: https://github.com/messense/nh3/compare/v0.3.5...v0.3.6

v0.3.5

What's Changed

Full Changelog: https://github.com/messense/nh3/compare/v0.3.4...v0.3.5

v0.3.4

What's Changed

New Contributors

Full Changelog: https://github.com/messense/nh3/compare/v0.3.3...v0.3.4

v0.3.3

What's Changed

New Contributors

Full Changelog: https://github.com/messense/nh3/compare/v0.3.2...v0.3.3

v0.3.2

What's Changed

... (truncated)

Commits
  • bea70df Bump version to 0.3.6
  • 788cee2 Expose ammonia's url_relative policy via url_relative kwarg (#131)
  • e76a81a Bump pyo3 from 0.28.3 to 0.29.0 (#130)
  • 8ec45a3 Add nh3.escape alias for clean_text (#127)
  • a1e0226 Document tag_attribute_values as alternate to attributes (#126)
  • 389fb79 Validate clean_content_tags conflict with tags (#125)
  • 61aad5a Bump uraimo/run-on-arch-action in the github-actions group (#124)
  • 9e78e6a Bump version to 0.3.5
  • 5225ec2 Add tags parameter to clean_text (#122)
  • 129df52 Bump pyo3 from 0.28.2 to 0.28.3 (#123)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nh3&package-manager=pip&previous-version=0.3.0&new-version=0.3.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Richard Levasseur --- .../bzlmod_lockfile/MODULE.bazel.lock | 416 ++++++++-------- tools/publish/requirements_darwin.txt | 166 ++++--- tools/publish/requirements_linux.txt | 461 +++++++++--------- tools/publish/requirements_universal.txt | 452 +++++++++-------- tools/publish/requirements_windows.txt | 166 ++++--- 5 files changed, 868 insertions(+), 793 deletions(-) diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index 92d1f6be7b..5fac194583 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -290,94 +290,110 @@ "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl": "77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34" }, "certifi": { - "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz": "47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", - "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl": "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de" + "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl": "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", + "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz": "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" }, "cffi": { - "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", - "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl": "3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", - "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl": "5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", - "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl": "b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", - "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", - "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", - "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl": "cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", - "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", - "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl": "737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", - "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl": "c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", - "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl": "89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", - "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl": "7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", - "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl": "cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", - "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl": "b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", - "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl": "6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", - "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl": "19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", - "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl": "81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", - "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl": "de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", - "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl": "9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", - "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl": "087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", - "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl": "a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", - "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", - "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl": "8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", - "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl": "45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", - "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl": "00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", - "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", - "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl": "2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", - "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", - "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl": "d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", - "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl": "b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", - "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl": "c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", - "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl": "e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", - "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", - "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl": "da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", - "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", - "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl": "fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", - "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl": "0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", - "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl": "4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", - "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl": "c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", - "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", - "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", - "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", - "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl": "f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", - "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl": "dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", - "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", - "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl": "1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", - "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", - "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", - "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl": "2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", - "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl": "94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", - "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl": "0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", - "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl": "66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", - "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", - "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", - "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", - "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl": "2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", - "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", - "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl": "203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", - "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl": "d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", - "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", - "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl": "d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", - "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl": "fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", - "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", - "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl": "38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", - "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl": "256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", - "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl": "dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", - "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", - "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", - "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", - "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", - "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", - "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", - "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl": "8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", - "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", - "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", - "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl": "1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", - "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl": "0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", - "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl": "6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", - "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz": "44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", - "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl": "74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", - "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", - "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl": "1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", - "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl": "da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", - "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" + "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl": "89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", + "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl": "8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", + "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl": "1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", + "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl": "f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", + "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", + "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl": "7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", + "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", + "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", + "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl": "df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", + "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl": "1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", + "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", + "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl": "7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", + "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", + "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl": "6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", + "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl": "2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", + "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl": "0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", + "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl": "7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", + "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", + "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl": "bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", + "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl": "961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", + "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", + "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", + "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", + "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl": "c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", + "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", + "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl": "19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", + "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl": "78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", + "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", + "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl": "6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", + "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl": "2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", + "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz": "efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", + "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl": "d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", + "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl": "a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", + "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl": "af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", + "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl": "8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", + "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", + "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl": "2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", + "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl": "8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", + "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", + "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", + "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl": "cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", + "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl": "164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", + "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", + "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl": "98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", + "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", + "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", + "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl": "9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", + "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl": "379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", + "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl": "702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", + "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl": "716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", + "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", + "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", + "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl": "fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", + "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", + "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl": "15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", + "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl": "df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", + "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl": "10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", + "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl": "37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", + "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl": "35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", + "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl": "cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", + "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", + "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl": "9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", + "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl": "47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", + "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl": "c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", + "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl": "bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", + "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl": "9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", + "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl": "dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", + "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl": "ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", + "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", + "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl": "0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", + "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl": "86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", + "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl": "a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", + "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl": "b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", + "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", + "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl": "db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", + "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", + "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", + "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl": "3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", + "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl": "f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", + "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl": "c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", + "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl": "02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", + "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl": "510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", + "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl": "c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", + "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl": "d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", + "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl": "7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", + "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl": "6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", + "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", + "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl": "cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", + "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", + "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl": "f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", + "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", + "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl": "64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", + "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", + "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", + "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl": "95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", + "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", + "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl": "4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", + "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", + "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl": "11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", + "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565" }, "charset-normalizer": { "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl": "609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", @@ -475,153 +491,153 @@ "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl": "ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" }, "cryptography": { - "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", - "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl": "ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", - "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl": "cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", - "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl": "5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", - "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl": "cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", - "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl": "d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", - "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl": "258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", - "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl": "fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", - "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl": "397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", - "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl": "420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", - "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl": "e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", - "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl": "8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", - "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl": "d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", - "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl": "60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", - "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl": "b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", - "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl": "c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", - "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl": "a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", - "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz": "e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", - "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", - "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl": "f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", - "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl": "fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", - "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl": "65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", - "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", - "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl": "fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", - "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl": "9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", - "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl": "d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", - "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", - "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl": "d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", - "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl": "935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", - "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", - "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl": "73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", - "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl": "abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", - "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl": "fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", - "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl": "5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", - "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl": "d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", - "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", - "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl": "7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", - "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl": "462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", - "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl": "80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", - "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl": "cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", - "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl": "35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", - "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl": "ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", - "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl": "1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", - "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl": "42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", - "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl": "04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", - "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl": "91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", - "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl": "506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", - "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl": "3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", - "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl": "24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de" + "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", + "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl": "d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", + "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl": "07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", + "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl": "ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", + "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl": "e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", + "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz": "f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", + "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl": "2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", + "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl": "9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", + "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", + "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", + "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl": "f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", + "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl": "53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", + "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl": "33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", + "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl": "b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", + "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl": "c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", + "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl": "2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", + "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl": "fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", + "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl": "6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", + "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", + "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl": "8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", + "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl": "67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", + "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl": "ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", + "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl": "66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", + "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", + "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl": "42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", + "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl": "b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", + "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl": "966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", + "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl": "2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", + "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl": "0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", + "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl": "0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", + "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl": "b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", + "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl": "4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", + "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl": "cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", + "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl": "be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", + "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl": "084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", + "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl": "6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", + "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", + "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl": "026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", + "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl": "d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", + "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl": "c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", + "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl": "b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", + "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", + "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", + "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl": "b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", + "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl": "73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", + "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl": "28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc" }, "docutils": { - "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz": "9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", - "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl": "b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8" + "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl": "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", + "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz": "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" + }, + "id": { + "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl": "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", + "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz": "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" }, "idna": { - "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", - "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz": "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9" + "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl": "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", + "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz": "ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" }, "importlib-metadata": { - "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl": "e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", - "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz": "d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000" + "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl": "2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", + "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz": "a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc" }, "jaraco-classes": { "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz": "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl": "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" }, "jaraco-context": { - "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz": "9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", - "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl": "f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" + "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz": "f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", + "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl": "bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" }, "jaraco-functools": { - "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl": "227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", - "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz": "cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294" + "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl": "99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", + "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz": "880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" }, "jeepney": { "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz": "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl": "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" }, "keyring": { - "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz": "0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", - "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl": "552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd" + "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz": "fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", + "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl": "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" }, "markdown-it-py": { - "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz": "cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", - "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl": "87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" + "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz": "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", + "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl": "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, "mdurl": { "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz": "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" }, "more-itertools": { - "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl": "52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", - "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz": "f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd" + "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz": "48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", + "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl": "4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192" }, "nh3": { - "https://files.pythonhosted.org/packages/0c/e0/cf1543e798ba86d838952e8be4cb8d18e22999be2a24b112a671f1c04fd6/nh3-0.3.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a", - "https://files.pythonhosted.org/packages/10/71/2fb1834c10fab6d9291d62c95192ea2f4c7518bd32ad6c46aab5d095cb87/nh3-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl": "0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5", - "https://files.pythonhosted.org/packages/23/1e/80a8c517655dd40bb13363fc4d9e66b2f13245763faab1a20f1df67165a7/nh3-0.3.0-cp313-cp313t-win_amd64.whl": "423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e", - "https://files.pythonhosted.org/packages/2f/d6/f1c6e091cbe8700401c736c2bc3980c46dca770a2cf6a3b48a175114058e/nh3-0.3.0-cp313-cp313t-win32.whl": "7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5", - "https://files.pythonhosted.org/packages/33/c1/8f8ccc2492a000b6156dce68a43253fcff8b4ce70ab4216d08f90a2ac998/nh3-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl": "1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9", - "https://files.pythonhosted.org/packages/39/2c/6394301428b2017a9d5644af25f487fa557d06bc8a491769accec7524d9a/nh3-0.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1", - "https://files.pythonhosted.org/packages/4c/3c/cba7b26ccc0ef150c81646478aa32f9c9535234f54845603c838a1dc955c/nh3-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl": "80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d", - "https://files.pythonhosted.org/packages/4e/9a/344b9f9c4bd1c2413a397f38ee6a3d5db30f1a507d4976e046226f12b297/nh3-0.3.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl": "37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9", - "https://files.pythonhosted.org/packages/5b/76/3165e84e5266d146d967a6cc784ff2fbf6ddd00985a55ec006b72bc39d5d/nh3-0.3.0-cp38-abi3-win_arm64.whl": "d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2", - "https://files.pythonhosted.org/packages/5c/86/a96b1453c107b815f9ab8fac5412407c33cc5c7580a4daf57aabeb41b774/nh3-0.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1", - "https://files.pythonhosted.org/packages/63/da/c5fd472b700ba37d2df630a9e0d8cc156033551ceb8b4c49cc8a5f606b68/nh3-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl": "ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95", - "https://files.pythonhosted.org/packages/66/3f/cd37f76c8ca277b02a84aa20d7bd60fbac85b4e2cbdae77cb759b22de58b/nh3-0.3.0-cp38-abi3-musllinux_1_2_aarch64.whl": "634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62", - "https://files.pythonhosted.org/packages/6a/1b/b15bd1ce201a1a610aeb44afd478d55ac018b4475920a3118ffd806e2483/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl": "e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a", - "https://files.pythonhosted.org/packages/8c/ae/324b165d904dc1672eee5f5661c0a68d4bab5b59fbb07afb6d8d19a30b45/nh3-0.3.0-cp38-abi3-win_amd64.whl": "bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95", - "https://files.pythonhosted.org/packages/8f/14/079670fb2e848c4ba2476c5a7a2d1319826053f4f0368f61fca9bb4227ae/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49", - "https://files.pythonhosted.org/packages/97/03/03f79f7e5178eb1ad5083af84faff471e866801beb980cc72943a4397368/nh3-0.3.0-cp38-abi3-musllinux_1_2_i686.whl": "c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450", - "https://files.pythonhosted.org/packages/97/33/11e7273b663839626f714cb68f6eb49899da5a0d9b6bc47b41fe870259c2/nh3-0.3.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392", - "https://files.pythonhosted.org/packages/9a/e0/af86d2a974c87a4ba7f19bc3b44a8eaa3da480de264138fec82fe17b340b/nh3-0.3.0-cp313-cp313t-win_arm64.whl": "16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f", - "https://files.pythonhosted.org/packages/a3/e5/ac7fc565f5d8bce7f979d1afd68e8cb415020d62fa6507133281c7d49f91/nh3-0.3.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl": "af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb", - "https://files.pythonhosted.org/packages/ad/7f/7c6b8358cf1222921747844ab0eef81129e9970b952fcb814df417159fb9/nh3-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2", - "https://files.pythonhosted.org/packages/b4/11/340b7a551916a4b2b68c54799d710f86cf3838a4abaad8e74d35360343bb/nh3-0.3.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb", - "https://files.pythonhosted.org/packages/c3/a4/96cff0977357f60f06ec4368c4c7a7a26cccfe7c9fcd54f5378bf0428fd3/nh3-0.3.0.tar.gz": "d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f", - "https://files.pythonhosted.org/packages/c9/50/76936ec021fe1f3270c03278b8af5f2079038116b5d0bfe8538ffe699d69/nh3-0.3.0-cp38-abi3-win32.whl": "6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d", - "https://files.pythonhosted.org/packages/ce/55/1974bcc16884a397ee699cebd3914e1f59be64ab305533347ca2d983756f/nh3-0.3.0-cp38-abi3-musllinux_1_2_x86_64.whl": "3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518", - "https://files.pythonhosted.org/packages/ee/db/7aa11b44bae4e7474feb1201d8dee04fabe5651c7cb51409ebda94a4ed67/nh3-0.3.0-cp38-abi3-musllinux_1_2_armv7l.whl": "b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23", - "https://files.pythonhosted.org/packages/f3/ba/59e204d90727c25b253856e456ea61265ca810cda8ee802c35f3fadaab00/nh3-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl": "e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35" - }, - "pkginfo": { - "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz": "5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b", - "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl": "c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343" + "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl": "edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", + "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl": "455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", + "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", + "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl": "f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", + "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl": "36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", + "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl": "082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", + "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl": "44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", + "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl": "2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", + "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl": "4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", + "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl": "25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", + "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", + "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl": "889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", + "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl": "f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", + "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl": "34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", + "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", + "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz": "f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", + "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl": "69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", + "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl": "43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", + "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl": "82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", + "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl": "69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", + "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl": "f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", + "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", + "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", + "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", + "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl": "5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", + "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl": "597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", + "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25" + }, + "packaging": { + "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz": "ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", + "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl": "5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, "pycparser": { "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", - "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz": "600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", - "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl": "e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", - "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz": "78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2" + "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz": "600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" }, "pygments": { - "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz": "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", - "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl": "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz": "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl": "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, "pywin32-ctypes": { "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz": "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl": "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" }, "readme-renderer": { - "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz": "8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", - "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl": "2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151" + "https://files.pythonhosted.org/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz": "030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", + "https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl": "3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f" }, "requests": { - "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz": "c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", - "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl": "3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b" + "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl": "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", + "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz": "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" }, "requests-toolbelt": { "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl": "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", @@ -632,28 +648,28 @@ "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl": "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" }, "rich": { - "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl": "536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", - "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz": "e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8" + "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl": "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", + "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz": "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" }, "secretstorage": { - "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz": "2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", - "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl": "f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" + "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz": "f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", + "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl": "0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" }, "six": { "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz": "ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl": "4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, "twine": { - "https://files.pythonhosted.org/packages/5d/ec/00f9d5fd040ae29867355e559a94e9a8429225a0284a3f5f091a3878bfc0/twine-5.1.1-py3-none-any.whl": "215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997", - "https://files.pythonhosted.org/packages/77/68/bd982e5e949ef8334e6f7dcf76ae40922a8750aa2e347291ae1477a4782b/twine-5.1.1.tar.gz": "9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db" + "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz": "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", + "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl": "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" }, "urllib3": { - "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl": "bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", - "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz": "1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" + "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz": "231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", + "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, "zipp": { - "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl": "071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", - "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz": "a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" + "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl": "25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", + "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz": "4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602" } } }, diff --git a/tools/publish/requirements_darwin.txt b/tools/publish/requirements_darwin.txt index dd292fbaee..5022924d80 100644 --- a/tools/publish/requirements_darwin.txt +++ b/tools/publish/requirements_darwin.txt @@ -6,9 +6,9 @@ backports-tarfile==1.2.0 \ --hash=sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34 \ --hash=sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991 # via jaraco-context -certifi==2025.10.5 \ - --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ - --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ @@ -105,95 +105,98 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -docutils==0.22.2 \ - --hash=sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d \ - --hash=sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8 +docutils==0.23 \ + --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \ + --hash=sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e # via readme-renderer -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca + # via twine +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests -importlib-metadata==8.7.0 \ - --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \ - --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd - # via - # keyring - # twine +importlib-metadata==9.0.0 \ + --hash=sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7 \ + --hash=sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc + # via keyring jaraco-classes==3.4.0 \ --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 # via keyring -jaraco-context==6.0.1 \ - --hash=sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3 \ - --hash=sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4 +jaraco-context==6.1.2 \ + --hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \ + --hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3 # via keyring -jaraco-functools==4.3.0 \ - --hash=sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8 \ - --hash=sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294 +jaraco-functools==4.6.0 \ + --hash=sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280 \ + --hash=sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30 # via keyring -keyring==25.6.0 \ - --hash=sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66 \ - --hash=sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd +keyring==25.7.0 \ + --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \ + --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b # via twine -markdown-it-py==4.0.0 \ - --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ - --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a # via rich mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -more-itertools==10.8.0 \ - --hash=sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b \ - --hash=sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd +more-itertools==11.1.0 \ + --hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \ + --hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192 # via # jaraco-classes # jaraco-functools -nh3==0.3.0 \ - --hash=sha256:0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5 \ - --hash=sha256:16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f \ - --hash=sha256:1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9 \ - --hash=sha256:37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9 \ - --hash=sha256:389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392 \ - --hash=sha256:3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518 \ - --hash=sha256:423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e \ - --hash=sha256:634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62 \ - --hash=sha256:6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d \ - --hash=sha256:7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5 \ - --hash=sha256:7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49 \ - --hash=sha256:7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2 \ - --hash=sha256:80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d \ - --hash=sha256:a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb \ - --hash=sha256:af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb \ - --hash=sha256:b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23 \ - --hash=sha256:ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95 \ - --hash=sha256:bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95 \ - --hash=sha256:c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450 \ - --hash=sha256:ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1 \ - --hash=sha256:d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f \ - --hash=sha256:d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2 \ - --hash=sha256:e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35 \ - --hash=sha256:e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a \ - --hash=sha256:ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a \ - --hash=sha256:f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1 +nh3==0.3.6 \ + --hash=sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56 \ + --hash=sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2 \ + --hash=sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec \ + --hash=sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0 \ + --hash=sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e \ + --hash=sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f \ + --hash=sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6 \ + --hash=sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae \ + --hash=sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf \ + --hash=sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6 \ + --hash=sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da \ + --hash=sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796 \ + --hash=sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41 \ + --hash=sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21 \ + --hash=sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78 \ + --hash=sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4 \ + --hash=sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e \ + --hash=sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25 \ + --hash=sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e \ + --hash=sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d \ + --hash=sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69 \ + --hash=sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d \ + --hash=sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9 \ + --hash=sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917 \ + --hash=sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10 \ + --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ + --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -pkginfo==1.12.1.2 \ - --hash=sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b \ - --hash=sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343 +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via twine -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via # readme-renderer # rich -readme-renderer==44.0 \ - --hash=sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151 \ - --hash=sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1 +readme-renderer==45.0 \ + --hash=sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1 \ + --hash=sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f # via twine -requests==2.33.0 \ - --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ - --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via # requests-toolbelt # twine @@ -205,21 +208,22 @@ rfc3986==2.0.0 \ --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \ --hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c # via twine -rich==14.1.0 \ - --hash=sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f \ - --hash=sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8 +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 # via twine -twine==5.1.1 \ - --hash=sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997 \ - --hash=sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db +twine==7.0.0 \ + --hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \ + --hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7 # via -r tools/publish/requirements.in -urllib3==2.6.3 \ - --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ - --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 # via + # id # requests # twine -zipp==3.23.0 \ - --hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e \ - --hash=sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166 +zipp==4.1.0 \ + --hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \ + --hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602 # via importlib-metadata diff --git a/tools/publish/requirements_linux.txt b/tools/publish/requirements_linux.txt index cfba2aceea..41b0755b12 100644 --- a/tools/publish/requirements_linux.txt +++ b/tools/publish/requirements_linux.txt @@ -6,95 +6,111 @@ backports-tarfile==1.2.0 \ --hash=sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34 \ --hash=sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991 # via jaraco-context -certifi==2025.10.5 \ - --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ - --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests -cffi==2.0.0 \ - --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ - --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ - --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ - --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ - --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ - --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ - --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ - --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ - --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ - --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ - --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ - --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ - --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ - --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ - --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ - --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ - --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ - --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ - --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ - --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ - --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ - --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ - --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ - --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ - --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ - --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ - --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ - --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ - --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ - --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ - --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ - --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ - --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ - --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ - --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ - --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ - --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ - --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ - --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ - --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ - --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ - --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ - --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ - --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ - --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ - --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ - --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ - --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ - --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ - --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ - --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ - --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ - --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ - --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ - --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ - --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ - --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ - --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ - --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ - --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ - --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ - --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ - --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ - --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ - --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ - --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ - --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ - --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ - --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ - --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ - --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ - --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ - --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ - --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ - --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ - --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ - --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ - --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ - --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ - --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ - --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ - --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ - --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ - --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf +cffi==2.1.0 \ + --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ + --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ + --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ + --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ + --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ + --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ + --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ + --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ + --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ + --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ + --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ + --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ + --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ + --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ + --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ + --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ + --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ + --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ + --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ + --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ + --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ + --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ + --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ + --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ + --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ + --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ + --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ + --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ + --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ + --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ + --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ + --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ + --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ + --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ + --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ + --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ + --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ + --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ + --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ + --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ + --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ + --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ + --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ + --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ + --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ + --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ + --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ + --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ + --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ + --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ + --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ + --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ + --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ + --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ + --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ + --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ + --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ + --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ + --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ + --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ + --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ + --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ + --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ + --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ + --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ + --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ + --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ + --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ + --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ + --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ + --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ + --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ + --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ + --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ + --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ + --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ + --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ + --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ + --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ + --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ + --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ + --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ + --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ + --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ + --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ + --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ + --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ + --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ + --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ + --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ + --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ + --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ + --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ + --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ + --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ + --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ + --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ + --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ + --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f # via cryptography charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ @@ -191,82 +207,81 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -cryptography==46.0.7 \ - --hash=sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65 \ - --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ - --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ - --hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \ - --hash=sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4 \ - --hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \ - --hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \ - --hash=sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968 \ - --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ - --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \ - --hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \ - --hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \ - --hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \ - --hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \ - --hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \ - --hash=sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f \ - --hash=sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee \ - --hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \ - --hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \ - --hash=sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99 \ - --hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \ - --hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \ - --hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \ - --hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \ - --hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \ - --hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \ - --hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \ - --hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \ - --hash=sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d \ - --hash=sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1 \ - --hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \ - --hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \ - --hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \ - --hash=sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455 \ - --hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \ - --hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \ - --hash=sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15 \ - --hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \ - --hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \ - --hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \ - --hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \ - --hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \ - --hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \ - --hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \ - --hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \ - --hash=sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f \ - --hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \ - --hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \ - --hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce +cryptography==49.0.0 \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b # via secretstorage -docutils==0.22.2 \ - --hash=sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d \ - --hash=sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8 +docutils==0.23 \ + --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \ + --hash=sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e # via readme-renderer -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca + # via twine +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests -importlib-metadata==8.7.0 \ - --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \ - --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd - # via - # keyring - # twine +importlib-metadata==9.0.0 \ + --hash=sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7 \ + --hash=sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc + # via keyring jaraco-classes==3.4.0 \ --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 # via keyring -jaraco-context==6.0.1 \ - --hash=sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3 \ - --hash=sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4 +jaraco-context==6.1.2 \ + --hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \ + --hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3 # via keyring -jaraco-functools==4.3.0 \ - --hash=sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8 \ - --hash=sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294 +jaraco-functools==4.6.0 \ + --hash=sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280 \ + --hash=sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30 # via keyring jeepney==0.9.0 \ --hash=sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683 \ @@ -274,73 +289,74 @@ jeepney==0.9.0 \ # via # keyring # secretstorage -keyring==25.6.0 \ - --hash=sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66 \ - --hash=sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd +keyring==25.7.0 \ + --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \ + --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b # via twine -markdown-it-py==4.0.0 \ - --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ - --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a # via rich mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -more-itertools==10.8.0 \ - --hash=sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b \ - --hash=sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd +more-itertools==11.1.0 \ + --hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \ + --hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192 # via # jaraco-classes # jaraco-functools -nh3==0.3.0 \ - --hash=sha256:0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5 \ - --hash=sha256:16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f \ - --hash=sha256:1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9 \ - --hash=sha256:37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9 \ - --hash=sha256:389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392 \ - --hash=sha256:3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518 \ - --hash=sha256:423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e \ - --hash=sha256:634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62 \ - --hash=sha256:6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d \ - --hash=sha256:7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5 \ - --hash=sha256:7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49 \ - --hash=sha256:7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2 \ - --hash=sha256:80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d \ - --hash=sha256:a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb \ - --hash=sha256:af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb \ - --hash=sha256:b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23 \ - --hash=sha256:ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95 \ - --hash=sha256:bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95 \ - --hash=sha256:c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450 \ - --hash=sha256:ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1 \ - --hash=sha256:d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f \ - --hash=sha256:d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2 \ - --hash=sha256:e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35 \ - --hash=sha256:e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a \ - --hash=sha256:ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a \ - --hash=sha256:f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1 +nh3==0.3.6 \ + --hash=sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56 \ + --hash=sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2 \ + --hash=sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec \ + --hash=sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0 \ + --hash=sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e \ + --hash=sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f \ + --hash=sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6 \ + --hash=sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae \ + --hash=sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf \ + --hash=sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6 \ + --hash=sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da \ + --hash=sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796 \ + --hash=sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41 \ + --hash=sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21 \ + --hash=sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78 \ + --hash=sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4 \ + --hash=sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e \ + --hash=sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25 \ + --hash=sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e \ + --hash=sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d \ + --hash=sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69 \ + --hash=sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d \ + --hash=sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9 \ + --hash=sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917 \ + --hash=sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10 \ + --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ + --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -pkginfo==1.12.1.2 \ - --hash=sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b \ - --hash=sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343 +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via twine -pycparser==2.23 \ - --hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \ - --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via # readme-renderer # rich -readme-renderer==44.0 \ - --hash=sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151 \ - --hash=sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1 +readme-renderer==45.0 \ + --hash=sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1 \ + --hash=sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f # via twine -requests==2.33.0 \ - --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ - --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via # requests-toolbelt # twine @@ -352,25 +368,26 @@ rfc3986==2.0.0 \ --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \ --hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c # via twine -rich==14.1.0 \ - --hash=sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f \ - --hash=sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8 +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 # via twine -secretstorage==3.3.3 \ - --hash=sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77 \ - --hash=sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99 +secretstorage==3.5.0 \ + --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 \ + --hash=sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be # via keyring -twine==5.1.1 \ - --hash=sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997 \ - --hash=sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db +twine==7.0.0 \ + --hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \ + --hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7 # via -r tools/publish/requirements.in -urllib3==2.6.3 \ - --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ - --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 # via + # id # requests # twine -zipp==3.23.0 \ - --hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e \ - --hash=sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166 +zipp==4.1.0 \ + --hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \ + --hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602 # via importlib-metadata diff --git a/tools/publish/requirements_universal.txt b/tools/publish/requirements_universal.txt index 4ab1911981..bd17c90a54 100644 --- a/tools/publish/requirements_universal.txt +++ b/tools/publish/requirements_universal.txt @@ -2,82 +2,115 @@ # bazel run //tools/publish:requirements_universal.update --index-url https://pypi.org/simple -backports-tarfile==1.2.0 ; python_full_version < '3.12' \ +backports-tarfile==1.2.0 ; python_full_version < '3.12' and platform_machine != 'ppc64le' and platform_machine != 's390x' \ --hash=sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34 \ --hash=sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991 # via jaraco-context -certifi==2025.10.5 \ - --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ - --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests -cffi==2.0.0 ; platform_python_implementation != 'PyPy' and sys_platform == 'linux' \ - --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ - --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ - --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ - --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ - --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ - --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ - --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ - --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ - --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ - --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ - --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ - --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ - --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ - --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ - --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ - --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ - --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ - --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ - --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ - --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ - --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ - --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ - --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ - --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ - --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ - --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ - --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ - --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ - --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ - --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ - --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ - --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ - --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ - --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ - --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ - --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ - --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ - --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ - --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ - --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ - --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ - --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ - --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ - --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ - --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ - --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ - --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ - --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ - --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ - --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ - --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ - --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ - --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ - --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ - --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ - --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ - --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ - --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ - --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ - --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ - --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ - --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ - --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ - --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ - --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ - --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ - --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b +cffi==2.1.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' \ + --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ + --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ + --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ + --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ + --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ + --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ + --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ + --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ + --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ + --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ + --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ + --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ + --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ + --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ + --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ + --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ + --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ + --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ + --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ + --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ + --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ + --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ + --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ + --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ + --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ + --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ + --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ + --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ + --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ + --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ + --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ + --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ + --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ + --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ + --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ + --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ + --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ + --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ + --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ + --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ + --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ + --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ + --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ + --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ + --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ + --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ + --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ + --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ + --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ + --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ + --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ + --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ + --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ + --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ + --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ + --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ + --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ + --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ + --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ + --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ + --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ + --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ + --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ + --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ + --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ + --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ + --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ + --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ + --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ + --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ + --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ + --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ + --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ + --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ + --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ + --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ + --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ + --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ + --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ + --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ + --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ + --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ + --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ + --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ + --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ + --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ + --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ + --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ + --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ + --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ + --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ + --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ + --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ + --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ + --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ + --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ + --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ + --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ + --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f # via cryptography charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ @@ -174,160 +207,160 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -cryptography==46.0.7 ; sys_platform == 'linux' \ - --hash=sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65 \ - --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ - --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ - --hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \ - --hash=sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4 \ - --hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \ - --hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \ - --hash=sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968 \ - --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ - --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \ - --hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \ - --hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \ - --hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \ - --hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \ - --hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \ - --hash=sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f \ - --hash=sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee \ - --hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \ - --hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \ - --hash=sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99 \ - --hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \ - --hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \ - --hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \ - --hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \ - --hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \ - --hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \ - --hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \ - --hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \ - --hash=sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d \ - --hash=sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1 \ - --hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \ - --hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \ - --hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \ - --hash=sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455 \ - --hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \ - --hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \ - --hash=sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15 \ - --hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \ - --hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \ - --hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \ - --hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \ - --hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \ - --hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \ - --hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \ - --hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \ - --hash=sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f \ - --hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \ - --hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \ - --hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce +cryptography==49.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b # via secretstorage -docutils==0.22.2 \ - --hash=sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d \ - --hash=sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8 +docutils==0.23 \ + --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \ + --hash=sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e # via readme-renderer -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca + # via twine +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests -importlib-metadata==8.7.0 \ - --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \ - --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd - # via - # keyring - # twine -jaraco-classes==3.4.0 \ +importlib-metadata==9.0.0 ; python_full_version < '3.12' and platform_machine != 'ppc64le' and platform_machine != 's390x' \ + --hash=sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7 \ + --hash=sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc + # via keyring +jaraco-classes==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \ --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 # via keyring -jaraco-context==6.0.1 \ - --hash=sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3 \ - --hash=sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4 +jaraco-context==6.1.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \ + --hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \ + --hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3 # via keyring -jaraco-functools==4.3.0 \ - --hash=sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8 \ - --hash=sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294 +jaraco-functools==4.6.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \ + --hash=sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280 \ + --hash=sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30 # via keyring -jeepney==0.9.0 ; sys_platform == 'linux' \ +jeepney==0.9.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \ --hash=sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683 \ --hash=sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732 # via # keyring # secretstorage -keyring==25.6.0 \ - --hash=sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66 \ - --hash=sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd +keyring==25.7.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \ + --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \ + --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b # via twine -markdown-it-py==4.0.0 \ - --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ - --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a # via rich mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -more-itertools==10.8.0 \ - --hash=sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b \ - --hash=sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd +more-itertools==11.1.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \ + --hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \ + --hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192 # via # jaraco-classes # jaraco-functools -nh3==0.3.0 \ - --hash=sha256:0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5 \ - --hash=sha256:16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f \ - --hash=sha256:1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9 \ - --hash=sha256:37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9 \ - --hash=sha256:389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392 \ - --hash=sha256:3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518 \ - --hash=sha256:423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e \ - --hash=sha256:634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62 \ - --hash=sha256:6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d \ - --hash=sha256:7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5 \ - --hash=sha256:7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49 \ - --hash=sha256:7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2 \ - --hash=sha256:80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d \ - --hash=sha256:a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb \ - --hash=sha256:af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb \ - --hash=sha256:b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23 \ - --hash=sha256:ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95 \ - --hash=sha256:bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95 \ - --hash=sha256:c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450 \ - --hash=sha256:ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1 \ - --hash=sha256:d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f \ - --hash=sha256:d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2 \ - --hash=sha256:e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35 \ - --hash=sha256:e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a \ - --hash=sha256:ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a \ - --hash=sha256:f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1 +nh3==0.3.6 \ + --hash=sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56 \ + --hash=sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2 \ + --hash=sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec \ + --hash=sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0 \ + --hash=sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e \ + --hash=sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f \ + --hash=sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6 \ + --hash=sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae \ + --hash=sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf \ + --hash=sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6 \ + --hash=sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da \ + --hash=sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796 \ + --hash=sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41 \ + --hash=sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21 \ + --hash=sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78 \ + --hash=sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4 \ + --hash=sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e \ + --hash=sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25 \ + --hash=sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e \ + --hash=sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d \ + --hash=sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69 \ + --hash=sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d \ + --hash=sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9 \ + --hash=sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917 \ + --hash=sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10 \ + --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ + --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -pkginfo==1.12.1.2 \ - --hash=sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b \ - --hash=sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343 +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via twine -pycparser==2.23 ; platform_python_implementation != 'PyPy' and sys_platform == 'linux' \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pycparser==3.0 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via # readme-renderer # rich -pywin32-ctypes==0.2.3 ; sys_platform == 'win32' \ +pywin32-ctypes==0.2.3 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'win32' \ --hash=sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8 \ --hash=sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755 # via keyring -readme-renderer==44.0 \ - --hash=sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151 \ - --hash=sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1 +readme-renderer==45.0 \ + --hash=sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1 \ + --hash=sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f # via twine -requests==2.33.0 \ - --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ - --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via # requests-toolbelt # twine @@ -339,25 +372,26 @@ rfc3986==2.0.0 \ --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \ --hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c # via twine -rich==14.1.0 \ - --hash=sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f \ - --hash=sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8 +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 # via twine -secretstorage==3.3.3 ; sys_platform == 'linux' \ - --hash=sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77 \ - --hash=sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99 +secretstorage==3.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \ + --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 \ + --hash=sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be # via keyring -twine==5.1.1 \ - --hash=sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997 \ - --hash=sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db +twine==7.0.0 \ + --hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \ + --hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7 # via -r tools/publish/requirements.in -urllib3==2.6.3 \ - --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ - --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 # via + # id # requests # twine -zipp==3.23.0 \ - --hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e \ - --hash=sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166 +zipp==4.1.0 ; python_full_version < '3.12' and platform_machine != 'ppc64le' and platform_machine != 's390x' \ + --hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \ + --hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602 # via importlib-metadata diff --git a/tools/publish/requirements_windows.txt b/tools/publish/requirements_windows.txt index 1cbd8197fe..b235d95a52 100644 --- a/tools/publish/requirements_windows.txt +++ b/tools/publish/requirements_windows.txt @@ -6,9 +6,9 @@ backports-tarfile==1.2.0 \ --hash=sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34 \ --hash=sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991 # via jaraco-context -certifi==2025.10.5 \ - --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ - --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ @@ -105,85 +105,88 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -docutils==0.22.2 \ - --hash=sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d \ - --hash=sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8 +docutils==0.23 \ + --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \ + --hash=sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e # via readme-renderer -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca + # via twine +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests -importlib-metadata==8.7.0 \ - --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \ - --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd - # via - # keyring - # twine +importlib-metadata==9.0.0 \ + --hash=sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7 \ + --hash=sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc + # via keyring jaraco-classes==3.4.0 \ --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 # via keyring -jaraco-context==6.0.1 \ - --hash=sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3 \ - --hash=sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4 +jaraco-context==6.1.2 \ + --hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \ + --hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3 # via keyring -jaraco-functools==4.3.0 \ - --hash=sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8 \ - --hash=sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294 +jaraco-functools==4.6.0 \ + --hash=sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280 \ + --hash=sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30 # via keyring -keyring==25.6.0 \ - --hash=sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66 \ - --hash=sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd +keyring==25.7.0 \ + --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \ + --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b # via twine -markdown-it-py==4.0.0 \ - --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ - --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a # via rich mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -more-itertools==10.8.0 \ - --hash=sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b \ - --hash=sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd +more-itertools==11.1.0 \ + --hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \ + --hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192 # via # jaraco-classes # jaraco-functools -nh3==0.3.0 \ - --hash=sha256:0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5 \ - --hash=sha256:16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f \ - --hash=sha256:1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9 \ - --hash=sha256:37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9 \ - --hash=sha256:389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392 \ - --hash=sha256:3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518 \ - --hash=sha256:423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e \ - --hash=sha256:634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62 \ - --hash=sha256:6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d \ - --hash=sha256:7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5 \ - --hash=sha256:7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49 \ - --hash=sha256:7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2 \ - --hash=sha256:80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d \ - --hash=sha256:a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb \ - --hash=sha256:af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb \ - --hash=sha256:b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23 \ - --hash=sha256:ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95 \ - --hash=sha256:bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95 \ - --hash=sha256:c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450 \ - --hash=sha256:ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1 \ - --hash=sha256:d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f \ - --hash=sha256:d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2 \ - --hash=sha256:e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35 \ - --hash=sha256:e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a \ - --hash=sha256:ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a \ - --hash=sha256:f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1 +nh3==0.3.6 \ + --hash=sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56 \ + --hash=sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2 \ + --hash=sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec \ + --hash=sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0 \ + --hash=sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e \ + --hash=sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f \ + --hash=sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6 \ + --hash=sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae \ + --hash=sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf \ + --hash=sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6 \ + --hash=sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da \ + --hash=sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796 \ + --hash=sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41 \ + --hash=sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21 \ + --hash=sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78 \ + --hash=sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4 \ + --hash=sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e \ + --hash=sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25 \ + --hash=sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e \ + --hash=sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d \ + --hash=sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69 \ + --hash=sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d \ + --hash=sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9 \ + --hash=sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917 \ + --hash=sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10 \ + --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ + --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -pkginfo==1.12.1.2 \ - --hash=sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b \ - --hash=sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343 +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via twine -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via # readme-renderer # rich @@ -191,13 +194,13 @@ pywin32-ctypes==0.2.3 \ --hash=sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8 \ --hash=sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755 # via keyring -readme-renderer==44.0 \ - --hash=sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151 \ - --hash=sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1 +readme-renderer==45.0 \ + --hash=sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1 \ + --hash=sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f # via twine -requests==2.33.0 \ - --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ - --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via # requests-toolbelt # twine @@ -209,21 +212,22 @@ rfc3986==2.0.0 \ --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \ --hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c # via twine -rich==14.1.0 \ - --hash=sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f \ - --hash=sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8 +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 # via twine -twine==5.1.1 \ - --hash=sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997 \ - --hash=sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db +twine==7.0.0 \ + --hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \ + --hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7 # via -r tools/publish/requirements.in -urllib3==2.6.3 \ - --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ - --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 # via + # id # requests # twine -zipp==3.23.0 \ - --hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e \ - --hash=sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166 +zipp==4.1.0 \ + --hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \ + --hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602 # via importlib-metadata From be47c220ea68f025dc9aca2102d86275e6b38fec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:37:03 +0000 Subject: [PATCH 119/179] build(deps): bump secretstorage from 3.3.3 to 3.5.0 in /tools/publish (#3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [secretstorage](https://github.com/mitya57/secretstorage) from 3.3.3 to 3.5.0.
Changelog

Sourced from secretstorage's changelog.

SecretStorage 3.5.0, 2025-11-23

  • Added timeout argument to the unlock() methods of Collection and Item [[#33](https://github.com/mitya57/secretstorage/issues/33)_].
  • Removed int_to_bytes() function in favor of the built-in method.

.. _[#33](https://github.com/mitya57/secretstorage/issues/33): mitya57/secretstorage#33

SecretStorage 3.4.1, 2025-11-11

  • Make sure public key length is exactly 128 bytes [[#48](https://github.com/mitya57/secretstorage/issues/48)_]. This fixes Client public key size is invalid error from KWallet.

.. _[#48](https://github.com/mitya57/secretstorage/issues/48): mitya57/secretstorage#48

SecretStorage 3.4.0, 2025-09-09

  • Handle D-Bus UnknownObject error when no collection is found [[#43](https://github.com/mitya57/secretstorage/issues/43)_]. Thanks to Renato Alencar for the pull request!
  • Added __repr__ methods to Collection and Item classes [[#47](https://github.com/mitya57/secretstorage/issues/47)_].
  • Moved project metadata to pyproject.toml.
  • Python ≥ 3.10 and setuptools ≥ 77.0 are now required.
  • Various code modernizations. Thanks to Hugo van Kemenade and Tomasz Kłoczko for the pull requests!

.. _[#43](https://github.com/mitya57/secretstorage/issues/43): mitya57/secretstorage#43 .. _[#47](https://github.com/mitya57/secretstorage/issues/47): mitya57/secretstorage#47

Commits
  • 3a3c006 Releasing version 3.5.0
  • fed7361 docs: Replace broken KeePassXC link with a working one
  • a97aa4b Update copyright years, again
  • d8d1ea0 Add timeout argument to unlock() methods
  • 2db9b67 util: Correct documentation for the exec_prompt() function
  • cc1e18d Get rid of int_to_bytes() function
  • d036431 Releasing version 3.4.1
  • d583dc6 Run the tests with Python 3.14
  • 070e560 Update copyright years
  • bff61ca Make sure my_public_key length is exactly 128 bytes
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=secretstorage&package-manager=pip&previous-version=3.3.3&new-version=3.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Richard Levasseur From 905a1c1d061cedfb31b058fb866d9261710f9469 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:37:23 +0000 Subject: [PATCH 120/179] build(deps): bump the pip group across 1 directory with 3 updates (#3912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the pip group with 3 updates in the /tools/publish directory: [cryptography](https://github.com/pyca/cryptography), [idna](https://github.com/kjd/idna) and [urllib3](https://github.com/urllib3/urllib3). Updates `cryptography` from 46.0.7 to 48.0.1
Changelog

Sourced from cryptography's changelog.

48.0.1 - 2026-06-09


* Updated Windows, macOS, and Linux wheels to be compiled with OpenSSL
4.0.1.

.. _v48-0-0:

48.0.0 - 2026-05-04

  • BACKWARDS INCOMPATIBLE: Support for Python 3.8 has been removed. cryptography now requires Python 3.9 or later.

  • BACKWARDS INCOMPATIBLE: Loading an X.509 CRL whose inner TBSCertList.signature algorithm does not match the outer signatureAlgorithm now raises ValueError. Previously, such CRLs were parsed successfully and only rejected during signature validation.

  • Added support for :doc:/hazmat/primitives/asymmetric/mlkem and :doc:/hazmat/primitives/asymmetric/mldsa when using OpenSSL 3.5.0 or later, in addition to the existing AWS-LC and BoringSSL support. This means post-quantum algorithms are now available to users of our wheels.

    • Note: Going forward, we do not guarantee that all functionality in cryptography will be available when building against OpenSSL. See :doc:/statements/state-of-openssl for more information.

.. _v47-0-0:

47.0.0 - 2026-04-24


* Support for Python 3.8 is deprecated and will be removed in the next
  ``cryptography`` release.
* **BACKWARDS INCOMPATIBLE:** Support for binary elliptic curves
  (``SECT*`` classes) has been removed. These curves are rarely used and
  have additional security considerations that make them undesirable.
* **BACKWARDS INCOMPATIBLE:** Support for OpenSSL 1.1.x has been
removed.
OpenSSL 3.0.0 or later is now required. LibreSSL, BoringSSL, and AWS-LC
  continue to be supported.
* **BACKWARDS INCOMPATIBLE:** Dropped support for LibreSSL < 4.1.
* **BACKWARDS INCOMPATIBLE:** Loading keys with unsupported algorithms
or
  keys with unsupported explicit curve encodings now raises
  :class:`~cryptography.exceptions.UnsupportedAlgorithm` instead of
  ``ValueError``. This change affects

:func:`~cryptography.hazmat.primitives.serialization.load_pem_private_key`,

:func:`~cryptography.hazmat.primitives.serialization.load_der_private_key`,

:func:`~cryptography.hazmat.primitives.serialization.load_pem_public_key`,

:func:`~cryptography.hazmat.primitives.serialization.load_der_public_key`,
  and :meth:`~cryptography.x509.Certificate.public_key` when called on
  certificates with unsupported public key algorithms.
</tr></table>

... (truncated)

Commits

Updates `idna` from 3.10 to 3.15
Changelog

Sourced from idna's changelog.

3.15 (2026-05-12)

  • Enforce DNS-length cap on individual labels early in check_label, short-circuiting contextual-rule processing for oversized input while staying compatible with UTS 46 usage.
  • Tidy core helpers: hoist bidi category sets to module-level frozensets (avoiding per-codepoint list construction), simplify length checks, and reuse the shared _unicode_dots_re from idna.core in the codec module.
  • Use raise ... from err for proper exception chaining and switch internal string formatting to f-strings.
  • Allow flit_core 4.x in the build backend.
  • Expand the ruff lint set (flake8-bugbear, flake8-simplify, pyupgrade, perflint) and apply the surfaced fixes; pin lint CI to Python 3.14.
  • Add Dependabot configuration for GitHub Actions.
  • Convert README and HISTORY from reStructuredText to Markdown.
  • Reference CVE-2026-45409 for the 3.14 advisory in place of the initial GHSA identifier.

Thanks to Felix Yan, Stan Ulbrych, and metsw24-max for contributions to this release.

3.14 (2026-05-10)

  • Removed opportunity to process long inputs into quadratic time by rejecting oversize inputs up-front. Closes a bypass of the CVE-2024-3651 mitigation. [CVE-2026-45409]

Thanks to Stan Ulbrych for reporting the issue.

3.13 (2026-04-22)

  • Correct classification error for codepoint U+A7F1

3.12 (2026-04-21)

  • Update to Unicode 17.0.0.
  • Issue a deprecation warning for the transitional argument.
  • Added lazy-loading to provide some performance improvements.
  • Removed vestiges of code related to Python 2 support, including segmentation of data structures specific to Jython.

Thanks to Rodrigo Nogueira for contributions to this release.

3.11 (2025-10-12)

  • Update to Unicode 16.0.0, including significant changes to UTS46 processing. As a result of Unicode ending support for it, transitional processing no longer has an effect and returns the same result.

... (truncated)

Commits
  • af30a09 Release 3.15
  • 30314d4 Pre-release 3.15rc0
  • 05d4b21 Merge pull request #237 from kjd/convert-docs-to-markdown
  • 2987fdb Convert README and HISTORY from reStructuredText to Markdown
  • 59fa800 Merge pull request #236 from kjd/dependabot/github_actions/actions-f3e34333ea
  • def6983 Merge branch 'master' into dependabot/github_actions/actions-f3e34333ea
  • bbd8004 Merge pull request #234 from StanFromIreland/patch-1
  • edd07c0 Bump github/codeql-action from 3.35.2 to 4.35.2 in the actions group
  • 5557db0 Merge branch 'master' into patch-1
  • f11746c Merge pull request #235 from StanFromIreland/patch-2
  • Additional commits viewable in compare view

Updates `urllib3` from 2.6.3 to 2.7.0
Release notes

Sourced from urllib3's releases.

2.7.0

🚀 urllib3 is fundraising for HTTP/2 support

urllib3 is raising ~$40,000 USD to release HTTP/2 support and ensure long-term sustainable maintenance of the project after a sharp decline in financial support. If your company or organization uses Python and would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and thousands of other projects please consider contributing financially to ensure HTTP/2 support is developed sustainably and maintained for the long-haul.

Thank you for your support.

Security

Addressed high-severity security issues. Impact was limited to specific use cases detailed in the accompanying advisories; overall user exposure was estimated to be marginal.

  • Decompression-bomb safeguards of the streaming API were bypassed:

    1. When HTTPResponse.drain_conn() was called after the response had been read and decompressed partially. (Reported by @​Cycloctane)
    2. During the second HTTPResponse.read(amt=N) or HTTPResponse.stream(amt=N) call when the response was decompressed using the official Brotli library. (Reported by @​kimkou2024)

    See GHSA-mf9v-mfxr-j63j for details.

  • HTTP pools created using ProxyManager.connection_from_url did not strip sensitive headers specified in Retry.remove_headers_on_redirect when redirecting to a different host. (GHSA-qccp-gfcp-xxvc reported by @​christos-spearbit)

Deprecations and Removals

  • Used FutureWarning instead of DeprecationWarning for better visibility of existing deprecation notices. Rescheduled the removal of deprecated features to version 3.0. (urllib3/urllib3#3763)
  • Removed support for end-of-life Python 3.9. (urllib3/urllib3#3720)
  • Removed support for end-of-life PyPy3.10. (urllib3/urllib3#4979)
  • Bumped the minimum supported pyOpenSSL version to 19.0.0. (urllib3/urllib3#3777)

Bugfixes

  • Fixed a bug where HTTPResponse.read(amt=None) was ignoring decompressed data buffered from previous partial reads. (urllib3/urllib3#3636)
  • Fixed a bug where HTTPResponse.read() could cache only part of the response after a partial read when cache_content=True. (urllib3/urllib3#4967)
  • Fixed HTTPResponse.stream() and HTTPResponse.read_chunked() to handle amt=0. (urllib3/urllib3#3793)
  • Updated _TYPE_BODY type alias to include missing Iterable[str], matching the documented and runtime behavior of chunked request bodies. (urllib3/urllib3#3798)
  • Fixed LocationParseError when paths resembling schemeless URIs were passed to HTTPConnectionPool.urlopen(). (urllib3/urllib3#3352)
  • Fixed BaseHTTPResponse.readinto() type annotation to accept memoryview in addition to bytearray, matching the io.RawIOBase.readinto contract and enabling use with io.BufferedReader without type errors. (urllib3/urllib3#3764)
Changelog

Sourced from urllib3's changelog.

2.7.0 (2026-05-07)

Security

Addressed high-severity security issues. Impact was limited to specific use cases detailed in the accompanying advisories; overall user exposure was estimated to be marginal.

  • Decompression-bomb safeguards of the streaming API were bypassed:

    1. When HTTPResponse.drain_conn() was called after the response had been read and decompressed partially.
    2. During the second HTTPResponse.read(amt=N) or HTTPResponse.stream(amt=N) call when the response was decompressed using the official Brotli <https://pypi.org/project/brotli/>__ library.

    See GHSA-mf9v-mfxr-j63j <https://github.com/urllib3/urllib3/security/advisories/GHSA-mf9v-mfxr-j63j>__ for details.

  • HTTP pools created using ProxyManager.connection_from_url did not strip sensitive headers specified in Retry.remove_headers_on_redirect when redirecting to a different host. (GHSA-qccp-gfcp-xxvc <https://github.com/urllib3/urllib3/security/advisories/GHSA-qccp-gfcp-xxvc>__)

Deprecations and Removals

  • Used FutureWarning instead of DeprecationWarning for better visibility of existing deprecation notices. Rescheduled the removal of deprecated features to version 3.0. ([#3763](https://github.com/urllib3/urllib3/issues/3763) <https://github.com/urllib3/urllib3/issues/3763>__)
  • Removed support for end-of-life Python 3.9. ([#3720](https://github.com/urllib3/urllib3/issues/3720) <https://github.com/urllib3/urllib3/issues/3720>__)
  • Removed support for end-of-life PyPy3.10. ([#4979](https://github.com/urllib3/urllib3/issues/4979) <https://github.com/urllib3/urllib3/issues/4979>__)
  • Bumped the minimum supported pyOpenSSL version to 19.0.0. ([#3777](https://github.com/urllib3/urllib3/issues/3777) <https://github.com/urllib3/urllib3/issues/3777>__)

Bugfixes

  • Fixed a bug where HTTPResponse.read(amt=None) was ignoring decompressed data buffered from previous partial reads. ([#3636](https://github.com/urllib3/urllib3/issues/3636) <https://github.com/urllib3/urllib3/issues/3636>__)
  • Fixed a bug where HTTPResponse.read() could cache only part of the response after a partial read when cache_content=True.

... (truncated)

Commits

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Richard Levasseur From 66d00b3ae7381a17260f7c6bc8ca1cd58afc7b0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:40:07 -0700 Subject: [PATCH 121/179] build(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.2 to 9.0.0.
Release notes

Sourced from astral-sh/setup-uv's releases.

v9.0.0 🌈 Change prune-cache default to false

Changes

This release disables the default cache cache pruning to ease the load on the PyPi infrastructure. Since users might experience more GitHub Actions cache usage which might result in higher costs this is marked as a breaking change. To read more on why we did this (now) you can read the detailed analysis and reasoning in #967

Besides this big breaking change we also have a small bugfix while building caches for linux distributions that behave a big different than the "big ones" and a speed up in version resolution by only reading the version manifest until a matching version is found saving runtime and network bandwith.

🚨 Breaking changes

🐛 Bug fixes

  • fix: fall back to distribution ID when os-release has no version field @​cxzhong (#961)

🚀 Enhancements

🧰 Maintenance

📚 Documentation

⬆️ Dependency updates

Commits
  • c771a70 chore(deps): roll up Dependabot updates (#970)
  • 2f537ca chore: update known checksums for 0.11.30 (#968)
  • 2269552 Speed up version client by partial response reads (#807)
  • 47a7f4f Change prune-cache default to false (#967)
  • 71966ef chore(deps): roll up Dependabot updates (#962)
  • f12b1f0 fix: fall back to distribution ID when os-release has no version field (#961)
  • ecd24dd chore: update known checksums for 0.11.29 (#960)
  • 6a19136 docs: update version references to v8.3.2 (#949)
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/automated_pr_review.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/automated_pr_review.yaml b/.github/workflows/automated_pr_review.yaml index dbfffa9083..01b63e42a4 100644 --- a/.github/workflows/automated_pr_review.yaml +++ b/.github/workflows/automated_pr_review.yaml @@ -86,7 +86,7 @@ jobs: path: reviewbot - name: Install uv - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 - name: Run Antigravity Review # Run inside reviewbot directory so execution context is the trusted base branch. From 41665f5662719c25c730ab3349e89837e8a99a91 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 28 Jul 2026 22:36:51 -0700 Subject: [PATCH 122/179] feat(ci): support GitHub Actions log retrieval and subagent failure analysis (#3976) Previously, CI failure monitoring and log analysis were tied strictly to Buildkite jobs, preventing automated analysis of failed GitHub Actions checks. Furthermore, failure notifications were hardcoded without triggering subagent delegation. To address this, update `monitor_remote_ci.py` to report failed GitHub Actions checks back to the active conversation for subagent delegation. Additionally, enhance `analyze_ci_failure.py` to fetch job logs via the `gh` CLI and parse GitHub Actions error signatures. * Also updates documentation in `monitor-ci-results` and `analyze-ci-failure` skills to reflect the subagent workflow. --- .agents/skills/analyze-ci-failure/SKILL.md | 25 +++++++---- .../scripts/analyze_ci_failure.py | 23 +++++++++++ .agents/skills/monitor-ci-results/SKILL.md | 41 ++++++++----------- .../scripts/monitor_remote_ci.py | 11 +++-- 4 files changed, 66 insertions(+), 34 deletions(-) diff --git a/.agents/skills/analyze-ci-failure/SKILL.md b/.agents/skills/analyze-ci-failure/SKILL.md index 2019c5b5f9..6bda9d5d6b 100644 --- a/.agents/skills/analyze-ci-failure/SKILL.md +++ b/.agents/skills/analyze-ci-failure/SKILL.md @@ -1,16 +1,25 @@ --- name: analyze-ci-failure -description: Download and analyze a CI failure log to construct an actionable suggested fix plan and report back +description: Download and analyze a CI failure log to construct an actionable + suggested fix plan and report back --- -When a CI monitoring workflow alerts you to a failed Buildkite job or GitHub check, invoke this skill by running: +When a CI monitoring workflow alerts you to a failed Buildkite job or GitHub +check, invoke this skill by running: ```bash -./.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py "" "" "" "" +./.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py \ + "" "" "" "" ``` ### ✨ What this Skill Does -1. **Resolves Log**: Automatically resolves the Buildkite job download URL or locates existing local log artifacts. -2. **Downloads & Ingests**: Fetches the full raw CI log file and saves it locally. -3. **Smart Error Extraction**: Scans the log lines for critical failure signatures (`Traceback`, `ERROR:`, `FAILED:`, missing packages, compiler aborts). -4. **Fix Plan Synthesis**: Constructs a beautifully structured Markdown suggested plan on how to resolve the root cause. -5. **Natively Notifies**: Dispatches a high-priority summary notification message back to your active agent conversation via `agentapi send-message`! +1. **Resolves Log**: Automatically resolves the Buildkite job download URL or + fetches GitHub Actions logs via `gh` CLI. +2. **Downloads & Ingests**: Fetches the full raw CI log file and saves it + locally. +3. **Smart Error Extraction**: Scans log lines for critical failure signatures + (`Traceback`, `ERROR:`, `FAILED:`, missing packages, compiler aborts, linter + errors). +4. **Fix Plan Synthesis**: Constructs a beautifully structured Markdown + suggested plan on how to resolve the root cause. +5. **Natively Notifies**: Dispatches a high-priority summary notification + message back to your active agent conversation via `agentapi send-message`. diff --git a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py index 3b975acfb3..20212f6d23 100644 --- a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py +++ b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py @@ -16,6 +16,25 @@ def fetch_log(build_id, job_id, output_path): else: log_url = f"https://buildkite.com/organizations/bazel/pipelines/rules-python-python/builds/{build_id}/jobs/{job_id}/download.txt" + # Check if this is a GitHub Actions job + gh_match = re.search(r"github\.com/.*/job/(\d+)", log_url) or re.search( + r"^(\d+)$", job_id + ) + if gh_match: + gh_job_id = gh_match.group(1) + print(f"📥 Fetching GitHub Action log for job {gh_job_id} using gh CLI...") + cmd = ["gh", "run", "view", "--job", gh_job_id, "--log"] + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + with open(output_path, "w") as f: + f.write(res.stdout) + return True + except Exception as e: + print( + f"⚠️ Failed to fetch GitHub log via gh CLI for job {gh_job_id}: {e}", + file=sys.stderr, + ) + if not log_url.endswith("/download.txt") and "buildkite.com" in log_url: log_url = re.sub(r"/log$", "/download.txt", log_url) @@ -55,6 +74,10 @@ def parse_log(log_path): "no such package", "no such target", "exit code", + "##[error]", + "Would reformat:", + "would be reformatted", + "error]", ] ): errors.append(line.strip()) diff --git a/.agents/skills/monitor-ci-results/SKILL.md b/.agents/skills/monitor-ci-results/SKILL.md index 2b4bc5a460..1cef8293f4 100644 --- a/.agents/skills/monitor-ci-results/SKILL.md +++ b/.agents/skills/monitor-ci-results/SKILL.md @@ -1,31 +1,26 @@ --- name: monitor-ci-results -description: Monitor CI for PRs and notify of status +description: Monitor remote CI results for a PR and autonomously launch + subagents to analyze CI failures --- When the user requests to monitor remote CI results or watch a pull request, -invoke `scripts/monitor_remote_ci.py `. - -This long-running monitoring service runs in the background and continuously -polls both GitHub PR checks and Buildkite workflow executions. +launch the monitoring script in the background: +```bash +./.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py \ + "" & +``` -### ✨ Autonomous Failure and Blocked Job Orchestration -1. **Blocked Jobs**: When a Buildkite job or GitHub check is in a blocked +### ✨ Autonomous Subagent Orchestration +1. **Background Polling**: `monitor_remote_ci.py` continuously polls both + GitHub PR checks and Buildkite workflow executions in the background. +2. **Blocked Jobs**: When a Buildkite job or GitHub check is in a blocked state waiting for user confirmation, it dispatches a notification via `agentapi send-message` so the user is alerted to confirm running the job. -2. **Failure Analysis**: When any CI job completes with errors or returns a - non-zero exit code: - - It automatically downloads the raw CI log file to `ci_logs/`. - - It launches an independent background analyzer script - (`analyze_ci_failure.py`). - - It authors a structured Markdown plan to fix the failure. - - It natively dispatches a high-priority notification back to your active - agent conversation using `agentapi send-message`! - -### Example Invocation -```bash -./scripts/monitor_remote_ci.py 3812 "0be435bd-96aa-4e1b-9c6f-727b31e80fa0" & -``` -*Note: Always include the trailing `&` when launching the monitoring script via -tool calls to ensure it runs as a detached background task without blocking -foreground execution.* +3. **Failure Reporting**: When any GitHub check or Buildkite job completes + with errors, `monitor_remote_ci.py` dispatches a high-priority notification + message reporting the failed check back to your conversation. +4. **Subagent Analysis**: Upon receiving a failure notification message from + the monitoring script, immediately launch a separate subagent using the + `invoke_subagent` tool with the role "CI Failure Analyzer" to run the + `analyze-ci-failure` skill on the reported failure. diff --git a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py index 40be4d5d26..f668ed96f5 100755 --- a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py +++ b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py @@ -104,6 +104,7 @@ def main(): name = check.get("name", "unknown") state = check.get("state", "UNKNOWN") link = check.get("link", "") + bucket = check.get("bucket", "") if "buildkite" in name.lower() and link: jobs = get_buildkite_jobs(link) @@ -202,7 +203,7 @@ def main(): f"⚠️ Remote CI Buildkite Job '{jname}' completed with errors!\n\n" f"Build ID: {build_id} | Job ID: {jid}\n" f"Log URL: {job.get('log_url', link)}\n\n" - f"Start subagent: run analyze-ci-failure skill on this failure" + f"Action required: Start a subagent to run the analyze-ci-failure skill for '{jname}' with build ID '{build_id}' and job ID '{jid}'." ) subprocess.run( [ @@ -240,12 +241,16 @@ def main(): with open(state_file, "w") as f: json.dump(monitored, f) - elif state in ["FAILURE", "failed"] and name not in monitored: + elif ( + state.upper() in ["FAILURE", "FAIL", "CANCELLED", "TIMED_OUT"] + or bucket == "fail" + ) and name not in monitored: print(f"🚨 Notifying failure for GitHub check '{name}'...") + job_id = link.split("/")[-1] if "/" in link else link msg = ( f"⚠️ Remote CI GitHub Check '{name}' completed with errors!\n\n" f"Link: {link}\n\n" - f"Start subagent: run analyze-ci-failure skill on this failure" + f"Action required: Start a subagent to run the analyze-ci-failure skill for '{name}' with link '{link}' and job ID '{job_id}'." ) subprocess.run( [ From fd25d1f3107dfa9032cf39ee8262b1cde0338fc7 Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:59:24 +0900 Subject: [PATCH 123/179] refactor(pypi): add whl_deps_library repo rule (#3960) Summary: - Add a new repo rule to just read metadata.json - Add integration tests for the repository rules in `whl_library.bzl` file. - Make some of the arguments optional in the BUILD.bazel code generation. No changelog, because the rule is not yet exposed to the user in any way. Split out of #3856 Work towards #2948 Fixes #3071 --------- Co-authored-by: Richard Levasseur --- .bazelrc.deleted_packages | 1 + .../pypi/generate_whl_library_build_bazel.bzl | 32 ++- python/private/pypi/pep508_deps.bzl | 6 + python/private/pypi/whl_library.bzl | 90 +++++- python/private/pypi/whl_library_targets.bzl | 257 ++++++++++-------- .../py_library/py_library_tests.bzl | 59 ++++ tests/integration/BUILD.bazel | 4 + .../integration/bzlmod_lockfile/MODULE.bazel | 4 +- .../MODULE.bazel | 2 +- .../pip_parse_isolated/MODULE.bazel | 4 +- tests/integration/whl_library/.bazelrc | 2 + tests/integration/whl_library/BUILD.bazel | 65 +++++ tests/integration/whl_library/MODULE.bazel | 57 ++++ tests/integration/whl_library/WORKSPACE | 0 .../integration/whl_library/test_contents.py | 179 ++++++++++++ ...generate_whl_library_build_bazel_tests.bzl | 72 +++-- .../whl_library_targets_tests.bzl | 123 +++++---- 17 files changed, 736 insertions(+), 221 deletions(-) create mode 100644 tests/integration/whl_library/.bazelrc create mode 100644 tests/integration/whl_library/BUILD.bazel create mode 100644 tests/integration/whl_library/MODULE.bazel create mode 100644 tests/integration/whl_library/WORKSPACE create mode 100644 tests/integration/whl_library/test_contents.py diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index db42040b5c..9c4745c178 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -44,6 +44,7 @@ common --deleted_packages=tests/integration/toolchain_target_settings common --deleted_packages=tests/integration/unified_pypi common --deleted_packages=tests/integration/uv_lock common --deleted_packages=tests/integration/validate_test_main +common --deleted_packages=tests/integration/whl_library common --deleted_packages=tests/modules/another_module common --deleted_packages=tests/modules/other common --deleted_packages=tests/modules/other/nspkg_delta diff --git a/python/private/pypi/generate_whl_library_build_bazel.bzl b/python/private/pypi/generate_whl_library_build_bazel.bzl index 71eab26c0e..ceee319cd7 100644 --- a/python/private/pypi/generate_whl_library_build_bazel.bzl +++ b/python/private/pypi/generate_whl_library_build_bazel.bzl @@ -25,6 +25,7 @@ _RENDER = { "extras": render.list, "group_deps": render.list, "include": str, + "repo": lambda maybe_label: repr(str(maybe_label)), "requires_dist": render.list, "srcs_exclude": render.list, "tags": render.list, @@ -38,19 +39,22 @@ _TEMPLATE = """\ package(default_visibility = ["//visibility:public"]) +{fn}( +{kwargs} +) +""" + +_PURL = """\ package_metadata( name = "package_metadata", purl = {purl}, visibility = ["//:__subpackages__"], ) - -{fn}( -{kwargs} -) """ def generate_whl_library_build_bazel( *, + metadata_version, annotation = None, config_load, purl = None, @@ -59,6 +63,7 @@ def generate_whl_library_build_bazel( """Generate a BUILD file for an unzipped Wheel Args: + metadata_version: The version to use for tag generation. annotation: The annotation for the build file. config_load: {type}`str` The location from where to load the config. purl: The purl. @@ -74,14 +79,26 @@ def generate_whl_library_build_bazel( """load("@package_metadata//rules:package_metadata.bzl", "package_metadata")""", ] - fn = "whl_library_targets_from_requires" + if kwargs.get("repo"): + fn = "whl_library_deps_targets" + else: + fn = "whl_library_targets" + + tags = [ + "pypi_name={}".format(kwargs.get("metadata_name")), + "pypi_version={}".format(metadata_version), + ] + kwargs["tags"] = tags + if not requires_dist: # no deps, we can leave the extra loads out pass - else: + elif config_load: loads.append("""load("{}", "{}")""".format(config_load, "packages")) kwargs["include"] = "packages" kwargs["requires_dist"] = requires_dist + else: + kwargs["requires_dist"] = requires_dist loads.extend([ """load("@rules_python//python/private/pypi:whl_library_targets.bzl", "{}")""".format(fn), @@ -106,9 +123,8 @@ def generate_whl_library_build_bazel( "{} = {},".format(k, _RENDER.get(k, repr)(v)) for k, v in sorted(kwargs.items()) ])), - purl = repr(purl), ), - ] + additional_content, + ] + ([_PURL.format(purl = repr(purl))] if purl else []) + additional_content, ) # NOTE: Ensure that we terminate with a new line diff --git a/python/private/pypi/pep508_deps.bzl b/python/private/pypi/pep508_deps.bzl index c004334d96..fd6d961bf5 100644 --- a/python/private/pypi/pep508_deps.bzl +++ b/python/private/pypi/pep508_deps.bzl @@ -44,6 +44,12 @@ def deps( * deps_select: {type}`dict[str, list[str]]` dependencies to include on particular subset of target platforms. """ + if not requires_dist: + return struct( + deps = [], + deps_select = {}, + ) + reqs = sorted( [requirement(r) for r in requires_dist], key = lambda x: "{}:{}:".format(x.name, sorted(x.extras), x.marker), diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl index 9a150db9e8..53edd62efb 100644 --- a/python/private/pypi/whl_library.bzl +++ b/python/private/pypi/whl_library.bzl @@ -332,6 +332,12 @@ def _whl_extract(rctx, *, whl_path, logger, sdist_filename = None): read_fn = rctx.read, logger = logger, ) + rctx.file("metadata.json", json.encode_indent({ + "name": metadata.name, + "provides_extra": metadata.provides_extra, + "requires_dist": metadata.requires_dist, + "version": metadata.version, + })) namespace_package_files = pypi_repo_utils.find_namespace_package_files(rctx, install_dir_path) entry_points = _get_entry_points(rctx, install_dir_path, metadata) @@ -429,6 +435,8 @@ def _whl_archive_impl(rctx): whl_path = rctx.path(filename) else: fail("Only wheels are supported") + else: + fail("Either 'whl_file' or 'urls' and 'filename' needs to be specified") return _whl_extract(rctx, whl_path = whl_path, logger = logger) @@ -571,9 +579,6 @@ For example if your whl depends on `numpy` and your Python package repo is named "index_url": attr.string( doc = "The index_url that the package will be downloaded from.", ), - "repo": attr.string( - doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.", - ), "repo_prefix": attr.string( doc = """ Prefix for the generated packages will be of the form `@//...` @@ -676,7 +681,6 @@ whl_archive = repository_rule( "group_deps", "group_name", "index_url", - "repo", "repo_prefix", "requirement", "sha256", @@ -702,7 +706,74 @@ Does not depend on any python. ], ) -def whl_library(name, **kwargs): +def _whl_deps_library_impl(rctx): + logger = repo_utils.logger(rctx) + + if rctx.attr.metadata_file and rctx.attr.metadata: + logger.fail("Only one of 'metadata_file' and 'metadata' can be specified") + return + if not (rctx.attr.metadata_file or rctx.attr.metadata): + logger.fail("At least one of 'metadata_file' and 'metadata' must be specified") + return + + if rctx.attr.metadata_file: + metadata_contents = rctx.read(rctx.attr.metadata_file) + else: + metadata_contents = rctx.attr.metadata + + metadata = struct(**json.decode(metadata_contents)) + + build_file_contents = generate_whl_library_build_bazel( + dep_template = rctx.attr.dep_template or "@{}{{name}}//:{{target}}".format( + rctx.attr.repo_prefix, + ), + config_load = rctx.attr.config_load, + metadata_name = metadata.name, + metadata_version = metadata.version, + requires_dist = metadata.requires_dist, + group_deps = rctx.attr.group_deps, + group_name = rctx.attr.group_name, + repo = rctx.attr.repo or ( + str(rctx.attr.metadata_file) if rctx.attr.metadata_file else None + ), + extras = requirement(rctx.attr.requirement).extras, + ) + rctx.file("BUILD.bazel", build_file_contents) + +whl_deps_library = repository_rule( + attrs = { + k: _pip_archive_attrs[k] + for k in [ + "config_load", + "dep_template", + "group_deps", + "group_name", + "requirement", + ] + } | { + "metadata": attr.string( + doc = """ +The subset of the METADATA contents that is needed for generation of the dependencies. +* name: {type}`str` +* version: {type}`str` +* provides_extra: {type}`list[str]` +* requires_dist: {type}`list[str]` +""", + ), + "metadata_file": attr.label(doc = "An alternative way to pass {attr}`metadata` but as a file."), + "repo": attr.label(doc = "A label at the root of the repo to get stuff from."), + }, + doc = """ +A repo rule that reuses the sources from a different place and then creates the necessary targets +so that this can be used in the repo. + +Does not depend on any python. +""", + implementation = _whl_deps_library_impl, + environ = [REPO_DEBUG_ENV_VAR], +) + +def whl_library(name, repo = None, **kwargs): """Create a whl_library. This proxies to one of the underlying implementations: @@ -711,15 +782,18 @@ def whl_library(name, **kwargs): Args: name: {type}`str` The name of the repo. + repo: Unused, will be dropped in the next major release. **kwargs: The args passed to the underlying implementation. Returns: the repo metadata. """ + _ = repo # buildifier: disable=unused-variable + whl_file = kwargs.get("whl_file") urls = kwargs.get("urls", []) filename = kwargs.get("filename") if whl_file or (urls and filename and filename.endswith(".whl")): - return whl_archive(name = name, **kwargs) - - return pip_archive(name = name, **kwargs) + whl_archive(name = name, **kwargs) + else: + pip_archive(name = name, **kwargs) diff --git a/python/private/pypi/whl_library_targets.bzl b/python/private/pypi/whl_library_targets.bzl index 217210e782..737a739145 100644 --- a/python/private/pypi/whl_library_targets.bzl +++ b/python/private/pypi/whl_library_targets.bzl @@ -49,11 +49,10 @@ _BAZEL_REPO_FILE_GLOBS = [ _IS_VENV_SITE_PACKAGES_YES = Label("//python/config_settings:_is_venvs_site_packages_yes") _VENV_SITE_PACKAGES_FLAG = Label("//python/config_settings:venvs_site_packages") -def whl_library_targets_from_requires( +def whl_library_targets( *, name, metadata_name = "", - metadata_version = "", requires_dist = [], extras = [], entry_points = {}, @@ -71,14 +70,12 @@ def whl_library_targets_from_requires( srcs_exclude = [], data = [], visibility = ["//visibility:public"], - tags = [], **kwargs): """The macro to create whl targets from the METADATA. Args: name: {type}`str` The wheel filename metadata_name: {type}`str` The package name as written in wheel `METADATA`. - metadata_version: {type}`str` The package version as written in wheel `METADATA`. group_deps: {type}`list[str]` names of fellow members of the group (if any). These will be excluded from generated deps lists so as to avoid direct cycles. These dependencies will be provided at runtime by the @@ -100,21 +97,14 @@ def whl_library_targets_from_requires( srcs_exclude: {type}`list[str]` The globs for srcs attribute exclusion. data: {type}`list[str]` A list of labels to include as part of the `data` attribute. visibility: {type}`list[str]` The visibility of the targets. - tags: {type}`list[str]` The tags set on the targets. - **kwargs: Extra args passed to the {obj}`whl_library_targets` and {obj}`whl_library_srcs`. + **kwargs: Extra args passed to the {obj}`whl_library_deps_targets` and {obj}`whl_library_srcs`. """ - pypi_tags = [ - "pypi_name={}".format(metadata_name), - "pypi_version={}".format(metadata_version), - ] - all_tags = sorted(tags + pypi_tags) - + create_extra_targets = bool(requires_dist or group_name) and dep_template whl_library_srcs( name = name, sdist_filename = sdist_filename, data_exclude = data_exclude, srcs_exclude = srcs_exclude, - tags = all_tags, filegroups = filegroups, entry_points = entry_points, visibility = visibility, @@ -123,26 +113,27 @@ def whl_library_targets_from_requires( copy_executables = copy_executables, enable_implicit_namespace_pkgs = enable_implicit_namespace_pkgs, namespace_package_files = namespace_package_files, + # If there are no dependencies, then let's create the targets with public labels. + # Note, we are not supporting grouping the packages in this case, but that is fine. + whl_name = WHEEL_FILE if create_extra_targets else WHEEL_FILE_PUBLIC_LABEL, + pkg_name = PY_SRCS_LABEL if create_extra_targets else PY_LIBRARY_PUBLIC_LABEL, **kwargs ) - package_deps = _parse_requires_dist( - name = metadata_name, - requires_dist = requires_dist, - excludes = group_deps, - extras = extras, - include = include, - ) - - whl_library_targets( - name = name, - dependencies = package_deps.deps, - dependencies_with_markers = package_deps.deps_select, - group_name = group_name, - dep_template = dep_template, - tags = all_tags, - **kwargs - ) + if create_extra_targets: + whl_library_deps_targets( + name = name, + metadata_name = metadata_name, + requires_dist = requires_dist, + group_deps = group_deps, # only needed if requires_dist is present + extras = extras, # only needed if requires_dist is present + include = include, # only needed if requires_dist is present + group_name = group_name, # only needed if requires_dist is present + dep_template = dep_template, # only needed if requires_dist is present + repo = None, # set aliases in the same repo + aliases = {}, + **kwargs + ) def whl_library_srcs( *, @@ -153,13 +144,15 @@ def whl_library_srcs( tags = [], filegroups = None, entry_points = {}, - visibility = ["//visibility:public"], data = [], copy_files = {}, copy_executables = {}, native = native, enable_implicit_namespace_pkgs = False, namespace_package_files = [], + whl_name = WHEEL_FILE, + pkg_name = PY_SRCS_LABEL, + visibility = ["//visibility:public"], rules = struct( copy_file = copy_file, py_binary = py_binary, @@ -192,9 +185,11 @@ def whl_library_srcs( data: {type}`list[str]` A list of labels to include as part of the `data` attribute in `py_library`. enable_implicit_namespace_pkgs: {type}`boolean` generate __init__.py files for namespace pkgs. - native: {type}`native` The native struct for overriding in tests. namespace_package_files: {type}`list[str]` A list of labels of files whose directories are namespace packages. + whl_name: {type}`str` The label name to use for the wheel filegroup target. + pkg_name: {type}`str` The label name to use for the py_library target. + native: {type}`native` The native struct for overriding in tests. rules: {type}`struct` A struct with references to rules for creating targets. """ tags = sorted(tags) @@ -278,7 +273,7 @@ def whl_library_srcs( if hasattr(native, "filegroup"): native.filegroup( - name = WHEEL_FILE, + name = whl_name, srcs = [name], visibility = visibility, ) @@ -334,7 +329,7 @@ def whl_library_srcs( data = data + [DATA_LABEL] rules.py_library( - name = PY_SRCS_LABEL, + name = pkg_name, srcs = srcs, pyi_srcs = pyi_srcs, data = data, @@ -347,29 +342,20 @@ def whl_library_srcs( namespace_package_files = namespace_package_files, ) -def _parse_requires_dist( +def whl_library_deps_targets( *, - name, + name = None, + repo, + aliases = None, + metadata_name, requires_dist, - excludes, - include, - extras): - return deps( - name = normalize_name(name), - requires_dist = requires_dist, - excludes = excludes, - include = include, - extras = extras, - ) - -def whl_library_targets( - *, - name, + extras, + include = [], + group_deps = [], + group_name = None, dep_template, tags = [], - dependencies = [], - dependencies_with_markers = {}, - group_name = "", + visibility = ["//visibility:public"], native = native, rules = struct( copy_file = copy_file, @@ -379,39 +365,38 @@ def whl_library_targets( venv_rewrite_shebang = venv_rewrite_shebang, env_marker_setting = env_marker_setting, create_inits = _create_inits, - ), - **_kwargs): + )): """Create all of the whl_library targets. Args: - name: {type}`str` The file to match for including it into the `whl` - filegroup. This may be also parsed to generate extra metadata. - dep_template: {type}`str` The dep_template to use for dependency - interpolation. - tags: {type}`list[str]` The tags set on the `py_library`. - dependencies: {type}`list[str]` A list of dependencies. - dependencies_with_markers: {type}`dict[str, str]` A marker to evaluate - in order for the dep to be included. - group_name: {type}`str` name of the dependency group (if any) which - contains this library. If set, this library will behave as a shim - to group implementation rules which will provide simultaneously - installed dependencies which would otherwise form a cycle. + name: {type}`str` The wheel filename + metadata_name: {type}`str` The package name as written in wheel `METADATA`. + group_deps: {type}`list[str]` names of fellow members of the group (if + any). These will be excluded from generated deps lists so as to avoid + direct cycles. These dependencies will be provided at runtime by the + group rules which wrap this library and its fellows together. + requires_dist: {type}`list[str]` The list of `Requires-Dist` values from + the whl `METADATA`. + extras: {type}`list[str]` The list of requested extras. This essentially includes extra transitive dependencies in the final targets depending on the wheel `METADATA`. + include: {type}`list[str]` The list of packages to include. + group_name: {type}`str | None` name of the dependency group (if any). + dep_template: {type}`str | None` The dep_template to use. + tags: {type}`list[str]` The tags set on the targets. + repo: {type}`str | Label | None` The BUILD.bazel label to the parent repo that has the + sources. If none, then will take the targets from the current dir. + aliases: {type}`dict[str, str] | None` The list of aliases to create in the parent repo. If None, will create + the default values. Empty list means no aliases. + visibility: {type}`list[str]` The visibility of the targets. native: {type}`native` The native struct for overriding in tests. rules: {type}`struct` A struct with references to rules for creating targets. - **_kwargs: ignored args that are not needed. """ - dependencies = sorted([normalize_name(d) for d in dependencies]) - tags = sorted(tags) - - _config_settings( - dependencies_with_markers = dependencies_with_markers, - rules = rules, - visibility = ["//visibility:private"], - ) - deps_conditional = { - d: "is_include_{}_true".format(d) - for d in dependencies_with_markers - } + repo_label = Label(repo).same_package_label if repo else (lambda x: x) + if aliases == None: + aliases = { + EXTRACTED_WHEEL_FILES: repo_label(EXTRACTED_WHEEL_FILES), + DIST_INFO_LABEL: repo_label(DIST_INFO_LABEL), + DATA_LABEL: repo_label(DATA_LABEL), + } # If this library is a member of a group, its public label aliases need to # point to the group implementation rule not the implementation rules. We @@ -430,6 +415,10 @@ def whl_library_targets( "//:", "//_groups:", ) + aliases = aliases | { + PY_LIBRARY_PUBLIC_LABEL: label_tmpl.format(PY_LIBRARY_PUBLIC_LABEL), + WHEEL_FILE_PUBLIC_LABEL: label_tmpl.format(WHEEL_FILE_PUBLIC_LABEL), + } impl_vis = [dep_template.format( name = "_config", target = "__pkg__", @@ -438,37 +427,59 @@ def whl_library_targets( "//_groups:", )] - native.alias( - name = PY_LIBRARY_PUBLIC_LABEL, - actual = label_tmpl.format(PY_LIBRARY_PUBLIC_LABEL), - visibility = ["//visibility:public"], - ) - native.alias( - name = WHEEL_FILE_PUBLIC_LABEL, - actual = label_tmpl.format(WHEEL_FILE_PUBLIC_LABEL), - visibility = ["//visibility:public"], - ) py_library_label = PY_LIBRARY_IMPL_LABEL whl_file_label = WHEEL_FILE_IMPL_LABEL - - elif group_name: - py_library_label = PY_LIBRARY_PUBLIC_LABEL - whl_file_label = WHEEL_FILE_PUBLIC_LABEL - impl_vis = [dep_template.format(name = "", target = "__subpackages__")] - else: py_library_label = PY_LIBRARY_PUBLIC_LABEL whl_file_label = WHEEL_FILE_PUBLIC_LABEL - impl_vis = ["//visibility:public"] + if group_name: + impl_vis = [dep_template.format(name = "", target = "__subpackages__")] + else: + impl_vis = visibility + + if not requires_dist: + # If the package is in a group but has no deps, we still need the public labels to + # point at the srcs targets so that the group implementation can use them. We don't + # need any of the extra targets, so just create the aliases. + aliases = aliases | { + py_library_label: repo_label(PY_SRCS_LABEL), + whl_file_label: repo_label(WHEEL_FILE), + } + + for alias, actual in aliases.items(): + native.alias( + name = alias, + actual = actual, + visibility = visibility, + ) + + if not requires_dist: + # If there are extras, then they will be visible in requires_dist. + return + + package_deps = _parse_requires_dist( + name = metadata_name, + requires_dist = requires_dist, + excludes = group_deps, + extras = extras, + include = include, + ) + + _config_settings( + dependencies_with_markers = package_deps.deps_select, + rules = rules, + visibility = ["//visibility:private"], + ) if hasattr(native, "filegroup"): + # We include the whl file as srcs so that `$(location :whl)` expands to the whl file. + # The transitive dependencies are available via the `data` attribute. native.filegroup( name = whl_file_label, - data = [ - WHEEL_FILE, - ] + _deps( - deps = dependencies, - deps_conditional = deps_conditional, + srcs = [repo_label(WHEEL_FILE)], + data = _deps( + deps = [], + package_deps = package_deps, tmpl = dep_template.format(name = "{}", target = WHEEL_FILE_PUBLIC_LABEL), ), visibility = impl_vis, @@ -477,23 +488,35 @@ def whl_library_targets( if hasattr(rules, "py_library"): rules.py_library( name = py_library_label, - srcs = [ - # We include as srcs to ensure that the (locations :pkg) works as expected. - PY_SRCS_LABEL, - ], - deps = [ - # We include as deps, so that `PyInfo` and friends get propagated as deps. - # not sure if just including it as `srcs` is enough. - PY_SRCS_LABEL, - ] + _deps( - deps = dependencies, - deps_conditional = deps_conditional, + # We include as srcs to ensure that the (locations :pkg) works as expected. + srcs = [repo_label(PY_SRCS_LABEL)], + deps = _deps( + # We include as deps, so that `PyInfo` and friends (e.g. `pyi_srcs`) get + # propagated. Just passing the target as `srcs` is not enough to propagate + # `pyi_srcs`, see `tests/base_rules/py_library`. + deps = [repo_label(PY_SRCS_LABEL)], + package_deps = package_deps, tmpl = dep_template.format(name = "{}", target = PY_LIBRARY_PUBLIC_LABEL), ), tags = tags, visibility = impl_vis, ) +def _parse_requires_dist( + *, + name, + requires_dist, + excludes, + include, + extras): + return deps( + name = normalize_name(name), + requires_dist = requires_dist, + excludes = excludes, + include = include, + extras = extras, + ) + def _config_settings(dependencies_with_markers, rules, **kwargs): """Generate config settings for the targets. @@ -510,12 +533,12 @@ def _config_settings(dependencies_with_markers, rules, **kwargs): **kwargs ) -def _deps(deps, deps_conditional, tmpl): - deps = [tmpl.format(d) for d in sorted(deps)] +def _deps(deps, package_deps, tmpl): + deps = [] + deps + [tmpl.format(d) for d in sorted(package_deps.deps)] - for dep, setting in deps_conditional.items(): + for dep in package_deps.deps_select: deps = deps + select({ - ":{}".format(setting): [tmpl.format(dep)], + ":is_include_{}_true".format(dep): [tmpl.format(dep)], "//conditions:default": [], }) diff --git a/tests/base_rules/py_library/py_library_tests.bzl b/tests/base_rules/py_library/py_library_tests.bzl index 3726ff1f41..c375e72794 100644 --- a/tests/base_rules/py_library/py_library_tests.bzl +++ b/tests/base_rules/py_library/py_library_tests.bzl @@ -3,10 +3,12 @@ load("@rules_testing//lib:analysis_test.bzl", "analysis_test") load("@rules_testing//lib:truth.bzl", "matching") load("@rules_testing//lib:util.bzl", rt_util = "util") +load("//python:py_info.bzl", "PyInfo") load("//python:py_library.bzl", "py_library") load("//python:py_runtime_info.bzl", "PyRuntimeInfo") load("//tests/base_rules:base_tests.bzl", "create_base_tests") load("//tests/base_rules:util.bzl", pt_util = "util") +load("//tests/support:py_info_subject.bzl", "py_info_subject") _tests = [] @@ -141,6 +143,63 @@ def _test_files_to_compile_impl(env, target): _tests.append(_test_files_to_compile) +def _test_pyi_srcs_not_propagated_via_srcs(name, config): + rt_util.helper_target( + config.rule, + name = name + "_lib", + srcs = ["lib.py"], + pyi_srcs = ["lib.pyi"], + ) + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_lib"], + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_pyi_srcs_not_propagated_via_srcs_impl, + ) + +def _test_pyi_srcs_not_propagated_via_srcs_impl(env, target): + info = env.expect.that_target(target).provider( + PyInfo, + factory = py_info_subject, + ) + info.transitive_pyi_files().contains_exactly([]) + +_tests.append(_test_pyi_srcs_not_propagated_via_srcs) + +def _test_pyi_srcs_propagated_via_deps(name, config): + rt_util.helper_target( + config.rule, + name = name + "_lib", + srcs = ["lib.py"], + pyi_srcs = ["lib.pyi"], + ) + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_lib"], + deps = [name + "_lib"], + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_pyi_srcs_propagated_via_deps_impl, + ) + +def _test_pyi_srcs_propagated_via_deps_impl(env, target): + info = env.expect.that_target(target).provider( + PyInfo, + factory = py_info_subject, + ) + info.transitive_pyi_files().contains_exactly([ + "{package}/lib.pyi", + ]) + +_tests.append(_test_pyi_srcs_propagated_via_deps) + def py_library_test_suite(name): config = struct(rule = py_library, base_test_rule = py_library) native.test_suite( diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel index abdb37be57..ef7681ebe3 100644 --- a/tests/integration/BUILD.bazel +++ b/tests/integration/BUILD.bazel @@ -137,6 +137,10 @@ rules_python_integration_test( py_main = "uv_lock_test.py", ) +rules_python_integration_test( + name = "whl_library_test", +) + py_library( name = "runner_lib", srcs = ["runner.py"], diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel b/tests/integration/bzlmod_lockfile/MODULE.bazel index 6c074bcdd4..bfd43cb895 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel @@ -12,8 +12,8 @@ python.toolchain(python_version = "3.13") # TODO: This test module should also verify that isolate = True works, will do in a followup PR. pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") pip.parse( - hub_name = "pypi", + hub_name = "pypi_lockfile", python_version = "3.13", requirements_lock = "//:requirements_lock.txt", ) -use_repo(pip, "pypi") +use_repo(pip, pypi = "pypi_lockfile") diff --git a/tests/integration/compile_pip_requirements_test_from_external_repo/MODULE.bazel b/tests/integration/compile_pip_requirements_test_from_external_repo/MODULE.bazel index 596a0bcfc8..201cb4a1ab 100644 --- a/tests/integration/compile_pip_requirements_test_from_external_repo/MODULE.bazel +++ b/tests/integration/compile_pip_requirements_test_from_external_repo/MODULE.bazel @@ -19,7 +19,7 @@ local_path_override( pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") pip.parse( - hub_name = "pypi", + hub_name = "pypi_compile", python_version = "3.9", requirements_lock = "@compile_pip_requirements//:requirements_lock.txt", ) diff --git a/tests/integration/pip_parse_isolated/MODULE.bazel b/tests/integration/pip_parse_isolated/MODULE.bazel index 6c44257acb..c25e9dc024 100644 --- a/tests/integration/pip_parse_isolated/MODULE.bazel +++ b/tests/integration/pip_parse_isolated/MODULE.bazel @@ -12,8 +12,8 @@ python.toolchain(python_version = "3.13") # This test module verifies that dependencies can be used with `isolate = True`. pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip", isolate = True) pip.parse( - hub_name = "pypi", + hub_name = "pypi_isolated", python_version = "3.13", requirements_lock = "//:requirements_lock.txt", ) -use_repo(pip, "pypi") +use_repo(pip, pypi = "pypi_isolated") diff --git a/tests/integration/whl_library/.bazelrc b/tests/integration/whl_library/.bazelrc new file mode 100644 index 0000000000..eee47a8f19 --- /dev/null +++ b/tests/integration/whl_library/.bazelrc @@ -0,0 +1,2 @@ +common --incompatible_default_to_explicit_init_py +test --test_output=errors diff --git a/tests/integration/whl_library/BUILD.bazel b/tests/integration/whl_library/BUILD.bazel new file mode 100644 index 0000000000..0c26fee4cf --- /dev/null +++ b/tests/integration/whl_library/BUILD.bazel @@ -0,0 +1,65 @@ +load("@rules_python//python:py_test.bzl", "py_test") + +[ + alias( + name = "{}_pkg".format(pkg), + actual = "@rules_python//python:none", + ) + for pkg in [ + "charset_normalizer", + "certifi", + "idna", + "urllib3", + ] +] + +[ + filegroup( + name = "{}_whl".format(pkg), + srcs = [], + visibility = ["//visibility:public"], + ) + for pkg in [ + "charset_normalizer", + "certifi", + "idna", + "urllib3", + ] +] + +# Extract all deps +genquery( + name = "whl_target_deps", + expression = "deps(@whl_archive//:pkg)", + scope = ["@whl_archive//:pkg"], +) + +# Extract all deps +genquery( + name = "whl_deps_target_deps", + expression = "deps(@whl_deps_library//:pkg)", + scope = ["@whl_deps_library//:pkg"], +) + +py_test( + name = "test_contents", + srcs = ["test_contents.py"], + data = [ + ":whl_deps_target_deps", + ":whl_target_deps", + "@pip_archive//:srcs", + "@pip_sdist_archive//:srcs", + "@whl_archive//:srcs", + "@whl_archive//:whl", + "@whl_deps_library//:whl", + ], + env = { + "SDIST_SRC_FILES": "$(locations @pip_sdist_archive//:srcs)", + "SRC_FILES": "$(locations @pip_archive//:srcs)", + "WHL_DEPS": "$(location :whl_target_deps)", + "WHL_DEPS_LOCATION": "$(location @whl_deps_library//:whl)", + "WHL_FILES": "$(locations @whl_archive//:srcs)", + "WHL_LOCATION": "$(location @whl_archive//:whl)", + "WHL_TARGET_DEPS": "$(location :whl_deps_target_deps)", + }, +) diff --git a/tests/integration/whl_library/MODULE.bazel b/tests/integration/whl_library/MODULE.bazel new file mode 100644 index 0000000000..39a579f79e --- /dev/null +++ b/tests/integration/whl_library/MODULE.bazel @@ -0,0 +1,57 @@ +module(name = "integration_test") + +bazel_dep(name = "package_metadata", version = "0.0.7") +bazel_dep(name = "rules_python", version = "0.0.0") +local_path_override( + module_name = "rules_python", + path = "../../..", +) + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain( + python_version = "3.14", +) +use_repo(python, pbs_host = "python_3_14_host") + +pip_archive = use_repo_rule("@rules_python//python/private/pypi:whl_library.bzl", "pip_archive") + +pip_archive( + name = "pip_sdist_archive", + filename = "requests-2.34.2.tar.gz", + python_interpreter_target = "@pbs_host//:python", + requirement = "requests", + # https://pypi.org/project/requests/#requests-2.34.2.tar.gz + sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", + urls = [ + "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", + ], +) + +pip_archive( + name = "pip_archive", + python_interpreter_target = "@pbs_host//:python", + requirement = "requests", +) + +whl_archive = use_repo_rule("@rules_python//python/private/pypi:whl_library.bzl", "whl_archive") + +whl_archive( + name = "whl_archive", + dep_template = "@integration_test//:{name}_{target}", + filename = "requests-2.34.2-py3-none-any.whl", + # https://pypi.org/project/requests/#requests-2.34.2-py3-none-any.whl + requirement = "requests", + sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", + urls = [ + "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", + ], +) + +whl_deps_library = use_repo_rule("@rules_python//python/private/pypi:whl_library.bzl", "whl_deps_library") + +whl_deps_library( + name = "whl_deps_library", + dep_template = "@integration_test//:{name}_{target}", + metadata_file = "@whl_archive//:metadata.json", + requirement = "requests", +) diff --git a/tests/integration/whl_library/WORKSPACE b/tests/integration/whl_library/WORKSPACE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/whl_library/test_contents.py b/tests/integration/whl_library/test_contents.py new file mode 100644 index 0000000000..ab352a5d98 --- /dev/null +++ b/tests/integration/whl_library/test_contents.py @@ -0,0 +1,179 @@ +import os +import re +import unittest +from pathlib import Path + + +class TestContents(unittest.TestCase): + maxDiff = None + + @staticmethod + def _get_files(env_var: str) -> list[str]: + return [ + f.partition("site-packages/")[-1] for f in os.environ[env_var].split(" ") + ] + + def test_sdist_srcs(self): + self.assertEqual( + self._get_files("SDIST_SRC_FILES"), + [ + "requests/__init__.py", + "requests/__version__.py", + "requests/_internal_utils.py", + "requests/_types.py", + "requests/adapters.py", + "requests/api.py", + "requests/auth.py", + "requests/certs.py", + "requests/compat.py", + "requests/cookies.py", + "requests/exceptions.py", + "requests/help.py", + "requests/hooks.py", + "requests/models.py", + "requests/packages.py", + "requests/sessions.py", + "requests/status_codes.py", + "requests/structures.py", + "requests/utils.py", + ], + ) + + def test_srcs(self): + self.assertEqual( + self._get_files("SRC_FILES"), + [ + "requests/__init__.py", + "requests/__version__.py", + "requests/_internal_utils.py", + "requests/_types.py", + "requests/adapters.py", + "requests/api.py", + "requests/auth.py", + "requests/certs.py", + "requests/compat.py", + "requests/cookies.py", + "requests/exceptions.py", + "requests/help.py", + "requests/hooks.py", + "requests/models.py", + "requests/packages.py", + "requests/sessions.py", + "requests/status_codes.py", + "requests/structures.py", + "requests/utils.py", + ], + ) + + def test_whl_srcs(self): + self.assertEqual( + self._get_files("WHL_FILES"), + [ + "requests/__init__.py", + "requests/__version__.py", + "requests/_internal_utils.py", + "requests/_types.py", + "requests/adapters.py", + "requests/api.py", + "requests/auth.py", + "requests/certs.py", + "requests/compat.py", + "requests/cookies.py", + "requests/exceptions.py", + "requests/help.py", + "requests/hooks.py", + "requests/models.py", + "requests/packages.py", + "requests/sessions.py", + "requests/status_codes.py", + "requests/structures.py", + "requests/utils.py", + ], + ) + + def test_whl_location(self): + self.assertTrue( + os.environ["WHL_LOCATION"].endswith("requests-2.34.2-py3-none-any.whl"), + msg=os.environ["WHL_LOCATION"], + ) + self.assertTrue( + os.environ["WHL_DEPS_LOCATION"].endswith( + "requests-2.34.2-py3-none-any.whl" + ), + msg=os.environ["WHL_DEPS_LOCATION"], + ) + + @staticmethod + def _read_file(env_var: str) -> list[str]: + return set(Path(os.environ[env_var]).read_text().splitlines()) + + @staticmethod + def _normalize_label(label: str) -> str: + if not label.startswith("@@"): + return label + repo, _, rest = label.partition("//") + parts = [p for p in re.split(r"[~+]", repo[2:]) if p] + if parts: + return f"@{parts[-1]}//{rest}" + return label + + def test_whl_deps_ar_the_same(self): + for var, main_dep in { + "WHL_DEPS": "@whl_archive//:pkg", + "WHL_TARGET_DEPS": "@whl_deps_library//:pkg", + }.items(): + self.assertEqual( + { + self._normalize_label(x) + for x in self._read_file(var) + if not x.endswith("toolchain_type") + }, + { + main_dep, + "//:certifi_pkg", + "//:charset_normalizer_pkg", + "//:idna_pkg", + "//:urllib3_pkg", + "@whl_archive//:data", + "@whl_archive//:package_metadata", + "@whl_archive//:site-packages/requests-2.34.2.dist-info/INSTALLER", + "@whl_archive//:site-packages/requests-2.34.2.dist-info/METADATA", + "@whl_archive//:site-packages/requests-2.34.2.dist-info/RECORD", + "@whl_archive//:site-packages/requests-2.34.2.dist-info/WHEEL", + "@whl_archive//:site-packages/requests-2.34.2.dist-info/licenses/LICENSE", + "@whl_archive//:site-packages/requests-2.34.2.dist-info/licenses/NOTICE", + "@whl_archive//:site-packages/requests-2.34.2.dist-info/top_level.txt", + "@whl_archive//:site-packages/requests/__init__.py", + "@whl_archive//:site-packages/requests/__version__.py", + "@whl_archive//:site-packages/requests/_internal_utils.py", + "@whl_archive//:site-packages/requests/_types.py", + "@whl_archive//:site-packages/requests/adapters.py", + "@whl_archive//:site-packages/requests/api.py", + "@whl_archive//:site-packages/requests/auth.py", + "@whl_archive//:site-packages/requests/certs.py", + "@whl_archive//:site-packages/requests/compat.py", + "@whl_archive//:site-packages/requests/cookies.py", + "@whl_archive//:site-packages/requests/exceptions.py", + "@whl_archive//:site-packages/requests/help.py", + "@whl_archive//:site-packages/requests/hooks.py", + "@whl_archive//:site-packages/requests/models.py", + "@whl_archive//:site-packages/requests/packages.py", + "@whl_archive//:site-packages/requests/py.typed", + "@whl_archive//:site-packages/requests/sessions.py", + "@whl_archive//:site-packages/requests/status_codes.py", + "@whl_archive//:site-packages/requests/structures.py", + "@whl_archive//:site-packages/requests/utils.py", + "@whl_archive//:srcs", + "@rules_python//python:none", + "@rules_python//python/config_settings:_is_venvs_site_packages_yes", + "@rules_python//python/config_settings:add_srcs_to_runfiles", + "@rules_python//python/config_settings:precompile", + "@rules_python//python/config_settings:precompile_source_retention", + "@rules_python//python/config_settings:venvs_site_packages", + "@rules_python//python/private:sentinel", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl b/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl index 1fd99205b1..bb03d9a589 100644 --- a/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl +++ b/tests/pypi/generate_whl_library_build_bazel/generate_whl_library_build_bazel_tests.bzl @@ -23,17 +23,11 @@ def _test_all_workspace(env): want = """\ load("@package_metadata//rules:package_metadata.bzl", "package_metadata") load("@pypi//:config.bzl", "packages") -load("@rules_python//python/private/pypi:whl_library_targets.bzl", "whl_library_targets_from_requires") +load("@rules_python//python/private/pypi:whl_library_targets.bzl", "whl_library_targets") package(default_visibility = ["//visibility:public"]) -package_metadata( - name = "package_metadata", - purl = None, - visibility = ["//:__subpackages__"], -) - -whl_library_targets_from_requires( +whl_library_targets( copy_executables = { "exec_src": "exec_dest", }, @@ -53,6 +47,7 @@ whl_library_targets_from_requires( ], group_name = "qux", include = packages, + metadata_name = "foo", name = "foo.whl", requires_dist = [ "foo", @@ -60,11 +55,23 @@ whl_library_targets_from_requires( "qux", ], srcs_exclude = ["srcs_exclude_all"], + tags = [ + "pypi_name=foo", + "pypi_version=0", + ], +) + +package_metadata( + name = "package_metadata", + purl = "foo", + visibility = ["//:__subpackages__"], ) # SOMETHING SPECIAL AT THE END """ actual = generate_whl_library_build_bazel( + metadata_version = "0", + metadata_name = "foo", dep_template = "@pypi//{name}:{target}", name = "foo.whl", requires_dist = ["foo", "bar-baz", "qux"], @@ -80,6 +87,7 @@ whl_library_targets_from_requires( config_load = "@pypi//:config.bzl", group_name = "qux", group_deps = ["foo", "fox", "qux"], + purl = "foo", ) env.expect.that_str(actual.replace("@@", "@")).equals(want) @@ -89,17 +97,11 @@ def _test_all(env): want = """\ load("@package_metadata//rules:package_metadata.bzl", "package_metadata") load("@pypi//:config.bzl", "packages") -load("@rules_python//python/private/pypi:whl_library_targets.bzl", "whl_library_targets_from_requires") +load("@rules_python//python/private/pypi:whl_library_targets.bzl", "whl_library_targets") package(default_visibility = ["//visibility:public"]) -package_metadata( - name = "package_metadata", - purl = None, - visibility = ["//:__subpackages__"], -) - -whl_library_targets_from_requires( +whl_library_targets( copy_executables = { "exec_src": "exec_dest", }, @@ -119,6 +121,7 @@ whl_library_targets_from_requires( ], group_name = "qux", include = packages, + metadata_name = "foo", name = "foo.whl", requires_dist = [ "foo", @@ -126,11 +129,23 @@ whl_library_targets_from_requires( "qux", ], srcs_exclude = ["srcs_exclude_all"], + tags = [ + "pypi_name=foo", + "pypi_version=0", + ], +) + +package_metadata( + name = "package_metadata", + purl = "foo", + visibility = ["//:__subpackages__"], ) # SOMETHING SPECIAL AT THE END """ actual = generate_whl_library_build_bazel( + metadata_version = "0", + metadata_name = "foo", dep_template = "@pypi//{name}:{target}", name = "foo.whl", requires_dist = ["foo", "bar-baz", "qux"], @@ -145,6 +160,7 @@ whl_library_targets_from_requires( ), config_load = "@pypi//:config.bzl", group_name = "qux", + purl = "foo", group_deps = ["foo", "fox", "qux"], ) env.expect.that_str(actual.replace("@@", "@")).equals(want) @@ -155,17 +171,11 @@ def _test_all_with_loads(env): want = """\ load("@package_metadata//rules:package_metadata.bzl", "package_metadata") load("@pypi//:config.bzl", "packages") -load("@rules_python//python/private/pypi:whl_library_targets.bzl", "whl_library_targets_from_requires") +load("@rules_python//python/private/pypi:whl_library_targets.bzl", "whl_library_targets") package(default_visibility = ["//visibility:public"]) -package_metadata( - name = "package_metadata", - purl = None, - visibility = ["//:__subpackages__"], -) - -whl_library_targets_from_requires( +whl_library_targets( copy_executables = { "exec_src": "exec_dest", }, @@ -185,6 +195,7 @@ whl_library_targets_from_requires( ], group_name = "qux", include = packages, + metadata_name = "foo", name = "foo.whl", requires_dist = [ "foo", @@ -192,11 +203,23 @@ whl_library_targets_from_requires( "qux", ], srcs_exclude = ["srcs_exclude_all"], + tags = [ + "pypi_name=foo", + "pypi_version=0", + ], +) + +package_metadata( + name = "package_metadata", + purl = "foo", + visibility = ["//:__subpackages__"], ) # SOMETHING SPECIAL AT THE END """ actual = generate_whl_library_build_bazel( + metadata_version = "0", + metadata_name = "foo", dep_template = "@pypi//{name}:{target}", name = "foo.whl", requires_dist = ["foo", "bar-baz", "qux"], @@ -212,6 +235,7 @@ whl_library_targets_from_requires( group_name = "qux", config_load = "@pypi//:config.bzl", group_deps = ["foo", "fox", "qux"], + purl = "foo", ) env.expect.that_str(actual.replace("@@", "@")).equals(want) diff --git a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl index 1f4aac1ba8..0a059c9d6a 100644 --- a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl +++ b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl @@ -17,8 +17,8 @@ load("@rules_testing//lib:test_suite.bzl", "test_suite") load( "//python/private/pypi:whl_library_targets.bzl", + "whl_library_deps_targets", "whl_library_srcs", - "whl_library_targets_from_requires", ) # buildifier: disable=bzl-visibility load("//tests/support/mocks:mocks.bzl", "mocks") @@ -105,7 +105,7 @@ def _test_copy(env): _tests.append(_test_copy) -def _test_whl_and_library_deps_from_requires(env): +def _test_whl_library_deps_targets(env): filegroup_calls = [] py_library_calls = [] env_marker_setting_calls = [] @@ -118,10 +118,9 @@ def _test_whl_and_library_deps_from_requires(env): m_glob.results.append(["site-packages/foo/DATA.txt"]) # data m_glob.results.append(["site-packages/foo/PYI.pyi"]) # pyi - whl_library_targets_from_requires( + whl_library_deps_targets( name = "foo-0-py3-none-any.whl", metadata_name = "Foo", - metadata_version = "0", dep_template = "@pypi//{name}:{target}", requires_dist = [ "foo", # this self-edge will be ignored @@ -130,11 +129,13 @@ def _test_whl_and_library_deps_from_requires(env): "booo", # this is effectively excluded due to the list below ], include = ["foo", "bar", "bar_baz"], - data_exclude = [], # Overrides for testing - filegroups = {}, + repo = None, + aliases = None, + extras = [], native = struct( filegroup = lambda **kwargs: filegroup_calls.append(kwargs), + alias = lambda **kwargs: None, config_setting = lambda **_: None, glob = m_glob.glob, ), @@ -147,80 +148,36 @@ def _test_whl_and_library_deps_from_requires(env): ) env.expect.that_collection(filegroup_calls).contains_exactly([ - { - "name": "whl_file", - "srcs": ["foo-0-py3-none-any.whl"], - "visibility": ["//visibility:public"], - }, { "name": "whl", # NOTE @aignas 2026-07-25: depending on the brackets position one may get different # results in the expectation. - "data": ["whl_file"] + (["@pypi//bar:whl"] + select({ + "srcs": ["whl_file"], + "data": ["@pypi//bar:whl"] + select({ ":is_include_bar_baz_true": ["@pypi//bar_baz:whl"], "//conditions:default": [], - })), + }), "visibility": ["//visibility:public"], }, ]) # buildifier: @unsorted-dict-items - env.expect.that_collection(py_library_calls).has_size(2) + env.expect.that_collection(py_library_calls).has_size(1) if len(py_library_calls) != 1: return py_library_call = py_library_calls[0] env.expect.that_dict(py_library_call).contains_exactly({ "name": "pkg", - "srcs": ["site-packages/foo/SRCS.py"] + select({ - Label("//python/config_settings:_is_venvs_site_packages_yes"): [], - "//conditions:default": ["_create_inits_target"], - }), - "pyi_srcs": ["site-packages/foo/PYI.pyi"], - "data": ["site-packages/foo/DATA.txt", "data"], - "imports": ["site-packages"], - "deps": ["@pypi//bar:pkg"] + select({ + "srcs": ["srcs"], + "deps": ["srcs", "@pypi//bar:pkg"] + select({ ":is_include_bar_baz_true": ["@pypi//bar_baz:pkg"], "//conditions:default": [], }), - "tags": ["pypi_name=Foo", "pypi_version=0"], + "tags": [], "visibility": ["//visibility:public"], - "experimental_venvs_site_packages": Label("//python/config_settings:venvs_site_packages"), - "namespace_package_files": [] + select({ - Label("//python/config_settings:_is_venvs_site_packages_yes"): [], - "//conditions:default": ["_create_inits_target"], - }), }) # buildifier: @unsorted-dict-items - env.expect.that_collection(m_glob.calls).contains_exactly([ - # bin call - mocks.glob_call( - ["bin/*"], - allow_empty = True, - ), - # rewrite-bin call - mocks.glob_call( - ["rewrite-bin/*"], - allow_empty = True, - ), - # srcs call - mocks.glob_call( - ["site-packages/**/*.py"], - exclude = [], - allow_empty = True, - ), - # data call - mocks.glob_call( - ["site-packages/**/*"], - exclude = [ - "**/*.py", - "**/*.pyc", - "**/*.pyc.*", - ], - allow_empty = True, - ), - # pyi call - mocks.glob_call(["site-packages/**/*.pyi"], allow_empty = True), - ]) + env.expect.that_collection(m_glob.calls).contains_exactly([]) env.expect.that_collection(env_marker_setting_calls).contains_exactly([ { @@ -230,7 +187,55 @@ def _test_whl_and_library_deps_from_requires(env): }, ]) # buildifier: @unsorted-dict-items -_tests.append(_test_whl_and_library_deps_from_requires) +_tests.append(_test_whl_library_deps_targets) + +def _test_whl_library_deps_targets_no_deps(env): + alias_calls = [] + filegroup_calls = [] + py_library_calls = [] + env_marker_setting_calls = [] + + whl_library_deps_targets( + name = "foo-0-py3-none-any.whl", + metadata_name = "Foo", + dep_template = "@pypi//{name}:{target}", + requires_dist = [], + group_name = "qux", + repo = None, + aliases = {}, + extras = [], + native = struct( + filegroup = lambda **kwargs: filegroup_calls.append(kwargs), + alias = lambda **kwargs: alias_calls.append(kwargs), + config_setting = lambda **_: None, + glob = lambda **_: [], + ), + rules = struct( + py_library = lambda **kwargs: py_library_calls.append(kwargs), + env_marker_setting = lambda **kwargs: env_marker_setting_calls.append(kwargs), + ), + ) + + # If the package is in a group but has no deps, then the public labels should be aliases + # to the srcs targets and no other targets should be created. + env.expect.that_collection(alias_calls).contains_exactly([ + { + "name": "pkg", + "actual": "srcs", + "visibility": ["//visibility:public"], + }, + { + "name": "whl", + "actual": "whl_file", + "visibility": ["//visibility:public"], + }, + ]) # buildifier: @unsorted-dict-items + + env.expect.that_collection(filegroup_calls).contains_exactly([]) + env.expect.that_collection(py_library_calls).contains_exactly([]) + env.expect.that_collection(env_marker_setting_calls).contains_exactly([]) + +_tests.append(_test_whl_library_deps_targets_no_deps) def _test_sdist_excludes_record(env): py_library_calls = [] From b6bd27bf35ef58482121599dbc3216a4194629bf Mon Sep 17 00:00:00 2001 From: Ignas Anikevicius <240938+aignas@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:02:27 +0900 Subject: [PATCH 124/179] tests(pypi): add test for excluding bazel files from wheels (#3979) This adds a test to verify that bazel files are excluded from the files matched from an extracted wheel. Followup to #3960 Work towards #2948 --- python/private/pypi/whl_library_targets.bzl | 5 +- .../whl_library_targets_tests.bzl | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/python/private/pypi/whl_library_targets.bzl b/python/private/pypi/whl_library_targets.bzl index 737a739145..ee5c781b3d 100644 --- a/python/private/pypi/whl_library_targets.bzl +++ b/python/private/pypi/whl_library_targets.bzl @@ -229,8 +229,9 @@ def whl_library_srcs( filegroups = { EXTRACTED_WHEEL_FILES: dict( include = ["**"], - exclude = ( - _BAZEL_REPO_FILE_GLOBS + + # The Bazel repo files are always excluded; only the sdist + # filename is conditional on `sdist_filename`. + exclude = _BAZEL_REPO_FILE_GLOBS + ( [sdist_filename] if sdist_filename else [] ), ), diff --git a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl index 0a059c9d6a..d752159b32 100644 --- a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl +++ b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl @@ -277,6 +277,60 @@ def _test_sdist_excludes_record(env): _tests.append(_test_sdist_excludes_record) +def _test_exclude_bazel_files(env): + # Regression test: the `extracted_whl_files` glob must always exclude the + # Bazel repo files, even when the wheel is not built from an sdist. + for sdist_filename in [None, "foo.tar.gz"]: + m_glob = mocks.glob() + m_glob.results.append([]) # bin + m_glob.results.append([]) # rewrite-bin + m_glob.results.append([]) # extracted_whl_files + m_glob.results.append([]) # dist_info + m_glob.results.append([]) # data + + whl_library_srcs( + name = "foo.whl", + sdist_filename = sdist_filename, + native = struct( + filegroup = lambda **_: None, + glob = m_glob.glob, + ), + rules = struct( + venv_rewrite_shebang = lambda **kwargs: None, + ), + ) + + expected_exclude = [ + "BUILD", + "BUILD.bazel", + "REPO.bazel", + "WORKSPACE", + "WORKSPACE.bzlmod", + "WORKSPACE.bazel", + ] + if sdist_filename: + expected_exclude.append(sdist_filename) + + env.expect.that_collection(m_glob.calls).contains_exactly([ + mocks.glob_call(["bin/*"], allow_empty = True), + mocks.glob_call(["rewrite-bin/*"], allow_empty = True), + mocks.glob_call( + include = ["**"], + exclude = expected_exclude, + allow_empty = True, + ), + mocks.glob_call( + include = ["site-packages/*.dist-info/**"], + allow_empty = True, + ), + mocks.glob_call( + include = ["data/**", "bin/**", "include/**"], + allow_empty = True, + ), + ]) + +_tests.append(_test_exclude_bazel_files) + def whl_library_targets_test_suite(name): """create the test suite. From 700ce58f4f438c8ec348043e7cc235017ee5a8ba Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 00:19:00 -0700 Subject: [PATCH 125/179] agents: relocate analyze-ci-failure logs to skill scratch directory (#3980) Previously, `analyze_ci_failure.py` output fetched CI logs and generated repair plans into a `ci_logs/` subfolder located directly inside the script directory (`.agents/skills/analyze-ci-failure/scripts/`). Storing runtime logs within the scripts directory pollutes the source tree. To resolve this: * Update `analyze_ci_failure.py` to target a dedicated `scratch/` directory at the skill root level (`.agents/skills/analyze-ci-failure/scratch/`). * Add `.agents/**/scratch` to `.gitignore` so all skill-generated scratch files remain untracked across all agent skills. --- .../analyze-ci-failure/scripts/analyze_ci_failure.py | 10 +++++----- .gitignore | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py index 20212f6d23..1f976f3e79 100644 --- a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py +++ b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py @@ -121,12 +121,12 @@ def main(): parser.add_argument("conv_id", help="Conversation ID to report back to") args = parser.parse_args() - skill_dir = os.path.abspath(os.path.dirname(__file__)) - logs_dir = os.path.join(skill_dir, "ci_logs") - os.makedirs(logs_dir, exist_ok=True) + skill_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + scratch_dir = os.path.join(skill_dir, "scratch") + os.makedirs(scratch_dir, exist_ok=True) safe_jname = re.sub(r"[^a-zA-Z0-9]", "_", args.job_name) - log_path = os.path.join(logs_dir, f"ci_{safe_jname}_{args.job_id}.log") + log_path = os.path.join(scratch_dir, f"ci_{safe_jname}_{args.job_id}.log") fetch_log(args.build_id, args.job_id, log_path) @@ -134,7 +134,7 @@ def main(): errors = parse_log(log_path) plan = create_plan(args.job_name, log_path, errors) - plan_file = os.path.join(logs_dir, f"ci_plan_{safe_jname}.md") + plan_file = os.path.join(scratch_dir, f"ci_plan_{safe_jname}.md") with open(plan_file, "w") as f: f.write(plan) diff --git a/.gitignore b/.gitignore index efce592aa0..cb5bdec980 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ MODULE.bazel.lock # Buildkite logs *Windows*.log +# Agent scratch directories +.agents/**/scratch + From 3a64c8b3abadda8eea687ad2c0fdcdd060e6fe45 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 00:25:32 -0700 Subject: [PATCH 126/179] agents: update create-pr and analyze-ci-failure skills (#3981) Update the create-pr skill to specify --repo bazel-contrib/rules_python and enforce using the agents: title prefix for agent-related pull requests. Also update analyze-ci-failure skill documentation to clarify the scratch directory path for downloaded logs. * Updates create-pr SKILL.md with upstream repo targeting rules and title prefix guidelines. * Updates analyze-ci-failure SKILL.md to clarify log download scratch directory path. --- .agents/skills/analyze-ci-failure/SKILL.md | 2 +- .agents/skills/create-pr/SKILL.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/analyze-ci-failure/SKILL.md b/.agents/skills/analyze-ci-failure/SKILL.md index 6bda9d5d6b..0af8a92fb1 100644 --- a/.agents/skills/analyze-ci-failure/SKILL.md +++ b/.agents/skills/analyze-ci-failure/SKILL.md @@ -15,7 +15,7 @@ check, invoke this skill by running: 1. **Resolves Log**: Automatically resolves the Buildkite job download URL or fetches GitHub Actions logs via `gh` CLI. 2. **Downloads & Ingests**: Fetches the full raw CI log file and saves it - locally. + locally to `.agents/skills/analyze-ci-failure/scratch/`. 3. **Smart Error Extraction**: Scans log lines for critical failure signatures (`Traceback`, `ERROR:`, `FAILED:`, missing packages, compiler aborts, linter errors). diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md index 010d112fb8..822ae1bdf8 100644 --- a/.agents/skills/create-pr/SKILL.md +++ b/.agents/skills/create-pr/SKILL.md @@ -15,6 +15,7 @@ to handle PR creation or description drafting. and PR descriptions** and **Documenting changes**) before drafting. - Strictly adhere to `CONTRIBUTING.md` rules for: - **PR Title**: Follow conventional commit style and title formatting. + For agent rules, skills, and system updates, use `agents:` prefix. - **PR Body**: Include rationale, high-level summary, and structure. - **Formatting**: Follow repository style guidelines and structure. - Create a Markdown artifact (`pr_info.md`) containing the PR title, body, @@ -23,6 +24,9 @@ to handle PR creation or description drafting. description, **do not** run `gh pr create`—just create the `pr_info.md` artifact for the user to review. Otherwise, execute `gh pr create` with the formatted title and body. + - **Targeting Upstream Repo**: When executing `gh pr create`, always target + the upstream repository by passing `--repo bazel-contrib/rules_python` and + `--head :`. 3. **Return Status**: Direct the subagent to communicate the PR number or draft status back using `send_message` (or `agentapi send-message`) with the parent conversation ID, or include it in its final completion response. From c11e5e84dad0209bd4073661bdccb7cc44d17cc0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 00:53:40 -0700 Subject: [PATCH 127/179] agents: update buildkite-retry-job skill to support job-level retries and rebuilds (#3983) Previously, the Buildkite retry functionality lacked granular job-level retry controls and explicit full-build rebuild options, defaulting to basic retries without flexible CLI flags for targeting specific failed jobs or executing pipeline rebuilds. To address this: * Update `retry_buildkite_jobs.py` to support `--jobs` for matching job names/patterns, `--job-id` for targeting specific job UUIDs, and `--rebuild` for full pipeline rebuilds via `bk build rebuild`. * Update default retry behavior to inspect build job states and automatically attempt job-level retries for failed jobs before falling back to full rebuilds. --- .agents/skills/buildkite-retry-job/SKILL.md | 28 ++-- .../scripts/retry_buildkite_jobs.py | 150 +++++++++++++++--- 2 files changed, 148 insertions(+), 30 deletions(-) diff --git a/.agents/skills/buildkite-retry-job/SKILL.md b/.agents/skills/buildkite-retry-job/SKILL.md index 3f43846da9..4b822e6222 100644 --- a/.agents/skills/buildkite-retry-job/SKILL.md +++ b/.agents/skills/buildkite-retry-job/SKILL.md @@ -1,17 +1,27 @@ --- name: buildkite-retry-job -description: Retry a failed build kite job +description: Retry failed Buildkite jobs individually or rebuild full builds --- -Use `scripts/retry_buildkite_jobs.py` to retry a job. This is best used -when there are network failures. +Use `scripts/retry_buildkite_jobs.py` to retry individual jobs or rebuild full +Buildkite builds. +### Retrying Modes -example: +1. **Job-Level Retry (Default for failed jobs)**: + Retries only specific failing jobs via `bk job retry `: + ```bash + ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py + ``` -``` -retry_buildkite_jobs.py org pipeline build -``` -You can also simply pass a PR number or a direct Buildkite build URL. +2. **Retry Specific Jobs by Name or ID**: + ```bash + ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py --jobs "compile_pip_requirements" + ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py --job-id + ``` -The `--jobs` flag can be used to retry specific jobs. +3. **Rebuild Full Build**: + Re-runs the entire pipeline build from scratch via `bk build rebuild`: + ```bash + ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py --rebuild + ``` diff --git a/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py b/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py index d501ce8f14..b0a154ac0c 100755 --- a/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py +++ b/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py @@ -43,39 +43,97 @@ def get_build_url_from_pr(pr_number): def normalize_build_target(target): # Transforms https://buildkite.com/bazel/rules-python-python/builds/15707 - # into bazel/rules-python-python/15707 - m = re.search(r"buildkite\.com/([^/]+)/([^/]+)/builds/(\d+)", target) + # into (org/pipeline, 15707) + m = re.search(r"buildkite\.com/([^/]+/[^/]+)/builds/(\d+)", target) if m: - return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" - return target + return m.group(1), m.group(2) + parts = target.split("/") + if len(parts) == 3: + return f"{parts[0]}/{parts[1]}", parts[2] + elif len(parts) == 2: + return parts[0], parts[1] + return "bazel/rules-python-python", target + + +def retry_job_by_uuid(job_id): + check_cli("bk", "https://github.com/buildkite/cli") + print(f"🚀 Retrying individual job UUID: {job_id}") + return subprocess.run(["bk", "job", "retry", job_id]) + + +def retry_jobs_by_name(pipeline, build_number, job_pattern): + check_cli("bk", "https://github.com/buildkite/cli") + cmd = ["bk", "build", "view", str(build_number), "-p", pipeline, "--json"] + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + data = json.loads(res.stdout) + jobs = data.get("jobs", []) + matched = [] + for j in jobs: + name = j.get("name", "") or j.get("label", "") + state = j.get("state", "") + jid = j.get("id") + if jid and ( + re.search(job_pattern, name, re.IGNORECASE) + or job_pattern.lower() in name.lower() + ): + matched.append((jid, name, state)) + + if not matched: + print( + f"⚠️ No jobs found matching pattern '{job_pattern}' in build #{build_number}", + file=sys.stderr, + ) + sys.exit(1) + + for jid, name, state in matched: + print(f"🚀 Retrying job '{name}' (ID: {jid}, status: {state})...") + subprocess.run(["bk", "job", "retry", jid]) + except Exception as e: + print(f"❌ Error fetching build jobs: {e}", file=sys.stderr) + sys.exit(1) def main(): parser = argparse.ArgumentParser( - description="Retry failed Buildkite jobs using the 'bk' CLI." + description="Retry Buildkite jobs individually using 'bk job retry' or rebuild the whole build using 'bk build rebuild'." ) parser.add_argument( "args", nargs="+", - help="Target build (org pipeline build OR a single PR# / URL / ID)", + help="Target build (PR#, Buildkite URL, or org/pipeline/build)", ) parser.add_argument( "--jobs", "--job-name", dest="job_name", - help="Specific job name or pattern to retry", + help="Specific job name or regex pattern to retry individually via 'bk job retry'", + ) + parser.add_argument( + "--job-id", + dest="job_id", + help="Specific job UUID to retry individually via 'bk job retry'", + ) + parser.add_argument( + "--rebuild", + action="store_true", + help="Rebuild the entire build afresh via 'bk build rebuild'", ) args = parser.parse_args() check_cli("bk", "https://github.com/buildkite/cli") + if args.job_id: + res = retry_job_by_uuid(args.job_id) + sys.exit(res.returncode) + if len(args.args) == 3: target = f"{args.args[0]}/{args.args[1]}/{args.args[2]}" elif len(args.args) == 1: target = args.args[0] else: print( - "❌ Error: Invalid arguments. Provide either 'org pipeline build' or a single target (PR#, URL, or org/pipeline/build).", + "❌ Error: Invalid arguments. Provide a single target (PR#, URL, or org/pipeline/build).", file=sys.stderr, ) sys.exit(1) @@ -84,23 +142,73 @@ def main(): print(f"🔍 Inspecting PR #{target} via gh to find Buildkite URL...") target = get_build_url_from_pr(target) - build_id = normalize_build_target(target) + pipeline, build_number = normalize_build_target(target) if args.job_name: - print(f"🚀 Retrying jobs matching '{args.job_name}' in build: {build_id}") - res = subprocess.run(["bk", "build", "retry", build_id, "--failed"]) - else: - print(f"🚀 Retrying all failed jobs in build: {build_id}") - res = subprocess.run(["bk", "build", "retry", build_id, "--failed"]) - - if res.returncode != 0: - print( - f"❌ Failed to retry build '{build_id}' via 'bk' CLI.", - file=sys.stderr, + retry_jobs_by_name(pipeline, build_number, args.job_name) + elif args.rebuild: + print(f"🚀 Rebuilding entire build #{build_number} for pipeline: {pipeline}") + res = subprocess.run( + ["bk", "build", "rebuild", build_number, "-p", pipeline, "--yes"] ) sys.exit(res.returncode) - - print(f"🎉 Successfully triggered retry for build: {build_id}") + else: + # Default behavior: try job-level retries of failed jobs if any, otherwise rebuild + cmd = [ + "bk", + "build", + "view", + str(build_number), + "-p", + pipeline, + "--json", + ] + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + data = json.loads(res.stdout) + failed_jobs = [ + j + for j in data.get("jobs", []) + if j.get("state") in ("failed", "broken", "timed_out") and j.get("id") + ] + if failed_jobs: + for j in failed_jobs: + jid = j["id"] + jname = j.get("name") or j.get("label") or jid + print(f"🚀 Retrying failed job '{jname}' (ID: {jid})...") + subprocess.run(["bk", "job", "retry", jid]) + else: + print( + f"🚀 Rebuilding entire build #{build_number} for pipeline: {pipeline}" + ) + res = subprocess.run( + [ + "bk", + "build", + "rebuild", + build_number, + "-p", + pipeline, + "--yes", + ] + ) + sys.exit(res.returncode) + except Exception: + print( + f"🚀 Rebuilding entire build #{build_number} for pipeline: {pipeline}" + ) + res = subprocess.run( + [ + "bk", + "build", + "rebuild", + build_number, + "-p", + pipeline, + "--yes", + ] + ) + sys.exit(res.returncode) if __name__ == "__main__": From 216f0f55132eb58ffec398626db0e7e09f321315 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 02:16:39 -0700 Subject: [PATCH 128/179] agents: prevent duplicate CI monitoring processes in monitor-ci-results skill (#3984) Previously, launching multiple CI monitoring workflows or updating PRs could spawn duplicate `monitor_remote_ci.py` background processes for the same pull request, causing redundant polling and notification noise. To prevent duplication: * Implement non-blocking file locking (`fcntl.flock`) in `monitor_remote_ci.py` using a lockfile in the skill's `scratch/` directory (`.monitored_pr_.lock`). * Automatically exit gracefully with an informative message if another process is already monitoring the target PR. * Update `SKILL.md` guidance to instruct agents to check for existing monitoring processes before launching new ones. * Relocate monitoring state files to the skill `scratch/` directory to keep the skill directory clean. --- .agents/skills/monitor-ci-results/SKILL.md | 21 ++++++--- .../scripts/monitor_remote_ci.py | 45 ++++++++++++++++++- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/.agents/skills/monitor-ci-results/SKILL.md b/.agents/skills/monitor-ci-results/SKILL.md index 1cef8293f4..3a1fab866b 100644 --- a/.agents/skills/monitor-ci-results/SKILL.md +++ b/.agents/skills/monitor-ci-results/SKILL.md @@ -5,7 +5,17 @@ description: Monitor remote CI results for a PR and autonomously launch --- When the user requests to monitor remote CI results or watch a pull request, -launch the monitoring script in the background: +or when monitoring CI after PR updates: + +> **Note**: `monitor_remote_ci.py` is a single long-running continuous task. +> It continuously watches all current and future CI runs for the PR. Do NOT +> launch duplicate monitoring jobs for the same PR. + +1. **Check Existing Process**: Check if a monitor script is already running for + the PR (e.g., `pgrep -f "monitor_remote_ci.py "`). If one is + already running, do not start another instance. +2. **Launch Monitoring Script**: If no monitor process is active for + ``, launch the script in the background: ```bash ./.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py \ "" & @@ -13,10 +23,11 @@ launch the monitoring script in the background: ### ✨ Autonomous Subagent Orchestration 1. **Background Polling**: `monitor_remote_ci.py` continuously polls both - GitHub PR checks and Buildkite workflow executions in the background. -2. **Blocked Jobs**: When a Buildkite job or GitHub check is in a blocked - state waiting for user confirmation, it dispatches a notification via - `agentapi send-message` so the user is alerted to confirm running the job. + GitHub PR checks and Buildkite workflow executions in the background across + new commits and CI re-runs. +2. **Blocked Jobs**: When a Buildkite job or GitHub check is in a blocked state + waiting for user confirmation, it dispatches a notification via `agentapi + send-message` so the user is alerted to confirm running the job. 3. **Failure Reporting**: When any GitHub check or Buildkite job completes with errors, `monitor_remote_ci.py` dispatches a high-priority notification message reporting the failed check back to your conversation. diff --git a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py index f668ed96f5..9b5970757e 100755 --- a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py +++ b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import fcntl import json import os import subprocess @@ -79,9 +80,49 @@ def main(): ) args = parser.parse_args() - skill_dir = os.path.abspath(os.path.dirname(__file__)) + skill_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + scratch_dir = os.path.join(skill_dir, "scratch") + os.makedirs(scratch_dir, exist_ok=True) - state_file = os.path.join(skill_dir, f"monitored_state_pr_{args.pr}.json") + # Acquire lock for PR to avoid duplicate monitoring processes + lock_file_path = os.path.join(scratch_dir, f".monitored_pr_{args.pr}.lock") + lock_file = open(lock_file_path, "a+") + running_agent_conv_id = os.environ.get("ANTIGRAVITY_CONVERSATION_ID", "") + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + lock_file.seek(0) + lock_file.truncate(0) + lock_data = { + "pid": os.getpid(), + "agent_conv_id": running_agent_conv_id, + "notify_conv_id": args.conv_id, + } + lock_file.write(json.dumps(lock_data) + "\n") + lock_file.flush() + except (BlockingIOError, OSError): + existing_agent_conv = "" + try: + lock_file.seek(0) + content = lock_file.read().strip() + if content: + data = json.loads(content) + existing_agent_conv = data.get("agent_conv_id", "") or data.get( + "conv_id", "" + ) + except Exception: + pass + + subagent_str = ( + f" under agent/subagent conversation '{existing_agent_conv}'" + if existing_agent_conv + else "" + ) + print( + f"ℹ️ Continuous remote CI monitoring for PR #{args.pr} is already running in another process{subagent_str}. Exiting." + ) + sys.exit(0) + + state_file = os.path.join(scratch_dir, f"monitored_state_pr_{args.pr}.json") monitored = {} if os.path.exists(state_file): try: From ddc675c9a5235d03aa98aae8637860e51ab30752 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 05:38:09 -0700 Subject: [PATCH 129/179] test: improve error reporting and artifact generation for lockfile test (#3982) When `bzlmod_lockfile_test` fails due to an out-of-date `MODULE.bazel.lock` file (e.g. under `--lockfile_mode=error`), Bazel previously output a generic failure message without indicating what diff changed or providing an updated lockfile artifact. This made debugging CI lockfile failures tedious and manual. To improve the developer experience: * On test failure, automatically run `bazel mod deps --lockfile_mode=update` to compute the expected lockfile changes. * Output a unified diff between the checked-in and expected `MODULE.bazel.lock` directly in the test failure logs along with actionable update commands. * Export the updated `MODULE.bazel.lock` and `MODULE.bazel.lock.diff` to `TEST_UNDECLARED_OUTPUTS_DIR` as build artifacts, allowing them to be downloaded directly in the Buildkite UI. --- tests/integration/BUILD.bazel | 1 + tests/integration/bzlmod_lockfile_test.py | 59 +++++++++++++++++++++++ tests/integration/runner.py | 3 ++ 3 files changed, 63 insertions(+) create mode 100644 tests/integration/bzlmod_lockfile_test.py diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel index ef7681ebe3..13b9c2e855 100644 --- a/tests/integration/BUILD.bazel +++ b/tests/integration/BUILD.bazel @@ -42,6 +42,7 @@ default_test_runner( rules_python_integration_test( name = "bzlmod_lockfile_test", bazel_versions = ["9.1.0"], + py_main = "bzlmod_lockfile_test.py", ) test_suite( diff --git a/tests/integration/bzlmod_lockfile_test.py b/tests/integration/bzlmod_lockfile_test.py new file mode 100644 index 0000000000..650f132f0f --- /dev/null +++ b/tests/integration/bzlmod_lockfile_test.py @@ -0,0 +1,59 @@ +import difflib +import os +import pathlib +import unittest + +from tests.integration import runner + + +class BzlmodLockfileTest(runner.TestCase): + def test_bzlmod_lockfile(self): + lockfile_path = self.repo_root / "MODULE.bazel.lock" + self.assertTrue( + lockfile_path.exists(), + f"Expected lockfile at {lockfile_path}", + ) + original_lockfile = lockfile_path.read_text() + + res = self.run_bazel("test", "//...", check=False) + if res.exit_code == 0: + return + + # Generate updated lockfile to compare diff and export artifact + self.run_bazel("mod", "deps", "--lockfile_mode=update", check=False) + updated_lockfile = lockfile_path.read_text() if lockfile_path.exists() else "" + + undeclared_outputs_dir = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR") + if undeclared_outputs_dir: + out_dir = pathlib.Path(undeclared_outputs_dir) + (out_dir / "MODULE.bazel.lock").write_text(updated_lockfile) + + if original_lockfile != updated_lockfile: + diff_lines = list( + difflib.unified_diff( + original_lockfile.splitlines(keepends=True), + updated_lockfile.splitlines(keepends=True), + fromfile="MODULE.bazel.lock (checked-in)", + tofile="MODULE.bazel.lock (expected/updated)", + ) + ) + diff_str = "".join(diff_lines) + + if undeclared_outputs_dir: + (out_dir / "MODULE.bazel.lock.diff").write_text(diff_str) + + msg = ( + f"MODULE.bazel.lock is out of date.\n\n" + f"--- DIFF ---\n{diff_str}\n" + f"--- END DIFF ---\n\n" + f"To update the lockfile, run:\n" + f" bazel mod deps --lockfile_mode=update\n" + f"inside tests/integration/bzlmod_lockfile, or copy the updated MODULE.bazel.lock artifact.\n" + ) + self.fail(msg) + else: + self.fail(f"bazel test //... failed:\n{res.describe()}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/runner.py b/tests/integration/runner.py index 2534ab2d90..c187623b3c 100644 --- a/tests/integration/runner.py +++ b/tests/integration/runner.py @@ -80,10 +80,13 @@ def setUp(self): # Put the global tmp not under the test tmp to better match how a real # execution has entirely different directories for these. self.tmp_dir = outer_test_tmpdir / "bit_tmp" + self.test_tmp_dir.mkdir(parents=True, exist_ok=True) + self.tmp_dir.mkdir(parents=True, exist_ok=True) self.bazel_env = { "PATH": os.environ["PATH"], "TEST_TMPDIR": str(self.test_tmp_dir), "TMP": str(self.tmp_dir), + "TEMP": str(self.tmp_dir), # For some reason, this is necessary for Bazel 6.4 to work. # If not present, it can't find some bash helpers in @bazel_tools "RUNFILES_DIR": os.environ["TEST_SRCDIR"], From 70734baa7dcc9e93be71682e1642711eb1729327 Mon Sep 17 00:00:00 2001 From: Lequn Chen Date: Sat, 1 Aug 2026 06:14:45 -0700 Subject: [PATCH 130/179] feat(pypi): support non-sha256 --hash pins and index digests (#3974) Previously only `--hash=sha256:` pins and `#sha256=` URL fragments were recognized. Pins using any other hash algorithm were silently dropped: the requirement was treated as hash-less, and in the pip fallback the hashes were stripped from the requirement line entirely, so the artifact was no longer verified against what was locked. Now a distribution digest is carried through the code as one canonical `:` string (the same shape as pip `--hash` and uv.lock `hash` values) for any `hashlib` algorithm: - a new `hash.bzl` module (single `hash` symbol) centralizes digest parsing, validation and conversion to SRI; - `parse_simpleapi_html` accepts any PEP 503 `#=` fragment and keys `whls`/`sdists` by the canonical digest; `sha256s_by_version` is renamed to `hashes_by_version`; - matching pins against the index is a direct dict lookup; when the index only advertises a different algorithm the requirement falls back to pip with the pins kept in the requirement line, so nothing installs unverified (pip verifies sha256/384/512 pins and rejects requirement lines pinned only with other algorithms); - `whl_library` gains an `integrity` attribute, and the extension now always passes the digest as an SRI `integrity` value instead of `sha256`, so the lock entries use one format for all algorithms; a malformed (non-hex) sha256/384/512 digest fails loudly at extension evaluation instead of silently disabling download verification; - the lock file facts store `:` values (`_FACT_VERSION` bumped to `v2`; old facts are refetched once); this also fixes the latent bug where `dist_filenames` was written keyed by URL but read back keyed by digest, so stored filenames were never found; - uv.lock `hash` values pass through for any algorithm instead of being mangled by a `sha256:` string replace. Repo names are unchanged for sha256 digests, and sha256 stays the preferred algorithm when a direct-URL requirement has several pins. Digests whose algorithm has no SRI form (e.g. md5) still match the index but are downloaded without downloader-side verification, with a warning. Fixes #3972 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com> --- docs/pypi/download.md | 5 +- news/3972.fixed.md | 11 + python/private/pypi/BUILD.bazel | 9 + python/private/pypi/extension.bzl | 4 +- python/private/pypi/hash.bzl | 189 +++++ python/private/pypi/hub_builder.bzl | 10 +- python/private/pypi/index_sources.bzl | 33 +- python/private/pypi/parse_requirements.bzl | 53 +- python/private/pypi/parse_simpleapi_html.bzl | 32 +- python/private/pypi/pypi_cache.bzl | 52 +- python/private/pypi/simpleapi_download.bzl | 4 +- python/private/pypi/whl_library.bzl | 24 +- python/private/pypi/whl_repo_name.bzl | 16 +- .../bzlmod_lockfile/MODULE.bazel.lock | 646 +++++++++--------- tests/pypi/hash/BUILD.bazel | 3 + tests/pypi/hash/hash_tests.bzl | 102 +++ tests/pypi/hub_builder/hub_builder_tests.bzl | 129 +++- .../index_sources/index_sources_tests.bzl | 82 ++- .../parse_requirements_tests.bzl | 210 ++++-- .../parse_simpleapi_html_tests.bzl | 94 ++- tests/pypi/pypi_cache/pypi_cache_tests.bzl | 195 ++++-- .../simpleapi_download_tests.bzl | 16 +- .../whl_repo_name/whl_repo_name_tests.bzl | 12 + 23 files changed, 1333 insertions(+), 598 deletions(-) create mode 100644 news/3972.fixed.md create mode 100644 python/private/pypi/hash.bzl create mode 100644 tests/pypi/hash/BUILD.bazel create mode 100644 tests/pypi/hash/hash_tests.bzl diff --git a/docs/pypi/download.md b/docs/pypi/download.md index e819ed0791..fb7f38dc24 100644 --- a/docs/pypi/download.md +++ b/docs/pypi/download.md @@ -342,8 +342,9 @@ This does not mean that `rules_python` is fetching the wheels eagerly; rather, it means that it is calling the PyPI server to get the Simple API response to get the list of all available source and wheel distributions. Once it has gotten all of the available distributions, it will select the right ones depending -on the `sha256` values in your `requirements_lock.txt` file. If `sha256` hashes -are not present in the requirements file, we will fall back to matching by version +on the `--hash` values in your `requirements_lock.txt` file (any hash algorithm +advertised by the index can be matched, not only `sha256`). If hashes are not +present in the requirements file, we will fall back to matching by version specified in the lock file. Fetching the distribution information from the PyPI allows `rules_python` to diff --git a/news/3972.fixed.md b/news/3972.fixed.md new file mode 100644 index 0000000000..f96f803988 --- /dev/null +++ b/news/3972.fixed.md @@ -0,0 +1,11 @@ +(pypi) Requirement `--hash=:` pins and Simple API +`#=` URL fragments are now parsed for all hash algorithms +instead of silently dropping everything except `sha256`. Non-sha256 pins are +matched against the digests advertised by the index and downloads are verified +using the corresponding Subresource Integrity value, and the pins are kept in +the requirement line when falling back to `pip` +([#3972](https://github.com/bazel-contrib/rules_python/issues/3972)). +As part of this, `whl_library` repos created by `pip.parse` now always pass +the digest via the `integrity` attribute (SRI format) instead of `sha256`, +and the lock file facts store digests as `:` values (the facts +version was bumped, so cached index information is refreshed once). diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 09ba5ef135..775aa0d9d2 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -166,6 +166,7 @@ bzl_library( srcs = ["hub_builder.bzl"], deps = [ ":attrs", + ":hash", ":parse_requirements", ":pep508_env", ":pep508_evaluate", @@ -222,6 +223,7 @@ bzl_library( srcs = ["parse_requirements.bzl"], deps = [ ":argparse", + ":hash", ":index_sources", ":parse_requirements_txt", ":pep508_evaluate", @@ -237,6 +239,7 @@ bzl_library( name = "parse_simpleapi_html", srcs = ["parse_simpleapi_html.bzl"], deps = [ + ":hash", ":version_from_filename", "//python/private:normalize_name", ], @@ -534,9 +537,15 @@ bzl_library( srcs = ["env_marker_info.bzl"], ) +bzl_library( + name = "hash", + srcs = ["hash.bzl"], +) + bzl_library( name = "index_sources", srcs = ["index_sources.bzl"], + deps = [":hash"], ) bzl_library( diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index 412b03c21f..210e169d4d 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -692,8 +692,8 @@ This value is going to be subject to `envsubst` substitutions if necessary, look The indexes must support Simple API as described here: https://packaging.python.org/en/latest/specifications/simple-repository-api/ -Index metadata will be used to get `sha256` values for packages even if the -`sha256` values are not present in the requirements.txt lock file. +Index metadata will be used to get the hash digest values for packages even +if the `--hash` values are not present in the requirements.txt lock file. Defaults to `https://pypi.org/simple`. diff --git a/python/private/pypi/hash.bzl b/python/private/pypi/hash.bzl new file mode 100644 index 0000000000..bcdfaecf00 --- /dev/null +++ b/python/private/pypi/hash.bzl @@ -0,0 +1,189 @@ +# Copyright 2026 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. + +"""Helpers for working with distribution hash digests. + +Both the requirement `--hash=:` option (PEP 508 tooling) and the +Simple API `#=` URL fragments (PEP 503) or `hashes` dicts +(PEP 691) may use any algorithm from `hashlib.algorithms_guaranteed`, even +though `sha256` is the most common one. + +Internally a single digest is always represented as the canonical +`:` string (the same shape as the pip `--hash` values and +the uv.lock `hash` values) and only converted to the Subresource Integrity +format at the `ctx.download(integrity = ...)` boundary. +""" + +# The names from `hashlib.algorithms_guaranteed`, which PEP 691 uses as the +# set of valid hash names and PEP 503 strongly recommends fragments to come +# from. +_ALGOS = [ + "blake2b", + "blake2s", + "md5", + "sha1", + "sha224", + "sha256", + "sha384", + "sha3_224", + "sha3_256", + "sha3_384", + "sha3_512", + "sha512", + "shake_128", + "shake_256", +] + +# The algorithms supported by the Subresource Integrity format understood by +# `ctx.download(integrity = ...)`, strongest first. +_SRI_ALGOS = ["sha512", "sha384", "sha256"] + +# The order used to pick the digest identifying an artifact when several are +# available: `sha256` first so that repo names stay stable for the common +# case, then the remaining algorithms that the bazel downloader can verify. +_PREFERRED_ALGOS = ["sha256", "sha512", "sha384"] + +_HEX = "0123456789abcdef" +_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +def _hex_to_b64(hex_digest): + if len(hex_digest) % 2: + return None + + data = [] + for i in range(0, len(hex_digest), 2): + hi = _HEX.find(hex_digest[i]) + lo = _HEX.find(hex_digest[i + 1]) + if hi < 0 or lo < 0: + return None + data.append(hi * 16 + lo) + + out = [] + for i in range(0, len(data) // 3 * 3, 3): + n = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2] + out.append(_B64[n >> 18]) + out.append(_B64[(n >> 12) & 63]) + out.append(_B64[(n >> 6) & 63]) + out.append(_B64[n & 63]) + + rem = len(data) % 3 + if rem == 1: + n = data[-1] << 16 + out.append(_B64[n >> 18]) + out.append(_B64[(n >> 12) & 63]) + out.append("==") + elif rem == 2: + n = (data[-2] << 16) | (data[-1] << 8) + out.append(_B64[n >> 18]) + out.append(_B64[(n >> 12) & 63]) + out.append(_B64[(n >> 6) & 63]) + out.append("=") + + return "".join(out) + +def _digest(algo, hex_digest): + """Construct a canonical `:` digest string. + + Args: + algo: {type}`str` the hash algorithm name, e.g. `sha256`. + hex_digest: {type}`str` the hex encoded digest. + + Returns: + {type}`str` the canonical digest string or an empty string if the + algorithm is not a known `hashlib` algorithm or the digest is empty. + """ + algo = algo.lower() + if not hex_digest or algo not in _ALGOS: + return "" + + return "{}:{}".format(algo, hex_digest) + +def _hex_to_sri(algo, hex_digest): + """Convert a hex digest to a Subresource Integrity value. + + Args: + algo: {type}`str` the hash algorithm name, e.g. `sha512`. + hex_digest: {type}`str` the hex encoded digest. + + Returns: + {type}`str` the SRI value (e.g. `sha512-...`) that can be passed to + `ctx.download(integrity = ...)` or an empty string if the algorithm + cannot be expressed as SRI or the digest is not valid hex. + """ + if algo not in _SRI_ALGOS or not hex_digest: + return "" + + b64 = _hex_to_b64(hex_digest.lower()) + if b64 == None: + return "" + + return "{}-{}".format(algo, b64) + +def _integrity(digest): + """Get the SRI value for a canonical `:` string. + + Args: + digest: {type}`str` the canonical digest string. + + Returns: + {type}`str` the SRI value (e.g. `sha256-...`) that can be passed to + `ctx.download(integrity = ...)` or an empty string if the algorithm + cannot be expressed as SRI. Fails if the algorithm is SRI supported + but the digest is not valid hex, because silently returning an empty + string would disable the download verification for an artifact that + should be verifiable. + """ + algo, _, hex_digest = digest.partition(":") + if algo not in _SRI_ALGOS or not hex_digest: + return "" + + sri = _hex_to_sri(algo, hex_digest) + if not sri: + fail("Invalid {} digest: {}".format(algo, hex_digest)) + + return sri + +def _preferred_digest(digests): + """Pick the digest that identifies an artifact, e.g. for repo naming. + + Args: + digests: {type}`list[str]` canonical `:` strings. + + Returns: + {type}`str` the canonical digest string of the most preferred + algorithm present or an empty string if there are no digests. + """ + by_algo = {} + for digest in digests: + if not digest: + continue + algo, _, _ = digest.partition(":") + by_algo.setdefault(algo, digest) + + if not by_algo: + return "" + + for algo in _PREFERRED_ALGOS: + if algo in by_algo: + return by_algo[algo] + + return by_algo[sorted(by_algo.keys())[0]] + +hash = struct( + ALGOS = _ALGOS, + digest = _digest, + hex_to_sri = _hex_to_sri, + integrity = _integrity, + preferred_digest = _preferred_digest, +) diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl index d7974d39ad..596cfb2cbe 100644 --- a/python/private/pypi/hub_builder.bzl +++ b/python/private/pypi/hub_builder.bzl @@ -8,6 +8,7 @@ load("//python/private:text_util.bzl", "render") load("//python/private:version.bzl", "version") load("//python/private:version_label.bzl", "version_label") load(":attrs.bzl", "use_isolated") +load(":hash.bzl", "hash") load(":parse_requirements.bzl", "parse_requirements") load(":pep508_env.bzl", "env") load(":pep508_evaluate.bzl", "evaluate") @@ -676,7 +677,12 @@ def _whl_repo( args["index_url"] = index_url args["urls"] = [src.url] - args["sha256"] = src.sha256 + + # The digest is always passed in the SRI format that the bazel downloader + # supports. It may be empty if the digest uses a hash algorithm that + # cannot be expressed as SRI (e.g. `md5`), in which case `whl_library` + # will download without verification and warn. + args["integrity"] = hash.integrity(src.digest) args["filename"] = src.filename # TODO @aignas 2025-11-02: once we have pipstar enabled we can add extra @@ -685,7 +691,7 @@ def _whl_repo( target_platforms = src.target_platforms if is_multiple_versions else [] return struct( - repo_name = whl_repo_name(src.filename, src.sha256, *target_platforms), + repo_name = whl_repo_name(src.filename, src.digest, *target_platforms), args = args, config_setting = whl_config_setting( version = python_version, diff --git a/python/private/pypi/index_sources.bzl b/python/private/pypi/index_sources.bzl index 1998e4fb33..4353f5051a 100644 --- a/python/private/pypi/index_sources.bzl +++ b/python/private/pypi/index_sources.bzl @@ -16,6 +16,8 @@ A file that houses private functions used in the `bzlmod` extension with the same name. """ +load(":hash.bzl", "hash") + # Just list them here and me super conservative _KNOWN_EXTS = [ # Note, the following source in pip has more extensions @@ -43,8 +45,10 @@ def index_sources(line): line(str): The requirements.txt entry. Returns: - A struct with shas attribute containing: - * `shas` - list[str]; shas to download from pypi_index. + A struct with hashes attribute containing: + * `hashes` - list[str]; `:` hashes of the artifacts + to download from pypi_index. Note that any hash algorithm from + {obj}`hash.ALGOS` is accepted, not only `sha256`. * `version` - str; version of the package. * `marker` - str; the marker expression, as per PEP508 spec. * `requirement` - str; a requirement line without the marker. This can @@ -58,10 +62,12 @@ def index_sources(line): marker, _, _ = maybe_hashes.partition("--hash=") maybe_hashes = maybe_hashes or line - shas = [ - sha.strip() - for sha in maybe_hashes.split("--hash=sha256:")[1:] - ] + hashes = [] + for h in maybe_hashes.split("--hash=")[1:]: + algo, _, hex_digest = h.strip().partition(" ")[0].partition(":") + digest = hash.digest(algo, hex_digest) + if digest: + hashes.append(digest) marker = marker.strip() if head == line: @@ -71,7 +77,7 @@ def index_sources(line): requirement_line = "{} {}".format( requirement, - " ".join(["--hash=sha256:{}".format(sha) for sha in shas]), + " ".join(["--hash={}".format(h) for h in hashes]), ).strip() url = "" @@ -80,9 +86,14 @@ def index_sources(line): maybe_requirement, _, url_and_rest = requirement.partition("@") url = url_and_rest.strip().partition(" ")[0].strip() - url, _, sha256 = url.partition("#sha256=") - if sha256: - shas.append(sha256) + url, _, fragment = url.partition("#") + algo, _, hex_digest = fragment.partition("=") + digest = hash.digest(algo, hex_digest) + if digest: + hashes.append(digest) + elif fragment: + # Not a hash fragment (e.g. `#egg=`), keep it as part of the URL. + url = "{}#{}".format(url, fragment) _, _, filename = url.rpartition("/") # Replace URL encoded characters and luckily there is only one case @@ -104,7 +115,7 @@ def index_sources(line): requirement = requirement, requirement_line = requirement_line, version = version, - shas = sorted(shas), + hashes = sorted(hashes), marker = marker, url = url, filename = filename, diff --git a/python/private/pypi/parse_requirements.bzl b/python/private/pypi/parse_requirements.bzl index 648ec9e65c..4874c7ac79 100644 --- a/python/private/pypi/parse_requirements.bzl +++ b/python/private/pypi/parse_requirements.bzl @@ -30,6 +30,7 @@ load("//python/private:normalize_name.bzl", "normalize_name") load("//python/private:repo_utils.bzl", "repo_utils") load("//python/uv/private:uv_lock_to_requirements.bzl", "uv_lock_extras_map") # buildifier: disable=bzl-visibility load(":argparse.bzl", "argparse") +load(":hash.bzl", "hash") load(":index_sources.bzl", "index_sources") load(":parse_requirements_txt.bzl", "parse_requirements_txt") load(":pep508_evaluate.bzl", "evaluate") @@ -82,7 +83,8 @@ def parse_requirements( * `index_url`: {type}`str` The index URL used to download the package. * `srcs`: {type}`list[struct]` A list of per-distribution source entries, each containing: `distribution`, `extra_pip_args`, `requirement_line`, - `target_platforms`, `filename`, `sha256`, `url`, `yanked`. + `target_platforms`, `filename`, `digest`, `url`, `yanked`. The `digest` + is a `:` string or empty if the artifact digest is unknown. """ if uv_lock and toml_decode: uv_lock = toml_decode(ctx.read(uv_lock)) @@ -203,11 +205,10 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p for wheel in pkg.get("wheels", []): url = wheel["url"] _, _, filename = url.rpartition("/") - sha256 = wheel.get("hash", "").replace("sha256:", "") candidates.append(struct( filename = filename, url = url, - sha256 = sha256, + digest = _parse_uv_lock_hash(wheel.get("hash", "")), kind = "wheel", )) @@ -216,11 +217,10 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p if sdist: url = sdist["url"] _, _, filename = url.rpartition("/") - sha256 = sdist.get("hash", "").replace("sha256:", "") sdist_struct = struct( filename = filename, url = url, - sha256 = sha256, + digest = _parse_uv_lock_hash(sdist.get("hash", "")), kind = "sdist", ) @@ -231,7 +231,7 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p git_struct = struct( filename = filename, url = url, - sha256 = "", + digest = "", kind = "git", source = pkg["source"], ) @@ -264,7 +264,7 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p # Group platforms by resolved source src_to_plats = {} for p, src in plat_to_src.items(): - key = src.filename + src.sha256 + key = src.filename + src.digest src_to_plats.setdefault(key, struct(src = src, plats = [])).plats.append(p) # Build resolved_srcs @@ -282,7 +282,7 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p requirement_line = requirement_line, target_platforms = plats, filename = src.filename, - sha256 = src.sha256, + digest = src.digest, url = src.url, yanked = None, )) @@ -309,6 +309,15 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p logger.debug(lambda: "Parsed {} packages from uv.lock".format(len(ret))) return ret +def _parse_uv_lock_hash(hash_str): + """Parse a uv.lock `hash` value of the form `:`. + + The algorithm is not necessarily `sha256` because uv records the strongest + digest that the index offers. + """ + algo, _, hex_digest = hash_str.partition(":") + return hash.digest(algo, hex_digest) + def _parse_requirements_from_req_files( ctx, *, @@ -495,7 +504,7 @@ def _package_srcs( dist = struct( url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ) req_line = r.srcs.requirement_line @@ -515,7 +524,7 @@ def _package_srcs( requirement_line = req_line, target_platforms = [], filename = dist.filename, - sha256 = dist.sha256, + digest = dist.digest, url = dist.url, yanked = dist.yanked, ), @@ -607,7 +616,7 @@ def _add_dists(*, requirement, index_urls, target_platform, logger = None): dist = struct( url = requirement.srcs.url, filename = requirement.srcs.filename, - sha256 = requirement.srcs.shas[0] if requirement.srcs.shas else "", + digest = hash.preferred_digest(requirement.srcs.hashes), yanked = None, ) @@ -619,29 +628,29 @@ def _add_dists(*, requirement, index_urls, target_platform, logger = None): whls = [] sdist = None - # First try to find distributions by SHA256 if provided - shas_to_use = requirement.srcs.shas - if not shas_to_use: + # First try to find distributions by the hashes if provided + hashes_to_use = requirement.srcs.hashes + if not hashes_to_use: version = requirement.srcs.version - shas_to_use = index_urls.sha256s_by_version.get(version, []) - logger.warn(lambda: "requirement file has been generated without hashes, will use all hashes for the given version {} that could find on the index:\n {}".format(version, shas_to_use)) + hashes_to_use = index_urls.hashes_by_version.get(version, []) + logger.warn(lambda: "requirement file has been generated without hashes, will use all hashes for the given version {} that could find on the index:\n {}".format(version, hashes_to_use)) - for sha256 in shas_to_use: + for digest in hashes_to_use: # For now if the artifact is marked as yanked we just ignore it. # # See https://packaging.python.org/en/latest/specifications/simple-repository-api/#adding-yank-support-to-the-simple-api - maybe_whl = index_urls.whls.get(sha256) + maybe_whl = index_urls.whls.get(digest) if maybe_whl and maybe_whl.yanked == None: whls.append(maybe_whl) continue - maybe_sdist = index_urls.sdists.get(sha256) + maybe_sdist = index_urls.sdists.get(digest) if maybe_sdist and maybe_sdist.yanked == None: sdist = maybe_sdist continue - logger.warn(lambda: "Could not find a whl or an sdist with sha256={}".format(sha256)) + logger.warn(lambda: "Could not find a whl or an sdist with hash {}; note that the index may be advertising digests calculated with a different hash algorithm".format(digest)) yanked = {} for dist in whls + [sdist]: @@ -662,8 +671,8 @@ def _add_dists(*, requirement, index_urls, target_platform, logger = None): if not whls and not sdist: # If there are no suitable wheels to handle for now allow fallback to pip, it # may be a little bit more helpful when debugging? Most likely something is - # going a bit wrong here, should we raise an error because the sha256 have most - # likely mismatched? We are already printing a warning above. + # going a bit wrong here, should we raise an error because the digests have + # most likely mismatched? We are already printing a warning above. return None, True # Select a single wheel that can work on the target_platform diff --git a/python/private/pypi/parse_simpleapi_html.bzl b/python/private/pypi/parse_simpleapi_html.bzl index 7f0d2776d7..e919c8ca55 100644 --- a/python/private/pypi/parse_simpleapi_html.bzl +++ b/python/private/pypi/parse_simpleapi_html.bzl @@ -17,10 +17,11 @@ Parse SimpleAPI HTML in Starlark. """ load("//python/private:normalize_name.bzl", "normalize_name") +load(":hash.bzl", "hash") load(":version_from_filename.bzl", "version_from_filename") def parse_simpleapi_html(*, content, parse_index = False): - """Get the package URLs for given shas by parsing the Simple API HTML. + """Get the package URLs for given digests by parsing the Simple API HTML. Args: content: {type}`str` The Simple API HTML content. @@ -29,11 +30,15 @@ def parse_simpleapi_html(*, content, parse_index = False): Returns: If it is the index page, return the map of package to URL it can be queried from. - Otherwise, a list of structs with: + Otherwise, a struct with `whls` and `sdists` dicts keyed by the `:` + value advertised in the URL fragment (PEP 503, most commonly `sha256`, but any + algorithm from {obj}`hash.ALGOS` is accepted) and `hashes_by_version` mapping each + version to the digests of its artifacts. The dict values are structs with: * filename: {type}`str` The filename of the artifact. * version: {type}`str` The version of the artifact. * url: {type}`str` The URL to download the artifact. - * sha256: {type}`str` The sha256 of the artifact. + * digest: {type}`str` The `:` value of the artifact, may be empty + if the index does not advertise any digest. * metadata_sha256: {type}`str` The whl METADATA sha256 if we can download it. If this is present, then the 'metadata_url' is also present. Defaults to "". * metadata_url: {type}`str` The URL for the METADATA if we can download it. Defaults to "". @@ -43,7 +48,7 @@ def parse_simpleapi_html(*, content, parse_index = False): """ sdists = {} whls = {} - sha256s_by_version = {} + hashes_by_version = {} # 1. Faster Version Extraction # Search only the first 2KB for versioning metadata instead of splitting everything @@ -101,7 +106,14 @@ def parse_simpleapi_html(*, content, parse_index = False): continue # 3. Efficient Attribute Parsing - dist_url, _, sha256 = href.partition("#sha256=") + # PEP 503 says the URL fragment SHOULD be `#=` where the + # hash name may be any algorithm from `hashlib`, not only `sha256`. + dist_url, _, fragment = href.partition("#") + algo, _, hex_digest = fragment.partition("=") + digest = hash.digest(algo, hex_digest) + if not digest and fragment: + # Not a hash fragment, keep it as part of the URL. + dist_url = "{}#{}".format(dist_url, fragment) # Handle Yanked status yanked = None @@ -109,7 +121,7 @@ def parse_simpleapi_html(*, content, parse_index = False): yanked = _unescape_pypi_html(attrs["data-yanked"]) version = version_from_filename(filename) - sha256s_by_version.setdefault(version, []).append(sha256) + hashes_by_version.setdefault(version, []).append(digest) # 4. Optimized Metadata Check (PEP 714) metadata_sha256 = "" @@ -126,16 +138,16 @@ def parse_simpleapi_html(*, content, parse_index = False): filename = filename, version = version, url = dist_url, - sha256 = sha256, + digest = digest, metadata_sha256 = metadata_sha256, metadata_url = metadata_url, yanked = yanked, ) if filename.endswith(".whl"): - whls[sha256] = dist + whls[digest] = dist else: - sdists[sha256] = dist + sdists[digest] = dist if parse_index: return packages @@ -143,7 +155,7 @@ def parse_simpleapi_html(*, content, parse_index = False): return struct( sdists = sdists, whls = whls, - sha256s_by_version = sha256s_by_version, + hashes_by_version = hashes_by_version, ) def _parse_attrs(attr_string): diff --git a/python/private/pypi/pypi_cache.bzl b/python/private/pypi/pypi_cache.bzl index d3a3034a79..4f6fa9ac37 100644 --- a/python/private/pypi/pypi_cache.bzl +++ b/python/private/pypi/pypi_cache.bzl @@ -13,7 +13,10 @@ load(":version_from_filename.bzl", "version_from_filename") # This value should be changed whenever the storage format changes. # Changing it simply means the information cached in the lockfile has to be # recomputed. -_FACT_VERSION = "v1" +# +# v2: the `dist_hashes` values and the `dist_yanked` keys are canonical +# `:` strings instead of bare sha256 hex digests. +_FACT_VERSION = "v2" def pypi_cache(mctx = None, store = None): """The cache for PyPI index queries. @@ -135,23 +138,23 @@ def _filter_packages(dists, requested_versions): } return result if result else None - sha256s_by_version = {} + hashes_by_version = {} whls = {} sdists = {} - for sha256, d in dists.sdists.items(): + for digest, d in dists.sdists.items(): if d.version not in requested_versions: continue - sdists[sha256] = d - sha256s_by_version.setdefault(d.version, []).append(sha256) + sdists[digest] = d + hashes_by_version.setdefault(d.version, []).append(digest) - for sha256, d in dists.whls.items(): + for digest, d in dists.whls.items(): if d.version not in requested_versions: continue - whls[sha256] = d - sha256s_by_version.setdefault(d.version, []).append(sha256) + whls[digest] = d + hashes_by_version.setdefault(d.version, []).append(digest) if not whls and not sdists: # TODO @aignas 2026-03-08: add logging @@ -161,9 +164,9 @@ def _filter_packages(dists, requested_versions): return struct( whls = whls, sdists = sdists, - sha256s_by_version = { + hashes_by_version = { k: sorted(v) - for k, v in sha256s_by_version.items() + for k, v in hashes_by_version.items() }, ) @@ -232,14 +235,14 @@ def _get_from_facts(facts, known_facts, index_url, requested_versions, facts_ver retrieved_versions = {} - for url, sha256 in known_facts.get("dist_hashes", {}).get(root_url, {}).get(distribution, {}).items(): - filename = known_facts.get("dist_filenames", {}).get(root_url, {}).get(distribution, {}).get(sha256) + for url, digest in known_facts.get("dist_hashes", {}).get(root_url, {}).get(distribution, {}).items(): + filename = known_facts.get("dist_filenames", {}).get(root_url, {}).get(distribution, {}).get(url) if not filename: _, _, filename = url.rpartition("/") version = version_from_filename(filename) if version not in requested_versions: - # TODO @aignas 2026-01-21: do the check by requested shas at some point + # TODO @aignas 2026-01-21: do the check by requested digests at some point # We don't have sufficient info in the lock file, need to call the API # continue @@ -251,16 +254,16 @@ def _get_from_facts(facts, known_facts, index_url, requested_versions, facts_ver else: dists = known_sources.setdefault("sdists", {}) - known_sources.setdefault("sha256s_by_version", {}).setdefault(version, []).append(sha256) + known_sources.setdefault("hashes_by_version", {}).setdefault(version, []).append(digest) - dists.setdefault(sha256, struct( - sha256 = sha256, + dists.setdefault(digest, struct( + digest = digest, filename = filename, version = version, metadata_url = "", metadata_sha256 = "", url = url, - yanked = known_facts.get("dist_yanked", {}).get(root_url, {}).get(distribution, {}).get(sha256), + yanked = known_facts.get("dist_yanked", {}).get(root_url, {}).get(distribution, {}).get(digest), )) if not known_sources: @@ -275,9 +278,9 @@ def _get_from_facts(facts, known_facts, index_url, requested_versions, facts_ver output = struct( whls = known_sources.get("whls", {}), sdists = known_sources.get("sdists", {}), - sha256s_by_version = { + hashes_by_version = { k: sorted(v) - for k, v in known_sources.get("sha256s_by_version", {}).items() + for k, v in known_sources.get("hashes_by_version", {}).items() }, ) @@ -318,7 +321,8 @@ def _store_facts(facts, fact_version, index_url, value): # "dist_hashes": { # "": { # "": { - # "": "", + # # An empty string if the index does not advertise any digest. + # "": ":", # }, # }, # }, @@ -332,16 +336,16 @@ def _store_facts(facts, fact_version, index_url, value): # "dist_yanked": { # "": { # "": { - # "": "", # if the package is yanked + # ":": "", # if the package is yanked # }, # }, # }, # }, - for sha256, d in (value.sdists | value.whls).items(): - facts.setdefault("dist_hashes", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(d.url, sha256) + for digest, d in (value.sdists | value.whls).items(): + facts.setdefault("dist_hashes", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(d.url, digest) if not d.url.endswith(d.filename): facts.setdefault("dist_filenames", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(d.url, d.filename) if d.yanked != None: - facts.setdefault("dist_yanked", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(sha256, d.yanked) + facts.setdefault("dist_yanked", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(digest, d.yanked) return value diff --git a/python/private/pypi/simpleapi_download.bzl b/python/private/pypi/simpleapi_download.bzl index 5377a08093..b5f7032b0a 100644 --- a/python/private/pypi/simpleapi_download.bzl +++ b/python/private/pypi/simpleapi_download.bzl @@ -34,7 +34,7 @@ def simpleapi_download( """Download Simple API HTML. First it queries all of the indexes for available packages and then it downloads the contents of - the per-package URLs and sha256 values. This is to enable us to use bazel_downloader with + the per-package URLs and hash digest values. This is to enable us to use bazel_downloader with `requirements.txt` files. As a side effect we also are able to "cross-compile" by fetching the right wheel for the right target platform through the information that we retrieve here. @@ -294,6 +294,6 @@ def _with_index_url(index_url, values): return struct( sdists = values.sdists, whls = values.whls, - sha256s_by_version = values.sha256s_by_version, + hashes_by_version = values.hashes_by_version, index_url = index_url, ) diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl index 53edd62efb..640a6518a6 100644 --- a/python/private/pypi/whl_library.bzl +++ b/python/private/pypi/whl_library.bzl @@ -419,10 +419,11 @@ def _whl_archive_impl(rctx): url = urls, output = filename, sha256 = rctx.attr.sha256, + integrity = rctx.attr.integrity if not rctx.attr.sha256 else "", auth = get_auth(rctx, urls), ) - if not rctx.attr.sha256: - # this is only seen when there is a direct URL reference without sha256 + if not rctx.attr.sha256 and not rctx.attr.integrity: + # this is only seen when there is a direct URL reference without a hash logger.warn("Please update the requirement line to include the hash:\n{} \\\n --hash=sha256:{}".format( rctx.attr.requirement, result.sha256, @@ -462,10 +463,11 @@ def _pip_archive_impl(rctx): url = urls, output = filename, sha256 = rctx.attr.sha256, + integrity = rctx.attr.integrity if not rctx.attr.sha256 else "", auth = get_auth(rctx, urls), ) - if not rctx.attr.sha256: - # this is only seen when there is a direct URL reference without sha256 + if not rctx.attr.sha256 and not rctx.attr.integrity: + # this is only seen when there is a direct URL reference without a hash logger.warn("Please update the requirement line to include the hash:\n{} \\\n --hash=sha256:{}".format( rctx.attr.requirement, result.sha256, @@ -579,6 +581,19 @@ For example if your whl depends on `numpy` and your Python package repo is named "index_url": attr.string( doc = "The index_url that the package will be downloaded from.", ), + "integrity": attr.string( + doc = """\ +The expected checksum of the downloaded whl in Subresource Integrity format +(e.g. `sha256-...` or `sha512-...`). Only used when `urls` is passed. If +`sha256` is also set, it takes precedence over this attribute. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + ), + "repo": attr.string( + doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.", + ), "repo_prefix": attr.string( doc = """ Prefix for the generated packages will be of the form `@//...` @@ -681,6 +696,7 @@ whl_archive = repository_rule( "group_deps", "group_name", "index_url", + "integrity", "repo_prefix", "requirement", "sha256", diff --git a/python/private/pypi/whl_repo_name.bzl b/python/private/pypi/whl_repo_name.bzl index 29d774c361..739ff2a4a1 100644 --- a/python/private/pypi/whl_repo_name.bzl +++ b/python/private/pypi/whl_repo_name.bzl @@ -18,25 +18,31 @@ load("//python/private:normalize_name.bzl", "normalize_name") load(":parse_whl_name.bzl", "parse_whl_name") -def whl_repo_name(filename, sha256, *target_platforms): +def whl_repo_name(filename, digest, *target_platforms): """Return a valid whl_library repo name given a distribution filename. Args: filename: {type}`str` the filename of the distribution. - sha256: {type}`str` the sha256 of the distribution. + digest: {type}`str` the digest of the distribution, either as a + canonical `:` string or a bare hex digest. *target_platforms: {type}`list[str]` the extra suffixes to append. Only used when we need to support different extras per version. Returns: a string that can be used in {obj}`whl_library`. """ + + # Strip the `:` prefix so that the name only contains the hex digest + # and stays the same as it has always been for bare sha256 values. + digest = digest.rpartition(":")[2] + parts = [] if not filename.endswith(".whl"): # Then the filename is basically foo-3.2.1. name, _, tail = filename.rpartition("-") parts.append(normalize_name(name)) - if sha256: + if digest: parts.append("sdist") version = "" else: @@ -56,8 +62,8 @@ def whl_repo_name(filename, sha256, *target_platforms): parts.append(abi_tag) parts.append(platform_tag) - if sha256: - parts.append(sha256[:8]) + if digest: + parts.append(digest[:8]) elif version: parts.insert(1, version) diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index 5fac194583..686ee0ee40 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -286,394 +286,394 @@ "dist_hashes": { "https://pypi.org/simple": { "backports-tarfile": { - "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz": "d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", - "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl": "77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34" + "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz": "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", + "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl": "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34" }, "certifi": { - "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl": "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", - "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz": "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" + "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl": "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", + "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz": "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" }, "cffi": { - "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl": "89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", - "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl": "8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", - "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl": "1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", - "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl": "f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", - "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", - "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl": "7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", - "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", - "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", - "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl": "df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", - "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl": "1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", - "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", - "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl": "7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", - "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", - "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl": "6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", - "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl": "2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", - "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl": "0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", - "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl": "7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", - "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", - "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl": "bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", - "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl": "961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", - "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", - "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", - "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", - "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl": "c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", - "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", - "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl": "19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", - "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl": "78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", - "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", - "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl": "6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", - "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl": "2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", - "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz": "efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", - "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl": "d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", - "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl": "a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", - "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl": "af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", - "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl": "8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", - "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", - "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl": "2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", - "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl": "8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", - "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", - "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", - "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl": "cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", - "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl": "164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", - "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", - "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl": "98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", - "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", - "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", - "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl": "9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", - "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl": "379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", - "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl": "702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", - "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl": "716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", - "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", - "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", - "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl": "fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", - "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", - "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl": "15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", - "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl": "df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", - "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl": "10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", - "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl": "37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", - "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl": "35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", - "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl": "cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", - "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", - "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl": "9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", - "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl": "47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", - "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl": "c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", - "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl": "bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", - "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl": "9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", - "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl": "dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", - "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl": "ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", - "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", - "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl": "0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", - "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl": "86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", - "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl": "a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", - "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl": "b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", - "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", - "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl": "db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", - "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", - "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", - "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl": "3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", - "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl": "f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", - "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl": "c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", - "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl": "02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", - "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl": "510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", - "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl": "c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", - "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl": "d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", - "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl": "7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", - "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl": "6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", - "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", - "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl": "cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", - "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", - "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl": "f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", - "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", - "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl": "64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", - "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", - "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", - "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl": "95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", - "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", - "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl": "4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", - "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", - "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl": "11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", - "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565" + "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl": "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", + "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl": "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", + "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl": "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", + "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl": "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", + "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", + "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl": "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", + "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", + "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", + "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl": "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", + "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl": "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", + "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", + "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl": "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", + "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", + "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", + "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", + "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl": "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", + "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl": "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", + "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", + "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl": "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", + "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl": "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", + "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", + "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", + "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", + "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl": "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", + "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl": "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", + "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", + "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl": "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", + "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", + "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl": "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", + "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl": "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", + "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz": "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", + "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", + "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl": "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", + "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl": "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", + "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl": "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", + "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", + "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl": "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", + "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl": "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", + "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", + "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", + "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl": "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", + "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl": "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", + "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", + "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl": "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", + "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", + "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", + "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl": "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", + "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl": "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", + "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl": "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", + "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl": "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", + "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", + "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", + "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl": "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", + "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", + "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl": "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", + "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl": "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", + "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl": "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", + "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl": "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", + "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl": "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", + "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl": "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", + "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", + "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl": "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", + "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", + "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl": "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", + "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl": "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", + "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl": "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", + "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl": "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", + "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl": "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", + "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", + "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl": "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", + "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl": "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", + "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl": "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", + "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl": "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", + "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", + "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl": "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", + "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", + "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", + "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl": "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", + "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl": "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", + "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl": "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", + "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl": "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", + "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl": "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", + "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl": "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", + "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl": "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", + "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl": "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", + "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl": "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", + "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", + "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl": "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", + "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", + "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl": "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", + "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", + "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl": "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", + "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", + "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", + "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl": "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", + "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", + "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl": "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", + "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", + "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl": "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", + "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565" }, "charset-normalizer": { - "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl": "609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", - "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", - "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl": "375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", - "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl": "0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", - "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", - "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl": "65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", - "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl": "78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", - "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", - "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", - "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl": "93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", - "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl": "f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", - "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl": "e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", - "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl": "40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", - "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl": "476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", - "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", - "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl": "60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", - "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl": "bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", - "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl": "8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", - "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", - "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl": "19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", - "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", - "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", - "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl": "a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", - "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl": "c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", - "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl": "416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", - "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", - "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl": "16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", - "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", - "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", - "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl": "1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", - "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", - "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl": "f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", - "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", - "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl": "ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", - "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl": "79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", - "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", - "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl": "69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", - "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", - "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl": "611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", - "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl": "45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", - "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl": "cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", - "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl": "0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", - "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", - "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", - "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl": "9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", - "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl": "4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", - "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl": "ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", - "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl": "03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", - "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl": "75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", - "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl": "90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", - "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", - "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", - "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl": "68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", - "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl": "c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", - "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl": "a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", - "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl": "ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", - "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", - "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl": "3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", - "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl": "253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", - "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl": "cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", - "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl": "280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", - "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl": "5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", - "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl": "0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", - "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl": "440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", - "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", - "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz": "673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", - "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", - "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl": "432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", - "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", - "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", - "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", - "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", - "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl": "51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", - "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", - "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", - "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl": "67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", - "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", - "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl": "83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", - "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", - "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl": "fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", - "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", - "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl": "43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", - "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", - "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl": "231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", - "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl": "0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", - "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl": "6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", - "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", - "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl": "78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", - "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", - "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl": "d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", - "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", - "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl": "898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", - "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl": "ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" + "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl": "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", + "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", + "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl": "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", + "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl": "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", + "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", + "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl": "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", + "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl": "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", + "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", + "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", + "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl": "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", + "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl": "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", + "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl": "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", + "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl": "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", + "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl": "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", + "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", + "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl": "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", + "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl": "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", + "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl": "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", + "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", + "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl": "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", + "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", + "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", + "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl": "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", + "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl": "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", + "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl": "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", + "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", + "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl": "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", + "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", + "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", + "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl": "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", + "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", + "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl": "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", + "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", + "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl": "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", + "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl": "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", + "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", + "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl": "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", + "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", + "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl": "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", + "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl": "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", + "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl": "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", + "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl": "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", + "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", + "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", + "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl": "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", + "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl": "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", + "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl": "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", + "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl": "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", + "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl": "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", + "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl": "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", + "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", + "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", + "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl": "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", + "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl": "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", + "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl": "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", + "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl": "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", + "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", + "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl": "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", + "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl": "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", + "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl": "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", + "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl": "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", + "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl": "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", + "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl": "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", + "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl": "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", + "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", + "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz": "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", + "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", + "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl": "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", + "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", + "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", + "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", + "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", + "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl": "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", + "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", + "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", + "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl": "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", + "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", + "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl": "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", + "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", + "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl": "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", + "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", + "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl": "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", + "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", + "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl": "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", + "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl": "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", + "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl": "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", + "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl": "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", + "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl": "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", + "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", + "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl": "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", + "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", + "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl": "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", + "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl": "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" }, "cryptography": { - "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", - "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl": "d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", - "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl": "07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", - "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl": "ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", - "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl": "e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", - "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz": "f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", - "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl": "2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", - "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl": "9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", - "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", - "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", - "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl": "f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", - "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl": "53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", - "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl": "33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", - "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl": "b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", - "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl": "c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", - "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl": "2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", - "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl": "fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", - "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl": "6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", - "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", - "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl": "8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", - "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl": "67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", - "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl": "ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", - "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl": "66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", - "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", - "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl": "42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", - "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl": "b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", - "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl": "966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", - "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl": "2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", - "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl": "0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", - "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl": "0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", - "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl": "b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", - "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl": "4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", - "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl": "cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", - "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl": "be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", - "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl": "084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", - "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl": "6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", - "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", - "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl": "026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", - "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl": "d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", - "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl": "c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", - "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl": "b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", - "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", - "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", - "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl": "b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", - "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl": "73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", - "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl": "28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc" + "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", + "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl": "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", + "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl": "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", + "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl": "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", + "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl": "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", + "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz": "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", + "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl": "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", + "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl": "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", + "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", + "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", + "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl": "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", + "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl": "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", + "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl": "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", + "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl": "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", + "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl": "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", + "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl": "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", + "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl": "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", + "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl": "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", + "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", + "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl": "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", + "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl": "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", + "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl": "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", + "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl": "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", + "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", + "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl": "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", + "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl": "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", + "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl": "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", + "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl": "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", + "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl": "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", + "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl": "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", + "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl": "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", + "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl": "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", + "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl": "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", + "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl": "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", + "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl": "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", + "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl": "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", + "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", + "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl": "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", + "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl": "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", + "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl": "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", + "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl": "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", + "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", + "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", + "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl": "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", + "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl": "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", + "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl": "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc" }, "docutils": { - "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl": "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", - "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz": "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" + "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl": "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", + "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz": "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" }, "id": { - "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl": "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", - "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz": "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" + "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl": "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", + "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz": "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" }, "idna": { - "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl": "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", - "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz": "ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" + "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl": "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", + "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz": "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" }, "importlib-metadata": { - "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl": "2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", - "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz": "a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc" + "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl": "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", + "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz": "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc" }, "jaraco-classes": { - "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz": "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", - "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl": "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz": "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl": "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" }, "jaraco-context": { - "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz": "f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", - "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl": "bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" + "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz": "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", + "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl": "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" }, "jaraco-functools": { - "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl": "99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", - "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz": "880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" + "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl": "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", + "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz": "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" }, "jeepney": { - "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz": "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", - "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl": "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" + "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz": "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", + "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl": "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" }, "keyring": { - "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz": "fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", - "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl": "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" + "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz": "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", + "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl": "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" }, "markdown-it-py": { - "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz": "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", - "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl": "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" + "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz": "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", + "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl": "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, "mdurl": { - "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", - "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz": "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl": "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz": "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" }, "more-itertools": { - "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz": "48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", - "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl": "4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192" + "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz": "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", + "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl": "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192" }, "nh3": { - "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl": "edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", - "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl": "455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", - "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", - "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl": "f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", - "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl": "36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", - "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl": "082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", - "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl": "44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", - "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl": "2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", - "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl": "4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", - "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl": "25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", - "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", - "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl": "889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", - "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl": "f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", - "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl": "34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", - "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", - "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz": "f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", - "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl": "69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", - "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl": "43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", - "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl": "82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", - "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl": "69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", - "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl": "f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", - "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", - "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", - "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", - "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl": "5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", - "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl": "597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", - "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25" + "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl": "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", + "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl": "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", + "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", + "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl": "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", + "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl": "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", + "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl": "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", + "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl": "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", + "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl": "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", + "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl": "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", + "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl": "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", + "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", + "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl": "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", + "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl": "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", + "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl": "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", + "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", + "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz": "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", + "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl": "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", + "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl": "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", + "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl": "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", + "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl": "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", + "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl": "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", + "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", + "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", + "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", + "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl": "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", + "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl": "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", + "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25" }, "packaging": { - "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz": "ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", - "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl": "5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" + "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz": "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", + "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl": "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, "pycparser": { - "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", - "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz": "600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" + "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl": "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", + "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz": "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" }, "pygments": { - "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz": "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", - "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl": "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" + "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz": "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl": "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, "pywin32-ctypes": { - "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz": "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", - "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl": "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" + "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz": "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", + "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl": "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" }, "readme-renderer": { - "https://files.pythonhosted.org/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz": "030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", - "https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl": "3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f" + "https://files.pythonhosted.org/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz": "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", + "https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl": "sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f" }, "requests": { - "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl": "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", - "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz": "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" + "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl": "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", + "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz": "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" }, "requests-toolbelt": { - "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl": "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", - "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz": "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" + "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl": "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", + "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz": "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" }, "rfc3986": { - "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz": "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", - "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl": "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" + "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz": "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", + "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl": "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" }, "rich": { - "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl": "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", - "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz": "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" + "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl": "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", + "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz": "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" }, "secretstorage": { - "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz": "f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", - "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl": "0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" + "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz": "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", + "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl": "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" }, "six": { - "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz": "ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", - "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl": "4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" + "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz": "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", + "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl": "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, "twine": { - "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz": "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", - "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl": "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" + "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz": "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", + "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl": "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" }, "urllib3": { - "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz": "231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", - "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" + "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz": "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", + "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl": "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, "zipp": { - "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl": "25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", - "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz": "4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602" + "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl": "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", + "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz": "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602" } } }, - "fact_version": "v1" + "fact_version": "v2" } } } diff --git a/tests/pypi/hash/BUILD.bazel b/tests/pypi/hash/BUILD.bazel new file mode 100644 index 0000000000..1e052af141 --- /dev/null +++ b/tests/pypi/hash/BUILD.bazel @@ -0,0 +1,3 @@ +load(":hash_tests.bzl", "hash_test_suite") + +hash_test_suite(name = "hash_tests") diff --git a/tests/pypi/hash/hash_tests.bzl b/tests/pypi/hash/hash_tests.bzl new file mode 100644 index 0000000000..6866a79d07 --- /dev/null +++ b/tests/pypi/hash/hash_tests.bzl @@ -0,0 +1,102 @@ +# Copyright 2026 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. + +"" + +load("@rules_testing//lib:test_suite.bzl", "test_suite") +load("//python/private/pypi:hash.bzl", "hash") # buildifier: disable=bzl-visibility + +_tests = [] + +# The digests of b"rules_python" with the SRI values calculated with: +# python3 -c 'import base64, hashlib; h = hashlib.sha512(b"rules_python").digest(); print(base64.b64encode(h))' +_SHA256 = "a90bb21ddf552d508565aa2f0d78ac13297e6c407e39eb578eeac09e62c5da3a" +_SHA256_SRI = "sha256-qQuyHd9VLVCFZaovDXisEyl+bEB+OetXjurAnmLF2jo=" +_SHA384 = "7407ad30f83623cabf9139bd80edb332516880321ab2a20fa0f281767675bfc10b53555eb5042aa58de4b9345f5f70b6" +_SHA384_SRI = "sha384-dAetMPg2I8q/kTm9gO2zMlFogDIasqIPoPKBdnZ1v8ELU1VetQQqpY3kuTRfX3C2" +_SHA512 = "127af31fba52ce5ac96a4bc0f1b31c31c12367feace2b7dbb81066df479883add9dd6166495b126b76ac8c624d254b6a4ba3fbb628e8dcf0a34e1b0b065f619f" +_SHA512_SRI = "sha512-EnrzH7pSzlrJakvA8bMcMcEjZ/6s4rfbuBBm30eYg63Z3WFmSVsSa3asjGJNJUtqS6P7tijo3PCjThsLBl9hnw==" + +def _test_digest(env): + env.expect.that_str(hash.digest("sha256", _SHA256)).equals("sha256:" + _SHA256) + + # The algorithm name is normalized to lower case. + env.expect.that_str(hash.digest("SHA256", _SHA256)).equals("sha256:" + _SHA256) + + # Unknown algorithms and empty digests yield nothing. + env.expect.that_str(hash.digest("egg", "foo")).equals("") + env.expect.that_str(hash.digest("sha256", "")).equals("") + env.expect.that_str(hash.digest("", "")).equals("") + +_tests.append(_test_digest) + +def _test_hex_to_sri(env): + env.expect.that_str(hash.hex_to_sri("sha256", _SHA256)).equals(_SHA256_SRI) + env.expect.that_str(hash.hex_to_sri("sha384", _SHA384)).equals(_SHA384_SRI) + env.expect.that_str(hash.hex_to_sri("sha512", _SHA512)).equals(_SHA512_SRI) + + # Upper case digests are accepted. + env.expect.that_str(hash.hex_to_sri("sha512", _SHA512.upper())).equals(_SHA512_SRI) + + # Algorithms that SRI does not support yield nothing. + env.expect.that_str(hash.hex_to_sri("md5", "0" * 32)).equals("") + env.expect.that_str(hash.hex_to_sri("blake2b", "0" * 128)).equals("") + + # Invalid digests yield nothing. + env.expect.that_str(hash.hex_to_sri("sha256", "")).equals("") + env.expect.that_str(hash.hex_to_sri("sha256", "abc")).equals("") + env.expect.that_str(hash.hex_to_sri("sha256", "not-hex!")).equals("") + +_tests.append(_test_hex_to_sri) + +def _test_integrity(env): + env.expect.that_str(hash.integrity("")).equals("") + env.expect.that_str(hash.integrity("md5:" + "0" * 32)).equals("") + env.expect.that_str(hash.integrity("sha256:" + _SHA256)).equals(_SHA256_SRI) + env.expect.that_str(hash.integrity("sha384:" + _SHA384)).equals(_SHA384_SRI) + env.expect.that_str(hash.integrity("sha512:" + _SHA512)).equals(_SHA512_SRI) + +_tests.append(_test_integrity) + +def _test_preferred_digest(env): + env.expect.that_str(hash.preferred_digest([])).equals("") + env.expect.that_str(hash.preferred_digest([""])).equals("") + + # sha256 stays preferred so that the repo names remain stable. + env.expect.that_str(hash.preferred_digest([ + "sha256:" + _SHA256, + "sha512:" + _SHA512, + ])).equals("sha256:" + _SHA256) + + # Otherwise the strongest SRI supported algorithm wins. + env.expect.that_str(hash.preferred_digest([ + "sha384:" + _SHA384, + "sha512:" + _SHA512, + ])).equals("sha512:" + _SHA512) + + # And anything else is picked alphabetically for determinism. + env.expect.that_str(hash.preferred_digest([ + "md5:deadb00f", + "blake2b:deadbeef", + ])).equals("blake2b:deadbeef") + +_tests.append(_test_preferred_digest) + +def hash_test_suite(name): + """Create the test suite. + + Args: + name: the name of the test suite + """ + test_suite(name = name, basic_tests = _tests) diff --git a/tests/pypi/hub_builder/hub_builder_tests.bzl b/tests/pypi/hub_builder/hub_builder_tests.bzl index 4651342dd4..f421c7e351 100644 --- a/tests/pypi/hub_builder/hub_builder_tests.bzl +++ b/tests/pypi/hub_builder/hub_builder_tests.bzl @@ -340,8 +340,8 @@ def _test_simple_extras_vs_no_extras_simpleapi(env): "dep_template": "@pypi//{name}:{target}", "filename": "simple-0.0.1-py3-none-any.whl", "index_url": "https://example.com/simple/", + "integrity": "sha256-3q2+7w==", "requirement": "simple[foo]==0.0.1", - "sha256": "deadbeef", "urls": ["/simple-0.0.1-py3-none-any.whl"], }, "pypi_315_simple_py3_none_any_deadbeef_windows_aarch64": { @@ -349,8 +349,8 @@ def _test_simple_extras_vs_no_extras_simpleapi(env): "dep_template": "@pypi//{name}:{target}", "filename": "simple-0.0.1-py3-none-any.whl", "index_url": "https://example.com/simple/", + "integrity": "sha256-3q2+7w==", "requirement": "simple==0.0.1", - "sha256": "deadbeef", "urls": ["/simple-0.0.1-py3-none-any.whl"], }, }) @@ -358,6 +358,77 @@ def _test_simple_extras_vs_no_extras_simpleapi(env): _tests.append(_test_simple_extras_vs_no_extras_simpleapi) +def _test_simple_sha512_simpleapi(env): + """Non-sha256 pins match the index digests and are downloaded with `integrity`.""" + + def mockread_simpleapi(*_, parse_index, **__): + if parse_index: + content = """\ + simple-0.0.1-py3-none-any.whl
+""" + return struct( + output = parse_simpleapi_html( + content = content, + parse_index = parse_index, + ), + success = True, + ) + + builder = hub_builder( + env, + simpleapi_download_fn = lambda *args, **kwargs: simpleapi_download( + read_simpleapi = mockread_simpleapi, + *args, + **kwargs + ), + ) + builder.pip_parse( + mocks.mctx( + mock_files = { + "win.txt": "simple==0.0.1 --hash=sha512:deadbeef", + }, + ), + _parse( + hub_name = "pypi", + python_version = "3.15", + requirements_windows = "win.txt", + experimental_index_url = "https://example.com", + target_platforms = ["windows_aarch64"], + ), + ) + pypi = builder.build() + + pypi.exposed_packages().contains_exactly(["simple"]) + pypi.whl_map().contains_exactly({ + "simple": { + "pypi_315_simple_py3_none_any_deadbeef": [ + whl_config_setting( + target_platforms = [ + "cp315_windows_aarch64", + ], + version = "3.15", + ), + ], + }, + }) + pypi.whl_libraries().contains_exactly({ + "pypi_315_simple_py3_none_any_deadbeef": { + "config_load": "@pypi//:config.bzl", + "dep_template": "@pypi//{name}:{target}", + "filename": "simple-0.0.1-py3-none-any.whl", + "index_url": "https://example.com/simple/", + "integrity": "sha512-3q2+7w==", + "requirement": "simple==0.0.1", + "urls": ["/simple-0.0.1-py3-none-any.whl"], + }, + }) + +_tests.append(_test_simple_sha512_simpleapi) + def _test_simple_multiple_python_versions(env): builder = hub_builder( env, @@ -672,8 +743,8 @@ torch==2.4.1+cpu ; platform_machine == 'x86_64' \ "dep_template": "@pypi//{name}:{target}", "filename": "torch-2.4.1+cpu-cp312-cp312-linux_x86_64.whl", "index_url": "https://torch.index/torch/", + "integrity": "sha256-iADe7wAmAR1QLAwlbMS2fQAjR/Y8OjjNjkXx9EXGE2Q=", "requirement": "torch==2.4.1+cpu", - "sha256": "8800deef0026011d502c0c256cc4b67d002347f63c3a38cd8e45f1f445c61364", "urls": ["/whl/cpu/torch-2.4.1%2Bcpu-cp312-cp312-linux_x86_64.whl"], }, "pypi_312_torch_cp312_cp312_manylinux_2_17_aarch64_36109432_linux_aarch64": { @@ -681,8 +752,8 @@ torch==2.4.1+cpu ; platform_machine == 'x86_64' \ "dep_template": "@pypi//{name}:{target}", "filename": "torch-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", "index_url": "https://torch.index/torch/", + "integrity": "sha256-NhCUMrEL1xY8mzDOiW88LMobhrl2X5VqFZTw/0MJHio=", "requirement": "torch==2.4.1", - "sha256": "36109432b10bd7163c9b30ce896f3c2cca1b86b9765f956a1594f0ff43091e2a", "urls": ["/whl/cpu/torch-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"], }, "pypi_312_torch_cp312_cp312_win_amd64_3a570e5c_windows_x86_64": { @@ -690,8 +761,8 @@ torch==2.4.1+cpu ; platform_machine == 'x86_64' \ "dep_template": "@pypi//{name}:{target}", "filename": "torch-2.4.1+cpu-cp312-cp312-win_amd64.whl", "index_url": "https://torch.index/torch/", + "integrity": "sha256-OlcOXFU0Fc293+Z5IHMns6OAayHGreoU+6d2hNFhnpc=", "requirement": "torch==2.4.1+cpu", - "sha256": "3a570e5c553415cdbddfe679207327b3a3806b21c6adea14fba77684d1619e97", "urls": ["/whl/cpu/torch-2.4.1%2Bcpu-cp312-cp312-win_amd64.whl"], }, "pypi_312_torch_cp312_none_macosx_11_0_arm64_72b484d5_osx_aarch64": { @@ -699,8 +770,8 @@ torch==2.4.1+cpu ; platform_machine == 'x86_64' \ "dep_template": "@pypi//{name}:{target}", "filename": "torch-2.4.1-cp312-none-macosx_11_0_arm64.whl", "index_url": "https://torch.index/torch/", + "integrity": "sha256-crSE1bbOwac1vz+locSIPQF0hpjF6c/b60/6t8eYfg0=", "requirement": "torch==2.4.1", - "sha256": "72b484d5b6cec1a735bf3fa5a1c4883d01748698c5e9cfdbeb4ffab7c7987e0d", "urls": ["/whl/cpu/torch-2.4.1-cp312-none-macosx_11_0_arm64.whl"], }, }) @@ -787,15 +858,15 @@ simple==0.0.1 --hash=sha256:deadb00f return { "simple": struct( whls = { - "deadb00f": struct( + "sha256:deadb00f": struct( yanked = None, filename = "simple-0.0.1-py3-none-any.whl", - sha256 = "deadb00f", + digest = "sha256:deadb00f", url = test.expect_url, ), }, sdists = {}, - sha256s_by_version = {}, + hashes_by_version = {}, index_url = test.expect_index_url, ), } @@ -842,8 +913,8 @@ simple==0.0.1 --hash=sha256:deadb00f "dep_template": "@pypi//{name}:{target}", "filename": "simple-0.0.1-py3-none-any.whl", "index_url": test.expect_index_url, + "integrity": "sha256-3q2wDw==", "requirement": "simple==0.0.1", - "sha256": "deadb00f", "urls": [test.expect_url], } if getattr(test, "envsubst", []): @@ -977,33 +1048,33 @@ def _test_simple_get_index(env): return { "plat_pkg": struct( whls = { - "deadb44f": struct( + "sha256:deadb44f": struct( yanked = None, filename = "plat-pkg-0.0.4-py3-none-linux_x86_64.whl", - sha256 = "deadb44f", + digest = "sha256:deadb44f", url = "example2.org/index/plat_pkg/", ), }, sdists = {}, - sha256s_by_version = { - "0.0.4": ["deadb44f"], + hashes_by_version = { + "0.0.4": ["sha256:deadb44f"], }, index_url = "https://pypi.org/simple", ), "simple": struct( whls = { - "deadb00f": struct( + "sha256:deadb00f": struct( yanked = None, filename = "simple-0.0.1-py3-none-any.whl", - sha256 = "deadb00f", + digest = "sha256:deadb00f", url = "example2.org", ), }, sdists = { - "deadbeef": struct( + "sha256:deadbeef": struct( yanked = None, filename = "simple-0.0.1.tar.gz", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "example.org", ), }, @@ -1011,17 +1082,17 @@ def _test_simple_get_index(env): ), "some_other_pkg": struct( whls = { - "deadb33f": struct( + "sha256:deadb33f": struct( yanked = None, filename = "some-other-pkg-0.0.1-py3-none-any.whl", - sha256 = "deadb33f", + digest = "sha256:deadb33f", url = "example2.org/index/some_other_pkg/", ), }, sdists = {}, - sha256s_by_version = { - "0.0.1": ["deadb33f"], - "0.0.3": ["deadbeef"], + hashes_by_version = { + "0.0.1": ["sha256:deadb33f"], + "0.0.3": ["sha256:deadbeef"], }, index_url = "https://with_index_url", ), @@ -1186,17 +1257,17 @@ git_dep @ git+https://git.server/repo/project@deadbeefdeadbeef "dep_template": "@pypi//{name}:{target}", "extra_pip_args": ["--extra-args-for-sdist-building"], "filename": "any-name.tar.gz", + "integrity": "", "python_interpreter_target": "unit_test_interpreter_target", "requirement": "direct_sdist_without_sha @ some-archive/any-name.tar.gz", - "sha256": "", "urls": ["some-archive/any-name.tar.gz"], }, "pypi_315_direct_without_sha_0_0_1_py3_none_any": { "config_load": "@pypi//:config.bzl", "dep_template": "@pypi//{name}:{target}", "filename": "direct_without_sha-0.0.1-py3-none-any.whl", + "integrity": "", "requirement": "direct_without_sha==0.0.1", - "sha256": "", "urls": ["example-direct.org/direct_without_sha-0.0.1-py3-none-any.whl"], "whl_patches": {"my_patch": "1"}, }, @@ -1219,8 +1290,8 @@ git_dep @ git+https://git.server/repo/project@deadbeefdeadbeef "dep_template": "@pypi//{name}:{target}", "filename": "plat-pkg-0.0.4-py3-none-linux_x86_64.whl", "index_url": "https://pypi.org/simple", + "integrity": "sha256-3q20Tw==", "requirement": "plat_pkg==0.0.4", - "sha256": "deadb44f", "urls": ["example2.org/index/plat_pkg/"], }, "pypi_315_simple_py3_none_any_deadb00f": { @@ -1228,16 +1299,16 @@ git_dep @ git+https://git.server/repo/project@deadbeefdeadbeef "dep_template": "@pypi//{name}:{target}", "filename": "simple-0.0.1-py3-none-any.whl", "index_url": "https://pypi.org/simple", + "integrity": "sha256-3q2wDw==", "requirement": "simple==0.0.1", - "sha256": "deadb00f", "urls": ["example2.org"], }, "pypi_315_some_pkg_py3_none_any_deadbaaf": { "config_load": "@pypi//:config.bzl", "dep_template": "@pypi//{name}:{target}", "filename": "some_pkg-0.0.1-py3-none-any.whl", + "integrity": "sha256-3q26rw==", "requirement": "some_pkg==0.0.1", - "sha256": "deadbaaf", "urls": ["example-direct.org/some_pkg-0.0.1-py3-none-any.whl"], }, "pypi_315_some_py3_none_any_deadb33f": { @@ -1245,8 +1316,8 @@ git_dep @ git+https://git.server/repo/project@deadbeefdeadbeef "dep_template": "@pypi//{name}:{target}", "filename": "some-other-pkg-0.0.1-py3-none-any.whl", "index_url": "https://with_index_url", + "integrity": "sha256-3q2zPw==", "requirement": "some_other_pkg==0.0.1", - "sha256": "deadb33f", "urls": ["example2.org/index/some_other_pkg/"], }, }) diff --git a/tests/pypi/index_sources/index_sources_tests.bzl b/tests/pypi/index_sources/index_sources_tests.bzl index 7aa22d164a..3a605b9eb9 100644 --- a/tests/pypi/index_sources/index_sources_tests.bzl +++ b/tests/pypi/index_sources/index_sources_tests.bzl @@ -26,7 +26,7 @@ def _test_no_simple_api_sources(env): requirement_line = "foo @ git+https://github.com/org/foo.git@deadbeef", marker = "", url = "git+https://github.com/org/foo.git@deadbeef", - shas = [], + hashes = [], version = "", filename = "", ), @@ -59,7 +59,7 @@ def _test_no_simple_api_sources(env): requirement_line = "foo==0.0.1 @ https://someurl.org/package.whl --hash=sha256:deadbeef", marker = "", url = "https://someurl.org/package.whl", - shas = ["deadbeef"], + hashes = ["sha256:deadbeef"], version = "0.0.1", filename = "package.whl", ), @@ -68,7 +68,7 @@ def _test_no_simple_api_sources(env): requirement_line = "foo==0.0.1 @ https://someurl.org/package.whl --hash=sha256:deadbeef", marker = "python_version < \"2.7\"", url = "https://someurl.org/package.whl", - shas = ["deadbeef"], + hashes = ["sha256:deadbeef"], version = "0.0.1", filename = "package.whl", ), @@ -80,7 +80,7 @@ def _test_no_simple_api_sources(env): requirement_line = "foo[extra] @ https://example.org/foo-1.0.tar.gz --hash=sha256:deadbe0f", marker = "", url = "https://example.org/foo-1.0.tar.gz", - shas = ["deadbe0f"], + hashes = ["sha256:deadbe0f"], version = "", filename = "foo-1.0.tar.gz", ), @@ -89,14 +89,14 @@ def _test_no_simple_api_sources(env): requirement_line = "torch @ https://download.pytorch.org/whl/cpu/torch-2.6.0%2Bcpu-cp311-cp311-linux_x86_64.whl#sha256=deadbeef", marker = "", url = "https://download.pytorch.org/whl/cpu/torch-2.6.0%2Bcpu-cp311-cp311-linux_x86_64.whl", - shas = ["deadbeef"], + hashes = ["sha256:deadbeef"], version = "", filename = "torch-2.6.0+cpu-cp311-cp311-linux_x86_64.whl", ), } for input, want in inputs.items(): got = index_sources(input) - env.expect.that_collection(got.shas).contains_exactly(want.shas if hasattr(want, "shas") else []) + env.expect.that_collection(got.hashes).contains_exactly(want.hashes if hasattr(want, "hashes") else []) env.expect.that_str(got.version).equals(want.version) env.expect.that_str(got.requirement).equals(want.requirement) env.expect.that_str(got.requirement_line).equals(got.requirement_line) @@ -109,9 +109,9 @@ _tests.append(_test_no_simple_api_sources) def _test_simple_api_sources(env): tests = { "foo==0.0.2 --hash=sha256:deafbeef --hash=sha256:deadbeef": struct( - shas = [ - "deadbeef", - "deafbeef", + hashes = [ + "sha256:deadbeef", + "sha256:deafbeef", ], marker = "", requirement = "foo==0.0.2", @@ -119,9 +119,9 @@ def _test_simple_api_sources(env): url = "", ), "foo[extra]==0.0.2; (python_version < 2.7 or extra == \"@\") --hash=sha256:deafbeef --hash=sha256:deadbeef": struct( - shas = [ - "deadbeef", - "deafbeef", + hashes = [ + "sha256:deadbeef", + "sha256:deafbeef", ], marker = "(python_version < 2.7 or extra == \"@\")", requirement = "foo[extra]==0.0.2", @@ -131,7 +131,7 @@ def _test_simple_api_sources(env): } for input, want in tests.items(): got = index_sources(input) - env.expect.that_collection(got.shas).contains_exactly(want.shas) + env.expect.that_collection(got.hashes).contains_exactly(want.hashes) env.expect.that_str(got.version).equals("0.0.2") env.expect.that_str(got.requirement).equals(want.requirement) env.expect.that_str(got.requirement_line).equals(want.requirement_line) @@ -140,6 +140,62 @@ def _test_simple_api_sources(env): _tests.append(_test_simple_api_sources) +def _test_non_sha256_hashes(env): + tests = { + # A `#sha512=` URL fragment, as any hash algorithm is allowed by PEP 503. + "foo @ https://example.org/foo-0.0.1-py3-none-any.whl#sha512=deadbeef": struct( + hashes = ["sha512:deadbeef"], + marker = "", + requirement = "foo", + requirement_line = "foo @ https://example.org/foo-0.0.1-py3-none-any.whl#sha512=deadbeef", + url = "https://example.org/foo-0.0.1-py3-none-any.whl", + version = "", + filename = "foo-0.0.1-py3-none-any.whl", + ), + # Unknown algorithms are dropped. + "foo==0.0.2 --hash=egg:deadbeef": struct( + hashes = [], + marker = "", + requirement = "foo==0.0.2", + requirement_line = "foo==0.0.2", + url = "", + version = "0.0.2", + filename = "", + ), + "foo==0.0.2 --hash=sha512:deadbeef": struct( + hashes = ["sha512:deadbeef"], + marker = "", + requirement = "foo==0.0.2", + requirement_line = "foo==0.0.2 --hash=sha512:deadbeef", + url = "", + version = "0.0.2", + filename = "", + ), + "foo==0.0.2 --hash=sha512:deafbeef --hash=sha256:deadbeef": struct( + hashes = [ + "sha256:deadbeef", + "sha512:deafbeef", + ], + marker = "", + requirement = "foo==0.0.2", + requirement_line = "foo==0.0.2 --hash=sha512:deafbeef --hash=sha256:deadbeef", + url = "", + version = "0.0.2", + filename = "", + ), + } + for input, want in tests.items(): + got = index_sources(input) + env.expect.that_collection(got.hashes).contains_exactly(want.hashes) + env.expect.that_str(got.version).equals(want.version) + env.expect.that_str(got.requirement).equals(want.requirement) + env.expect.that_str(got.requirement_line).equals(want.requirement_line) + env.expect.that_str(got.marker).equals(want.marker) + env.expect.that_str(got.url).equals(want.url) + env.expect.that_str(got.filename).equals(want.filename) + +_tests.append(_test_non_sha256_hashes) + def index_sources_test_suite(name): """Create the test suite. diff --git a/tests/pypi/parse_requirements/parse_requirements_tests.bzl b/tests/pypi/parse_requirements/parse_requirements_tests.bzl index 57b3ea5d3e..54f906929a 100644 --- a/tests/pypi/parse_requirements/parse_requirements_tests.bzl +++ b/tests/pypi/parse_requirements/parse_requirements_tests.bzl @@ -92,6 +92,9 @@ foo==0.0.3 --hash=sha256:deadbaaf --hash=sha256:deadb11f --hash=sha256:5d15t --abi=cp39 foo==0.0.3 --hash=sha256:deadbaaf +""", + "requirements_sha512": """\ +foo==0.0.1 --hash=sha512:deadbeef """, "requirements_windows": """\ foo[extra]==0.0.2 --hash=sha256:deadbeef @@ -108,6 +111,7 @@ bar==0.0.1 --hash=sha256:deadb00f "uv_lock_foo_requires_dist_extras": """{"package":[{"name":"foo","version":"0.0.1","source":{"registry":"https://pypi.org/simple"},"wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"root-pkg","source":{"virtual":"."},"version":"0.0.0","dependencies":[{"name":"foo"}],"metadata":{"requires-dist":[{"name":"foo","extras":["all"]}]}}]}""", "uv_lock_foo_resolution_markers_dedup": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","resolution-markers":["sys_platform == 'linux'"],"wheels":[{"hash":"sha256:aaa","url":"https://files.pythonhosted.org/packages/foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl"},{"hash":"sha256:bbb","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.2","resolution-markers":["sys_platform == 'darwin'"],"wheels":[{"hash":"sha256:ccc","url":"https://files.pythonhosted.org/packages/foo-0.0.2-cp39-cp39-macosx_11_0_arm64.whl"},{"hash":"sha256:ddd","url":"https://files.pythonhosted.org/packages/foo-0.0.2-py3-none-any.whl"}]}]}""", "uv_lock_foo_sdist": """{"package":[{"name":"foo","sdist":{"hash":"sha256:feedcafe","url":"https://files.pythonhosted.org/packages/foo-0.0.1.tar.gz"},"source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", + "uv_lock_foo_sha512": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha512:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", "uv_lock_foo_virtual": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"virtual-pkg","source":{"virtual":true},"version":"0.0.0"}]}""", "uv_lock_foo_with_extras": """{"package":[{"name":"foo","provides-extras":["extra"],"source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", "uv_lock_git_vcs": """{"package":[{"name":"foo","source":{"git":"https://github.com/org/foo.git"},"version":"0.1.0"}]}""", @@ -192,7 +196,7 @@ def _test_simple(env): ], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -221,7 +225,7 @@ def _test_direct_urls_integration(env): extra_pip_args = [], filename = "foo-1.1.tar.gz", requirement_line = "foo @ https://github.com/org/foo/downloads/foo-1.1.tar.gz", - sha256 = "", + digest = "", target_platforms = ["osx_x86_64"], url = "https://github.com/org/foo/downloads/foo-1.1.tar.gz", yanked = None, @@ -231,7 +235,7 @@ def _test_direct_urls_integration(env): extra_pip_args = [], filename = "package.whl", requirement_line = "foo[extra]", - sha256 = "", + digest = "", target_platforms = ["linux_x86_64"], url = "https://some-url/package.whl", yanked = None, @@ -263,7 +267,7 @@ def _test_direct_urls_no_extract(env): extra_pip_args = [], filename = "", requirement_line = "foo @ https://github.com/org/foo/downloads/foo-1.1.tar.gz", - sha256 = "", + digest = "", target_platforms = ["osx_x86_64"], url = "", yanked = None, @@ -273,7 +277,7 @@ def _test_direct_urls_no_extract(env): extra_pip_args = [], filename = "", requirement_line = "foo[extra] @ https://some-url/package.whl", - sha256 = "", + digest = "", target_platforms = ["linux_x86_64"], url = "", yanked = None, @@ -308,7 +312,7 @@ def _test_extra_pip_args(env): ], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -338,7 +342,7 @@ def _test_dupe_requirements(env): target_platforms = ["linux_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -370,7 +374,7 @@ def _test_multi_os(env): target_platforms = ["windows_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -388,7 +392,7 @@ def _test_multi_os(env): target_platforms = ["linux_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), struct( @@ -398,7 +402,7 @@ def _test_multi_os(env): target_platforms = ["windows_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -436,7 +440,7 @@ def _test_multi_os_legacy(env): target_platforms = ["cp39_linux_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -454,7 +458,7 @@ def _test_multi_os_legacy(env): target_platforms = ["cp39_linux_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), struct( @@ -464,7 +468,7 @@ def _test_multi_os_legacy(env): target_platforms = ["cp39_osx_aarch64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -522,7 +526,7 @@ def _test_env_marker_resolution(env): target_platforms = ["cp311_linux_super_exotic", "cp311_windows_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -540,7 +544,7 @@ def _test_env_marker_resolution(env): target_platforms = ["cp311_windows_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -571,7 +575,7 @@ def _test_different_package_version(env): target_platforms = ["linux_aarch64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), struct( @@ -581,7 +585,7 @@ def _test_different_package_version(env): target_platforms = ["linux_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -612,7 +616,7 @@ def _test_different_package_extras(env): target_platforms = ["linux_aarch64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), struct( @@ -622,7 +626,7 @@ def _test_different_package_extras(env): target_platforms = ["linux_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -652,7 +656,7 @@ def _test_optional_hash(env): target_platforms = ["linux_x86_64"], url = "https://example.org/bar-0.0.4.whl", filename = "bar-0.0.4.whl", - sha256 = "", + digest = "", yanked = None, ), ], @@ -670,7 +674,7 @@ def _test_optional_hash(env): target_platforms = ["linux_x86_64"], url = "https://example.org/foo-0.0.5.whl", filename = "foo-0.0.5.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", yanked = None, ), ], @@ -700,7 +704,7 @@ def _test_git_sources(env): target_platforms = ["linux_x86_64"], url = "", filename = "", - sha256 = "", + digest = "", yanked = None, ), ], @@ -740,23 +744,23 @@ def _test_overlapping_shas_with_index_results(env): "foo": struct( index_url = "https://example.com", sdists = { - "5d15t": struct( + "sha256:5d15t": struct( url = "sdist", - sha256 = "5d15t", + digest = "sha256:5d15t", filename = "foo-0.0.1.tar.gz", yanked = None, ), }, whls = { - "deadb11f": struct( + "sha256:deadb11f": struct( url = "super2", - sha256 = "deadb11f", + digest = "sha256:deadb11f", filename = "foo-0.0.1-py3-none-macosx_14_0_x86_64.whl", yanked = None, ), - "deadbaaf": struct( + "sha256:deadbaaf": struct( url = "super2", - sha256 = "deadbaaf", + digest = "sha256:deadbaaf", filename = "foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -777,7 +781,7 @@ def _test_overlapping_shas_with_index_results(env): extra_pip_args = [], filename = "foo-0.0.1-py3-none-any.whl", requirement_line = "foo==0.0.3", - sha256 = "deadbaaf", + digest = "sha256:deadbaaf", target_platforms = ["cp39_linux_x86_64"], url = "super2", yanked = None, @@ -787,7 +791,7 @@ def _test_overlapping_shas_with_index_results(env): extra_pip_args = [], filename = "foo-0.0.1-py3-none-macosx_14_0_x86_64.whl", requirement_line = "foo==0.0.3", - sha256 = "deadb11f", + digest = "sha256:deadb11f", target_platforms = ["cp39_osx_x86_64"], url = "super2", yanked = None, @@ -798,6 +802,62 @@ def _test_overlapping_shas_with_index_results(env): _tests.append(_test_overlapping_shas_with_index_results) +def _test_non_sha256_hash_matching(env): + """Test that non-sha256 pins are matched against the index digests.""" + got = parse_requirements( + requirements_by_platform = { + "requirements_sha512": ["cp311_linux_x86_64"], + }, + platforms = { + "cp311_linux_x86_64": struct( + env = pep508_env( + python_version = "3.11.0", + os = "linux", + arch = "x86_64", + ), + whl_abi_tags = ["none"], + whl_platform_tags = ["any"], + ), + }, + get_index_urls = lambda _, __, **kwargs: { + "foo": struct( + index_url = "https://example.com", + sdists = {}, + whls = { + "sha512:deadbeef": struct( + url = "https://example.com/foo-0.0.1-py3-none-any.whl", + digest = "sha512:deadbeef", + filename = "foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + }, + ), + }, + ) + + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "https://example.com", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + filename = "foo-0.0.1-py3-none-any.whl", + requirement_line = "foo==0.0.1", + digest = "sha512:deadbeef", + target_platforms = ["cp311_linux_x86_64"], + url = "https://example.com/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_non_sha256_hash_matching) + def _test_get_index_urls_different_versions(env): """Test that different versions from index URLs are matched correctly per platform.""" got = parse_requirements( @@ -832,15 +892,15 @@ def _test_get_index_urls_different_versions(env): index_url = "", sdists = {}, whls = { - "deadb11f": struct( + "sha256:deadb11f": struct( url = "super2", - sha256 = "deadb11f", + digest = "sha256:deadb11f", filename = "foo-0.0.2-py3-none-any.whl", yanked = None, ), - "deadbaaf": struct( + "sha256:deadbaaf": struct( url = "super2", - sha256 = "deadbaaf", + digest = "sha256:deadbaaf", filename = "foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -861,7 +921,7 @@ def _test_get_index_urls_different_versions(env): extra_pip_args = [], filename = "", requirement_line = "boo==0.0.4 --hash=sha256:deadbaaf", - sha256 = "", + digest = "", target_platforms = ["cp39_linux_x86_64"], url = "", yanked = None, @@ -879,7 +939,7 @@ def _test_get_index_urls_different_versions(env): extra_pip_args = [], filename = "", requirement_line = "foo==0.0.1 --hash=sha256:deadbeef", - sha256 = "", + digest = "", target_platforms = ["cp39_linux_x86_64"], url = "", yanked = None, @@ -889,7 +949,7 @@ def _test_get_index_urls_different_versions(env): extra_pip_args = [], filename = "foo-0.0.2-py3-none-any.whl", requirement_line = "foo==0.0.2", - sha256 = "deadb11f", + digest = "sha256:deadb11f", target_platforms = ["cp310_linux_x86_64"], url = "super2", yanked = None, @@ -969,9 +1029,9 @@ def _test_get_index_urls_single_py_version(env): index_url = "", sdists = {}, whls = { - "deadb11f": struct( + "sha256:deadb11f": struct( url = "super2", - sha256 = "deadb11f", + digest = "sha256:deadb11f", filename = "foo-0.0.2-py3-none-any.whl", yanked = None, ), @@ -992,7 +1052,7 @@ def _test_get_index_urls_single_py_version(env): extra_pip_args = [], filename = "foo-0.0.2-py3-none-any.whl", requirement_line = "foo==0.0.2", - sha256 = "deadb11f", + digest = "sha256:deadb11f", target_platforms = ["cp310_linux_x86_64"], url = "super2", yanked = None, @@ -1062,7 +1122,7 @@ def _test_uv_lock_consistent(env): requirement_line = "foo[extra]==0.0.1", target_platforms = ["linux_x86_64", "windows_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1090,7 +1150,7 @@ def _test_uv_lock_primary_source(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1100,6 +1160,34 @@ def _test_uv_lock_primary_source(env): _tests.append(_test_uv_lock_primary_source) +def _test_uv_lock_non_sha256_hash(env): + """Test that non-sha256 uv.lock hashes are propagated to the sources.""" + got = parse_requirements( + uv_lock = "uv_lock_foo_sha512", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "https://pypi.org/simple/foo", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo==0.0.1", + target_platforms = ["linux_x86_64"], + filename = "foo-0.0.1-py3-none-any.whl", + digest = "sha512:deadbeef", + url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_non_sha256_hash) + def _test_uv_lock_primary_source_multiple_versions(env): """Test that uv.lock with multiple versions of the same package works.""" got = parse_requirements( @@ -1118,7 +1206,7 @@ def _test_uv_lock_primary_source_multiple_versions(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1128,7 +1216,7 @@ def _test_uv_lock_primary_source_multiple_versions(env): requirement_line = "foo==0.0.2", target_platforms = ["linux_x86_64"], filename = "foo-0.0.2-py3-none-any.whl", - sha256 = "deadb11f", + digest = "sha256:deadb11f", url = "https://files.pythonhosted.org/packages/foo-0.0.2-py3-none-any.whl", yanked = None, ), @@ -1156,7 +1244,7 @@ def _test_uv_lock_primary_source_with_extras(env): requirement_line = "foo[extra]==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1184,7 +1272,7 @@ def _test_uv_lock_primary_source_includes_virtual(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1222,7 +1310,7 @@ def _test_uv_lock_cross_consistent(env): requirement_line = "foo[extra]==0.0.1", target_platforms = ["linux_x86_64", "windows_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1250,7 +1338,7 @@ def _test_uv_lock_vcs_entry(env): requirement_line = "foo==0.1.0", target_platforms = ["linux_x86_64"], filename = "foo.git", - sha256 = "", + digest = "", url = "https://github.com/org/foo.git", yanked = None, ), @@ -1278,7 +1366,7 @@ def _test_uv_lock_rules_python_pkg_not_skipped(env): requirement_line = "rules_python==0.0.1", target_platforms = ["linux_x86_64"], filename = "rules_python-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/rules_python-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1311,7 +1399,7 @@ def _test_uv_lock_no_consistency_check(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1339,7 +1427,7 @@ def _test_uv_lock_multiple_packages(env): requirement_line = "bar==0.0.1", target_platforms = ["linux_x86_64"], filename = "bar-0.0.1.tar.gz", - sha256 = "deadb00f", + digest = "sha256:deadb00f", url = "https://files.pythonhosted.org/packages/bar-0.0.1.tar.gz", yanked = None, ), @@ -1357,7 +1445,7 @@ def _test_uv_lock_multiple_packages(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1386,7 +1474,7 @@ def _test_uv_lock_with_extra_pip_args(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1418,7 +1506,7 @@ def _test_uv_lock_multi_os_with_requirements(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_aarch64", "linux_x86_64", "windows_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1446,7 +1534,7 @@ def _test_uv_lock_extras_optional_deps(env): requirement_line = "foo[extra1,extra2]==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1474,7 +1562,7 @@ def _test_uv_lock_extras_dep_edge(env): requirement_line = "bar[extra1]==0.0.2", target_platforms = ["linux_x86_64"], filename = "bar-0.0.2-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/bar-0.0.2-py3-none-any.whl", yanked = None, ), @@ -1492,7 +1580,7 @@ def _test_uv_lock_extras_dep_edge(env): requirement_line = "foo==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "baadbeef", + digest = "sha256:baadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), @@ -1527,7 +1615,7 @@ def _test_uv_lock_wheel_dedup_single_version(env): requirement_line = "foo==0.0.1", target_platforms = ["cp39_linux_x86_64"], filename = "foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", - sha256 = "aaa", + digest = "sha256:aaa", url = "https://files.pythonhosted.org/packages/foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", yanked = None, ), @@ -1573,7 +1661,7 @@ def _test_uv_lock_wheel_dedup_resolution_markers(env): requirement_line = "foo==0.0.1", target_platforms = ["cp39_linux_x86_64"], filename = "foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", - sha256 = "aaa", + digest = "sha256:aaa", url = "https://files.pythonhosted.org/packages/foo-0.0.1-cp39-cp39-manylinux_2_17_x86_64.whl", yanked = None, ), @@ -1583,7 +1671,7 @@ def _test_uv_lock_wheel_dedup_resolution_markers(env): requirement_line = "foo==0.0.2", target_platforms = ["cp39_osx_aarch64"], filename = "foo-0.0.2-cp39-cp39-macosx_11_0_arm64.whl", - sha256 = "ccc", + digest = "sha256:ccc", url = "https://files.pythonhosted.org/packages/foo-0.0.2-cp39-cp39-macosx_11_0_arm64.whl", yanked = None, ), @@ -1611,7 +1699,7 @@ def _test_uv_lock_requires_dist_extras(env): requirement_line = "foo[all]==0.0.1", target_platforms = ["linux_x86_64"], filename = "foo-0.0.1-py3-none-any.whl", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl", yanked = None, ), diff --git a/tests/pypi/parse_simpleapi_html/parse_simpleapi_html_tests.bzl b/tests/pypi/parse_simpleapi_html/parse_simpleapi_html_tests.bzl index c84140f459..975fcd0ee6 100644 --- a/tests/pypi/parse_simpleapi_html/parse_simpleapi_html_tests.bzl +++ b/tests/pypi/parse_simpleapi_html/parse_simpleapi_html_tests.bzl @@ -78,7 +78,7 @@ def _test_sdist(env): ), struct( filename = "foo-0.0.1.tar.gz", - sha256 = "deadbeefasource", + digest = "sha256:deadbeefasource", url = "https://example.org/full-url/foo-0.0.1.tar.gz", yanked = None, version = "0.0.1", @@ -95,7 +95,7 @@ def _test_sdist(env): ), struct( filename = "foo-0.0.1.tar.gz", - sha256 = "deadbeefasource", + digest = "sha256:deadbeefasource", url = "https://example.org/full-url/foo-0.0.1.tar.gz", version = "0.0.1", yanked = "", @@ -112,7 +112,7 @@ def _test_sdist(env): ), struct( filename = "foo-0.0.1.tar.gz", - sha256 = "deadbeefasource", + digest = "sha256:deadbeefasource", url = "https://example.org/full-url/foo-0.0.1.tar.gz", version = "0.0.1", # NOTE @aignas 2026-03-09: we preserve the white space @@ -130,7 +130,7 @@ def _test_sdist(env): ), struct( filename = "foo-0.0.1.tar.gz", - sha256 = "deadbeefasource", + digest = "sha256:deadbeefasource", url = "https://example.org/full-url/foo-0.0.1.tar.gz", version = "0.0.1", yanked = "", @@ -143,22 +143,22 @@ def _test_sdist(env): got = parse_simpleapi_html(content = html) env.expect.that_collection(got.sdists).has_size(1) env.expect.that_collection(got.whls).has_size(0) - env.expect.that_collection(got.sha256s_by_version).has_size(1) + env.expect.that_collection(got.hashes_by_version).has_size(1) if not got: fail("expected at least one element, but did not get anything from:\n{}".format(html)) actual = env.expect.that_struct( - got.sdists[want.sha256], + got.sdists[want.digest], attrs = dict( filename = subjects.str, - sha256 = subjects.str, + digest = subjects.str, url = subjects.str, yanked = subjects.str, version = subjects.str, ), ) actual.filename().equals(want.filename) - actual.sha256().equals(want.sha256) + actual.digest().equals(want.digest) actual.url().equals(want.url) actual.yanked().equals(want.yanked) actual.version().equals(want.version) @@ -182,7 +182,7 @@ def _test_whls(env): filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", metadata_sha256 = "deadb00f", metadata_url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", version = "0.0.2", yanked = None, @@ -202,7 +202,7 @@ def _test_whls(env): filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", metadata_sha256 = "deadb00f", metadata_url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", version = "0.0.2", yanked = None, @@ -221,7 +221,7 @@ def _test_whls(env): filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", metadata_sha256 = "deadb00f", metadata_url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata", - sha256 = "deadbeef", + digest = "sha256:deadbeef", version = "0.0.2", url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", yanked = None, @@ -240,7 +240,7 @@ def _test_whls(env): filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", metadata_sha256 = "deadb00f", metadata_url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata", - sha256 = "deadbeef", + digest = "sha256:deadbeef", version = "0.0.2", url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", yanked = None, @@ -258,7 +258,7 @@ def _test_whls(env): filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", metadata_sha256 = "", metadata_url = "", - sha256 = "deadbeef", + digest = "sha256:deadbeef", url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", version = "0.0.2", yanked = None, @@ -275,12 +275,12 @@ def _test_whls(env): fail("expected at least one element, but did not get anything from:\n{}".format(html)) actual = env.expect.that_struct( - got.whls[want.sha256], + got.whls[want.digest], attrs = dict( filename = subjects.str, metadata_sha256 = subjects.str, metadata_url = subjects.str, - sha256 = subjects.str, + digest = subjects.str, url = subjects.str, yanked = subjects.str, version = subjects.str, @@ -289,13 +289,75 @@ def _test_whls(env): actual.filename().equals(want.filename) actual.metadata_sha256().equals(want.metadata_sha256) actual.metadata_url().equals(want.metadata_url) - actual.sha256().equals(want.sha256) + actual.digest().equals(want.digest) actual.url().equals(want.url) actual.yanked().equals(want.yanked) actual.version().equals(want.version) _tests.append(_test_whls) +def _test_non_sha256_fragment(env): + """The index may advertise digests calculated with any hash algorithm (PEP 503).""" + + html = _generate_html( + struct( + attrs = [ + 'href="https://example.org/full-url/foo-0.0.1-py3-none-any.whl#sha512=deadbeef"', + ], + filename = "foo-0.0.1-py3-none-any.whl", + ), + struct( + attrs = [ + 'href="https://example.org/full-url/foo-0.0.1.tar.gz#sha512=deadb00f"', + ], + filename = "foo-0.0.1.tar.gz", + ), + struct( + # A non-hash fragment is kept as part of the URL. + attrs = [ + 'href="https://example.org/full-url/foo-0.0.2-py3-none-any.whl#egg=foo"', + ], + filename = "foo-0.0.2-py3-none-any.whl", + ), + ) + got = parse_simpleapi_html(content = html) + + env.expect.that_collection(got.whls).has_size(2) + env.expect.that_collection(got.sdists).has_size(1) + env.expect.that_dict(got.hashes_by_version).contains_exactly({ + "0.0.1": ["sha512:deadbeef", "sha512:deadb00f"], + "0.0.2": [""], + }) + + whl = got.whls["sha512:deadbeef"] + env.expect.that_str(whl.digest).equals("sha512:deadbeef") + env.expect.that_str(whl.url).equals("https://example.org/full-url/foo-0.0.1-py3-none-any.whl") + + sdist = got.sdists["sha512:deadb00f"] + env.expect.that_str(sdist.digest).equals("sha512:deadb00f") + + no_hash_whl = got.whls[""] + env.expect.that_str(no_hash_whl.digest).equals("") + env.expect.that_str(no_hash_whl.url).equals("https://example.org/full-url/foo-0.0.2-py3-none-any.whl#egg=foo") + +_tests.append(_test_non_sha256_fragment) + +def _test_sha256_fragment_digest(env): + html = _generate_html( + struct( + attrs = [ + 'href="https://example.org/full-url/foo-0.0.1-py3-none-any.whl#sha256=deadbeef"', + ], + filename = "foo-0.0.1-py3-none-any.whl", + ), + ) + got = parse_simpleapi_html(content = html) + + whl = got.whls["sha256:deadbeef"] + env.expect.that_str(whl.digest).equals("sha256:deadbeef") + +_tests.append(_test_sha256_fragment_digest) + def parse_simpleapi_html_test_suite(name): """Create the test suite. diff --git a/tests/pypi/pypi_cache/pypi_cache_tests.bzl b/tests/pypi/pypi_cache/pypi_cache_tests.bzl index 14c12ae6d2..21c2f5bd8c 100644 --- a/tests/pypi/pypi_cache/pypi_cache_tests.bzl +++ b/tests/pypi/pypi_cache/pypi_cache_tests.bzl @@ -11,8 +11,8 @@ def _cache(env, **kwargs): cache = pypi_cache(**kwargs) attrs = { + "hashes_by_version": subjects.dict, "sdists": subjects.dict, - "sha256s_by_version": subjects.dict, "whls": subjects.dict, } @@ -48,14 +48,14 @@ def _test_memory_cache_hit(env): # Mocked parsed result from a PyPI-like index fake_result = struct( sdists = { - "sha_1": struct(version = "1.0.0", filename = "pkg-1.0.0.tar.gz"), + "sha256:sha_1": struct(version = "1.0.0", filename = "pkg-1.0.0.tar.gz"), }, whls = { - "sha_2": struct(version = "1.1.0", filename = "pkg-1.1.0-py3-none-any.whl"), + "sha256:sha_2": struct(version = "1.1.0", filename = "pkg-1.1.0-py3-none-any.whl"), }, - sha256s_by_version = { - "1.0.0": ["sha_1"], - "1.1.0": ["sha_2"], + hashes_by_version = { + "1.0.0": ["sha256:sha_1"], + "1.1.0": ["sha256:sha_2"], }, ) @@ -70,7 +70,7 @@ def _test_memory_cache_hit(env): got.sdists().contains_exactly(fake_result.sdists) got.whls().contains_exactly(fake_result.whls) - got.sha256s_by_version().contains_exactly(fake_result.sha256s_by_version) + got.hashes_by_version().contains_exactly(fake_result.hashes_by_version) # A different key with fewer versions key = ("https://{PYPI_INDEX_URL}/pkg", "https://pypi.org/simple/pkg", ["1.0.0"]) @@ -78,7 +78,7 @@ def _test_memory_cache_hit(env): got = cache.get(key) got.sdists().contains_exactly(fake_result.sdists) got.whls().contains_exactly({}) - got.sha256s_by_version().contains_exactly({"1.0.0": ["sha_1"]}) + got.hashes_by_version().contains_exactly({"1.0.0": ["sha256:sha_1"]}) # A key with no matches key = ("https://{PYPI_INDEX_URL}/pkg", "https://pypi.org/simple/pkg", ["1.2.0"]) @@ -94,31 +94,34 @@ def _test_pypi_cache_writes_to_facts(env): fake_result = struct( sdists = { - "sha_sdist": struct( + "sha256:sha_sdist": struct( version = "1.0.0", filename = "pkg-1.0.0.tar.gz", url = "https://pypi.org/files/pkg-1.0.0.tar.gz", + digest = "sha256:sha_sdist", yanked = "", ), }, whls = { - "sha_whl": struct( + "sha256:sha_whl": struct( version = "1.0.0", filename = "pkg-1.0.0-py3-none-any.whl", url = "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl", + digest = "sha256:sha_whl", yanked = "Security issue", ), # This won't get stored - "sha_whl_2": struct( + "sha256:sha_whl_2": struct( version = "1.1.0", filename = "pkg-1.1.0-py3-none-any.whl", url = "https://pypi.org/files/pkg-1.1.0-py3-none-any.whl", + digest = "sha256:sha_whl_2", yanked = None, ), }, - sha256s_by_version = { - "1.0.0": ["sha_sdist", "sha_whl"], - "1.1.0": ["sha_whl_2"], + hashes_by_version = { + "1.0.0": ["sha256:sha_sdist", "sha256:sha_whl"], + "1.1.0": ["sha256:sha_whl_2"], }, ) @@ -130,11 +133,11 @@ def _test_pypi_cache_writes_to_facts(env): # Then the key returns us the same items got = cache.get(key) got.whls().contains_exactly({ - "sha_whl": fake_result.whls["sha_whl"], + "sha256:sha_whl": fake_result.whls["sha256:sha_whl"], }) got.sdists().contains_exactly(fake_result.sdists) - got.sha256s_by_version().contains_exactly({ - "1.0.0": fake_result.sha256s_by_version["1.0.0"], + got.hashes_by_version().contains_exactly({ + "1.0.0": fake_result.hashes_by_version["1.0.0"], }) # Then when we get facts at the end @@ -143,30 +146,30 @@ def _test_pypi_cache_writes_to_facts(env): # We are not using the real index URL, because we may have credentials in here "https://{PYPI_INDEX_URL}": { "pkg": { - "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha_whl", - "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha_sdist", + "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha256:sha_whl", + "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha256:sha_sdist", }, }, }, "dist_yanked": { "https://{PYPI_INDEX_URL}": { "pkg": { - "sha_sdist": "", - "sha_whl": "Security issue", + "sha256:sha_sdist": "", + "sha256:sha_whl": "Security issue", }, }, }, - "fact_version": "v1", # Facts version + "fact_version": "v2", # Facts version }) # When we get the other items cached in memory, they get written to facts got = cache.get((key[0], key[1], ["1.1.0"])) got.whls().contains_exactly({ - "sha_whl_2": fake_result.whls["sha_whl_2"], + "sha256:sha_whl_2": fake_result.whls["sha256:sha_whl_2"], }) got.sdists().contains_exactly({}) - got.sha256s_by_version().contains_exactly({ - "1.1.0": fake_result.sha256s_by_version["1.1.0"], + got.hashes_by_version().contains_exactly({ + "1.1.0": fake_result.hashes_by_version["1.1.0"], }) # Then when we get facts at the end @@ -175,21 +178,21 @@ def _test_pypi_cache_writes_to_facts(env): # We are not using the real index URL, because we may have credentials in here "https://{PYPI_INDEX_URL}": { "pkg": { - "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha_whl", - "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha_sdist", - "https://pypi.org/files/pkg-1.1.0-py3-none-any.whl": "sha_whl_2", + "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha256:sha_whl", + "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha256:sha_sdist", + "https://pypi.org/files/pkg-1.1.0-py3-none-any.whl": "sha256:sha_whl_2", }, }, }, "dist_yanked": { "https://{PYPI_INDEX_URL}": { "pkg": { - "sha_sdist": "", - "sha_whl": "Security issue", + "sha256:sha_sdist": "", + "sha256:sha_whl": "Security issue", }, }, }, - "fact_version": "v1", # Facts version + "fact_version": "v2", # Facts version }) _tests.append(_test_pypi_cache_writes_to_facts) @@ -201,20 +204,20 @@ def _test_pypi_cache_reads_from_facts(env): # We are not using the real index URL, because we may have credentials in here "https://{PYPI_INDEX_URL}": { "pkg": { - "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha_whl", - "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha_sdist", + "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha256:sha_whl", + "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha256:sha_sdist", }, }, }, "dist_yanked": { "https://{PYPI_INDEX_URL}": { "pkg": { - "sha_sdist": "", - "sha_whl": "Security issue", + "sha256:sha_sdist": "", + "sha256:sha_whl": "Security issue", }, }, }, - "fact_version": "v1", # Facts version + "fact_version": "v2", # Facts version }) cache = _cache(env, mctx = mock_ctx) @@ -229,8 +232,8 @@ def _test_pypi_cache_reads_from_facts(env): expected_result = struct( sdists = { - "sha_sdist": struct( - sha256 = "sha_sdist", + "sha256:sha_sdist": struct( + digest = "sha256:sha_sdist", version = "1.0.0", filename = "pkg-1.0.0.tar.gz", metadata_url = "", @@ -240,8 +243,8 @@ def _test_pypi_cache_reads_from_facts(env): ), }, whls = { - "sha_whl": struct( - sha256 = "sha_whl", + "sha256:sha_whl": struct( + digest = "sha256:sha_whl", version = "1.0.0", filename = "pkg-1.0.0-py3-none-any.whl", url = "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl", @@ -250,14 +253,14 @@ def _test_pypi_cache_reads_from_facts(env): yanked = "Security issue", ), }, - sha256s_by_version = { - "1.0.0": ["sha_sdist", "sha_whl"], + hashes_by_version = { + "1.0.0": ["sha256:sha_sdist", "sha256:sha_whl"], }, ) got.whls().contains_exactly(expected_result.whls) got.sdists().contains_exactly(expected_result.sdists) - got.sha256s_by_version().contains_exactly(expected_result.sha256s_by_version) + got.hashes_by_version().contains_exactly(expected_result.hashes_by_version) # Then when we store the same facts back again, because we accessed the cached keys. cache.get_facts().contains_exactly(mock_ctx.facts) @@ -275,14 +278,14 @@ def _test_pypi_cache_reads_from_facts_drops_unaccessed_dists(env): "dist_hashes": { "https://{PYPI_INDEX_URL}": { "pkg": { - "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha_whl_1.0.0", - "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha_sdist_1.0.0", - "https://pypi.org/files/pkg-1.1.0-py3-none-any.whl": "sha_whl_1.1.0", - "https://pypi.org/files/pkg-1.1.0.tar.gz": "sha_sdist_1.1.0", + "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha256:sha_whl_1.0.0", + "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha256:sha_sdist_1.0.0", + "https://pypi.org/files/pkg-1.1.0-py3-none-any.whl": "sha256:sha_whl_1.1.0", + "https://pypi.org/files/pkg-1.1.0.tar.gz": "sha256:sha_sdist_1.1.0", }, }, }, - "fact_version": "v1", + "fact_version": "v2", }) cache = _cache(env, mctx = mock_ctx) @@ -292,8 +295,8 @@ def _test_pypi_cache_reads_from_facts_drops_unaccessed_dists(env): expected = struct( sdists = { - "sha_sdist_1.0.0": struct( - sha256 = "sha_sdist_1.0.0", + "sha256:sha_sdist_1.0.0": struct( + digest = "sha256:sha_sdist_1.0.0", version = "1.0.0", filename = "pkg-1.0.0.tar.gz", metadata_url = "", @@ -303,8 +306,8 @@ def _test_pypi_cache_reads_from_facts_drops_unaccessed_dists(env): ), }, whls = { - "sha_whl_1.0.0": struct( - sha256 = "sha_whl_1.0.0", + "sha256:sha_whl_1.0.0": struct( + digest = "sha256:sha_whl_1.0.0", version = "1.0.0", filename = "pkg-1.0.0-py3-none-any.whl", metadata_url = "", @@ -313,29 +316,93 @@ def _test_pypi_cache_reads_from_facts_drops_unaccessed_dists(env): yanked = None, ), }, - sha256s_by_version = { - "1.0.0": ["sha_sdist_1.0.0", "sha_whl_1.0.0"], + hashes_by_version = { + "1.0.0": ["sha256:sha_sdist_1.0.0", "sha256:sha_whl_1.0.0"], }, ) got.whls().contains_exactly(expected.whls) got.sdists().contains_exactly(expected.sdists) - got.sha256s_by_version().contains_exactly(expected.sha256s_by_version) + got.hashes_by_version().contains_exactly(expected.hashes_by_version) # get_facts() must only contain version 1.0.0 data; 1.1.0 is dropped cache.get_facts().contains_exactly({ "dist_hashes": { "https://{PYPI_INDEX_URL}": { "pkg": { - "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha_whl_1.0.0", - "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha_sdist_1.0.0", + "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha256:sha_whl_1.0.0", + "https://pypi.org/files/pkg-1.0.0.tar.gz": "sha256:sha_sdist_1.0.0", }, }, }, - "fact_version": "v1", + "fact_version": "v2", }) _tests.append(_test_pypi_cache_reads_from_facts_drops_unaccessed_dists) +def _test_pypi_cache_facts_non_sha256_round_trip(env): + """Verifies that digests of other hash algorithms survive the facts round trip.""" + mock_ctx = mocks.mctx(facts = {}) + cache = _cache(env, mctx = mock_ctx) + + fake_result = struct( + sdists = {}, + whls = { + "sha512:whl_digest": struct( + version = "1.0.0", + filename = "pkg-1.0.0-py3-none-any.whl", + url = "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl", + digest = "sha512:whl_digest", + yanked = None, + ), + }, + hashes_by_version = { + "1.0.0": ["sha512:whl_digest"], + }, + ) + + key = ("https://{PYPI_INDEX_URL}/pkg/", "https://pypi.org/simple/pkg/", ["1.0.0"]) + cache.setdefault(key, fake_result) + + # The hash algorithm is stored in the facts + cache.get_facts().contains_exactly({ + "dist_hashes": { + "https://{PYPI_INDEX_URL}": { + "pkg": { + "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha512:whl_digest", + }, + }, + }, + "fact_version": "v2", # Facts version + }) + + # And a cache that only has the facts reconstructs the digests + cache = _cache(env, mctx = mocks.mctx(facts = { + "dist_hashes": { + "https://{PYPI_INDEX_URL}": { + "pkg": { + "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl": "sha512:whl_digest", + }, + }, + }, + "fact_version": "v2", # Facts version + })) + + got = cache.get(key) + got.whls().contains_exactly({ + "sha512:whl_digest": struct( + digest = "sha512:whl_digest", + version = "1.0.0", + filename = "pkg-1.0.0-py3-none-any.whl", + metadata_url = "", + metadata_sha256 = "", + url = "https://pypi.org/files/pkg-1.0.0-py3-none-any.whl", + yanked = None, + ), + }) + got.hashes_by_version().contains_exactly({"1.0.0": ["sha512:whl_digest"]}) + +_tests.append(_test_pypi_cache_facts_non_sha256_round_trip) + def _test_memory_cache_index_urls(env): """Verifies that the cache returns stored values for index_urls.""" store = {} @@ -377,7 +444,7 @@ def _test_pypi_cache_writes_index_urls_to_facts(env): cache.setdefault(key, fake_result) cache.get_facts().contains_exactly({ - "fact_version": "v1", + "fact_version": "v2", "index_urls": { "https://pypi.org/simple/": { "pkg-a": "https://pypi.org/simple/pkg-a/", @@ -389,7 +456,7 @@ def _test_pypi_cache_writes_index_urls_to_facts(env): cache.setdefault(key, fake_result) cache.get_facts().contains_exactly({ - "fact_version": "v1", + "fact_version": "v2", "index_urls": { "https://pypi.org/simple/": { "pkg-a": "https://pypi.org/simple/pkg-a/", @@ -403,7 +470,7 @@ _tests.append(_test_pypi_cache_writes_index_urls_to_facts) def _test_pypi_cache_reads_index_urls_from_facts(env): """Verifies that reading index_urls from facts works correctly.""" mock_ctx = mocks.mctx(facts = { - "fact_version": "v1", + "fact_version": "v2", "index_urls": { "https://pypi.org/simple/": { "pkg-a": "https://pypi.org/simple/pkg-a/", @@ -434,7 +501,7 @@ _tests.append(_test_pypi_cache_reads_index_urls_from_facts) def _test_pypi_cache_reads_index_urls_from_facts_incomplete(env): """Verifies that incomplete index_urls facts returns None (forces fresh download).""" mock_ctx = mocks.mctx(facts = { - "fact_version": "v1", + "fact_version": "v2", "index_urls": { "https://pypi.org/simple/": { "pkg-a": "https://pypi.org/simple/pkg-a/", @@ -457,7 +524,7 @@ def _test_pypi_cache_reads_index_urls_from_facts_drops_unaccessed(env): removed from all requirements files) get cleaned up from the lockfile. """ mock_ctx = mocks.mctx(facts = { - "fact_version": "v1", + "fact_version": "v2", "index_urls": { "https://pypi.org/simple/": { "pkg-a": "https://pypi.org/simple/pkg-a/", @@ -478,7 +545,7 @@ def _test_pypi_cache_reads_index_urls_from_facts_drops_unaccessed(env): # get_facts() must only return the requested (accessed) subset; pkg-c is dropped cache.get_facts().contains_exactly({ - "fact_version": "v1", + "fact_version": "v2", "index_urls": { "https://pypi.org/simple/": { "pkg-a": "https://pypi.org/simple/pkg-a/", diff --git a/tests/pypi/simpleapi_download/simpleapi_download_tests.bzl b/tests/pypi/simpleapi_download/simpleapi_download_tests.bzl index 4e86b76e10..dfa33ebe65 100644 --- a/tests/pypi/simpleapi_download/simpleapi_download_tests.bzl +++ b/tests/pypi/simpleapi_download/simpleapi_download_tests.bzl @@ -43,7 +43,7 @@ def _test_simple(env): output = struct( sdists = {"deadbeef": url.strip("/").split("/")[-1]}, whls = {"deadb33f": url.strip("/").split("/")[-1]}, - sha256s_by_version = {"fizz": url.strip("/").split("/")[-1]}, + hashes_by_version = {"fizz": url.strip("/").split("/")[-1]}, ), success = True, ) @@ -71,19 +71,19 @@ def _test_simple(env): "bar": struct( index_url = "https://main.com/bar/", sdists = {"deadbeef": "bar"}, - sha256s_by_version = {"fizz": "bar"}, + hashes_by_version = {"fizz": "bar"}, whls = {"deadb33f": "bar"}, ), "baz": struct( index_url = "https://main.com/baz/", sdists = {"deadbeef": "baz"}, - sha256s_by_version = {"fizz": "baz"}, + hashes_by_version = {"fizz": "baz"}, whls = {"deadb33f": "baz"}, ), "foo": struct( index_url = "https://extra.com/foo/", sdists = {"deadbeef": "foo"}, - sha256s_by_version = {"fizz": "foo"}, + hashes_by_version = {"fizz": "foo"}, whls = {"deadb33f": "foo"}, ), }) @@ -115,7 +115,7 @@ def _test_index_overrides(env): output = struct( sdists = {"deadbeef": url.strip("/").split("/")[-1]}, whls = {"deadb33f": url.strip("/").split("/")[-1]}, - sha256s_by_version = {"fizz": url.strip("/").split("/")[-1]}, + hashes_by_version = {"fizz": url.strip("/").split("/")[-1]}, ), success = True, ) @@ -147,19 +147,19 @@ def _test_index_overrides(env): "ba_z": struct( index_url = "https://main.com/ba-z/", sdists = {"deadbeef": "ba-z"}, - sha256s_by_version = {"fizz": "ba-z"}, + hashes_by_version = {"fizz": "ba-z"}, whls = {"deadb33f": "ba-z"}, ), "bar": struct( index_url = "https://main.com/bar/", sdists = {"deadbeef": "bar"}, - sha256s_by_version = {"fizz": "bar"}, + hashes_by_version = {"fizz": "bar"}, whls = {"deadb33f": "bar"}, ), "foo": struct( index_url = "https://extra.com/foo/", sdists = {"deadbeef": "foo"}, - sha256s_by_version = {"fizz": "foo"}, + hashes_by_version = {"fizz": "foo"}, whls = {"deadb33f": "foo"}, ), }) diff --git a/tests/pypi/whl_repo_name/whl_repo_name_tests.bzl b/tests/pypi/whl_repo_name/whl_repo_name_tests.bzl index 35e6bcdf9f..968bdc3d2a 100644 --- a/tests/pypi/whl_repo_name/whl_repo_name_tests.bzl +++ b/tests/pypi/whl_repo_name/whl_repo_name_tests.bzl @@ -25,6 +25,18 @@ def _test_simple(env): _tests.append(_test_simple) +def _test_simple_canonical_digest(env): + got = whl_repo_name("foo-1.2.3-py3-none-any.whl", "sha256:deadbeef") + env.expect.that_str(got).equals("foo_py3_none_any_deadbeef") + +_tests.append(_test_simple_canonical_digest) + +def _test_simple_canonical_digest_other_algo(env): + got = whl_repo_name("foo-1.2.3-py3-none-any.whl", "sha512:deadbeef000deadbeef") + env.expect.that_str(got).equals("foo_py3_none_any_deadbeef") + +_tests.append(_test_simple_canonical_digest_other_algo) + def _test_simple_no_sha(env): got = whl_repo_name("foo-1.2.3-py3-none-any.whl", "") env.expect.that_str(got).equals("foo_1_2_3_py3_none_any") From fbb48ff22bfb3c77fb05f6e43fd5113a16226927 Mon Sep 17 00:00:00 2001 From: Cal Jacobson Date: Sat, 1 Aug 2026 10:41:36 -0400 Subject: [PATCH 131/179] fix(gazelle)!: use per-version stdlib lists for Python 3.13 and 3.14 (#3978) The gazelle Python extension picks its stdlib list by `python_version`. The select had no branch for `3.13` or `3.14`, so those configurations fell through to the `3.11` list and gazelle misses everything that changed since `3.11` (`3.14` added `compression.zstd`, `3.13` removed `telnetlib`). Bump `python_stdlib_list` to `stdlib-list` `0.12.0`, the first release shipping `lists/3.14.txt`, and add the two matching branches. Point `//conditions:default` at the newest list, so configurations with no matching branch stop landing on one four releases behind. Fetch the archive from the PyPI sdist rather than a GitHub release asset. Unlike `0.11.0`, `0.12.0` published no GitHub release assets. GitHub's auto-generated tag archives are not checksum-stable. PyPI never reuses an uploaded filename. ## BREAKING CHANGE: Gazelle plugin now requires rules_python 1.5.0 Bazel resolves every `select()` key at analysis time, so `is_python_3.14` must exist even for builds that never select it. `rules_python` generates that target from `MINOR_MAPPING`, which lists `3.14` starting in `1.5.0`. Older versions fail analysis, and that broke the `Gazelle: BCR` jobs on the first push. Two pins constrain resolution, and both move to `1.5.0`: the plugin's own `bazel_dep` (was `0.18.0`) and `gazelle/examples/bzlmod_build_file_generation` (was `1.4.0`). The example's `other_module` keeps its placeholder pin, which the root module overrides. ## Testing I added no automated coverage and would appreciate maintainer input on how, if at all, to test this. I didn't see an obvious way given the existing test infra. --- gazelle/MODULE.bazel | 2 +- gazelle/deps.bzl | 6 +++--- gazelle/examples/bzlmod_build_file_generation/MODULE.bazel | 2 +- gazelle/python/BUILD.bazel | 6 ++++-- news/3978.changed.md | 3 +++ news/3978.fixed.md | 5 +++++ 6 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 news/3978.changed.md create mode 100644 news/3978.fixed.md diff --git a/gazelle/MODULE.bazel b/gazelle/MODULE.bazel index cff6341a2b..b6eed23c6f 100644 --- a/gazelle/MODULE.bazel +++ b/gazelle/MODULE.bazel @@ -5,7 +5,7 @@ module( ) bazel_dep(name = "bazel_skylib", version = "1.8.2") -bazel_dep(name = "rules_python", version = "0.18.0") +bazel_dep(name = "rules_python", version = "1.5.0") bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go") bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle") bazel_dep(name = "rules_cc", version = "0.0.16") diff --git a/gazelle/deps.bzl b/gazelle/deps.bzl index 7072c6a372..c57d5193b8 100644 --- a/gazelle/deps.bzl +++ b/gazelle/deps.bzl @@ -26,9 +26,9 @@ def python_stdlib_list_deps(): http_archive( name = "python_stdlib_list", build_file_content = """exports_files(glob(["stdlib_list/lists/*.txt"]))""", - sha256 = "aa21a4f219530e85ecc364f0bbff2df4e6097a8954c63652af060f4e64afa65d", - strip_prefix = "stdlib-list-0.11.0", - url = "https://github.com/pypi/stdlib-list/releases/download/v0.11.0/v0.11.0.tar.gz", + sha256 = "517824f27ee89e591d8ae7c1dd9ff34f672eae50ee886ea31bb8816d77535675", + strip_prefix = "stdlib_list-0.12.0", + url = "https://files.pythonhosted.org/packages/8c/25/f1540879c8815387980e56f973e54605bd924612399ace31487f7444171c/stdlib_list-0.12.0.tar.gz", ) def gazelle_deps(): diff --git a/gazelle/examples/bzlmod_build_file_generation/MODULE.bazel b/gazelle/examples/bzlmod_build_file_generation/MODULE.bazel index 1f92ea3826..81719be01f 100644 --- a/gazelle/examples/bzlmod_build_file_generation/MODULE.bazel +++ b/gazelle/examples/bzlmod_build_file_generation/MODULE.bazel @@ -13,7 +13,7 @@ module( # For typical setups you set the version. # See the releases page for available versions. # https://github.com/bazel-contrib/rules_python/releases -bazel_dep(name = "rules_python", version = "1.4.0") +bazel_dep(name = "rules_python", version = "1.5.0") # The following stanza defines the dependency rules_python_gazelle_plugin. # For typical setups you set the version. diff --git a/gazelle/python/BUILD.bazel b/gazelle/python/BUILD.bazel index 8218d0b6d9..1ffa2890e1 100644 --- a/gazelle/python/BUILD.bazel +++ b/gazelle/python/BUILD.bazel @@ -54,10 +54,12 @@ copy_file( "@rules_python//python/config_settings:is_python_3.10": "@python_stdlib_list//:stdlib_list/lists/3.10.txt", "@rules_python//python/config_settings:is_python_3.11": "@python_stdlib_list//:stdlib_list/lists/3.11.txt", "@rules_python//python/config_settings:is_python_3.12": "@python_stdlib_list//:stdlib_list/lists/3.12.txt", + "@rules_python//python/config_settings:is_python_3.13": "@python_stdlib_list//:stdlib_list/lists/3.13.txt", + "@rules_python//python/config_settings:is_python_3.14": "@python_stdlib_list//:stdlib_list/lists/3.14.txt", "@rules_python//python/config_settings:is_python_3.8": "@python_stdlib_list//:stdlib_list/lists/3.8.txt", "@rules_python//python/config_settings:is_python_3.9": "@python_stdlib_list//:stdlib_list/lists/3.9.txt", - # This is the same behaviour as previously - "//conditions:default": "@python_stdlib_list//:stdlib_list/lists/3.11.txt", + # Fall back to the latest stdlib list + "//conditions:default": "@python_stdlib_list//:stdlib_list/lists/3.14.txt", }, ), out = "stdlib_list.txt", diff --git a/news/3978.changed.md b/news/3978.changed.md new file mode 100644 index 0000000000..8418abd0b3 --- /dev/null +++ b/news/3978.changed.md @@ -0,0 +1,3 @@ +(gazelle) **BREAKING** rules_python 1.5.0 or higher is now required. The Python +extension selects its standard library list on `is_python_3.14`, which earlier +versions do not define. diff --git a/news/3978.fixed.md b/news/3978.fixed.md new file mode 100644 index 0000000000..2c5cbb5f5d --- /dev/null +++ b/news/3978.fixed.md @@ -0,0 +1,5 @@ +(gazelle) The Python extension now uses the correct standard library module list for +`python_version` 3.13 and 3.14; previously both fell back to the 3.11 list, so modules +added or removed since then (e.g. `compression.zstd`, `telnetlib`) were misclassified. The +fallback list for unrecognized versions is now the newest available one rather than 3.11 +([#3978](https://github.com/bazel-contrib/rules_python/pull/3978)). From 0d6016ffe5307e2e7c30a90319f14f8efa801d2b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 08:22:08 -0700 Subject: [PATCH 132/179] feat(cc): introduce py_extension rule for C/C++ Python extension modules (#3973) Building C/C++ Python extension modules currently requires manually configuring complex `cc_binary` or `cc_shared_library` targets and setting up platform, ABI3, and extension tags. The new `py_extension` rule provides a high-level Starlark API for compiling native extensions that integrate seamlessly with `rules_python` toolchains and `py_library` dependencies. At a high level, the implementation is fairly simple: Users can provide raw C/C++ `srcs`, which are put into a `cc_library`. That `cc_library` is then given to `cc_shared_library`, which performs the linking to create e.g. `libfoo.so`. That artifact is then given to a custom rule that handles rename it according to Python needs, e.g. `foo.abi3.so`. `cc_shared_library` is used because it provides various facilities to control creation of a shared library, namely, control over what is and isn't linked into the shared library. Credit to @rsartor-cmd for finishing the prototype, expanding test coverage, and verifying behavior. Closes #3283. --------- Signed-off-by: dependabot[bot] Co-authored-by: rsartor-cmd Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../scripts/analyze_ci_failure.py | 3 + news/3973.added.md | 6 + python/cc/BUILD.bazel | 7 + python/cc/py_extension.bzl | 20 ++ python/features.bzl | 1 + python/private/BUILD.bazel | 2 + python/private/cc/BUILD.bazel | 45 ++++ python/private/cc/py_extension_macro.bzl | 191 ++++++++++++++ python/private/cc/py_extension_rule.bzl | 135 ++++++++++ python/private/common_labels.bzl | 1 + python/private/current_py_cc_libs.bzl | 34 ++- python/private/flags.bzl | 4 + python/private/py_cc_toolchain_info.bzl | 28 +++ python/private/py_cc_toolchain_macro.bzl | 7 + python/private/py_cc_toolchain_rule.bzl | 105 ++++++++ python/private/py_executable.bzl | 4 +- python/private/toolchain_types.bzl | 1 + .../py_cc_toolchain/py_cc_toolchain_tests.bzl | 31 +++ tests/cc/py_extension/BUILD.bazel | 194 ++++++++++++++ tests/cc/py_extension/add_one.c | 7 + tests/cc/py_extension/add_one.h | 6 + tests/cc/py_extension/add_one_helper.c | 7 + tests/cc/py_extension/add_one_helper.h | 2 + .../py_extension/dependency_graph/BUILD.bazel | 12 + .../dependency_graph_tests.bzl | 236 ++++++++++++++++++ .../dependency_graph/test_lib_a.c | 6 + .../dependency_graph/test_lib_b.c | 5 + .../dependency_graph/test_lib_c.c | 6 + .../dependency_graph/test_symbols.h | 8 + tests/cc/py_extension/ext_init_in_dep.c | 20 ++ tests/cc/py_extension/ext_limited.c | 22 ++ tests/cc/py_extension/ext_pkg_test.c | 22 ++ tests/cc/py_extension/ext_shared.c | 33 +++ tests/cc/py_extension/ext_source.c | 33 +++ tests/cc/py_extension/ext_static.c | 1 + .../cc/py_extension/py_extension/BUILD.bazel | 12 + .../py_extension/py_extension_tests.bzl | 102 ++++++++ .../py_extension/py_extension_build_test.bzl | 15 ++ .../cc/py_extension/py_extension_pkg_test.py | 16 ++ tests/cc/py_extension/py_extension_test.py | 47 ++++ .../py_extension/py_limited_api/BUILD.bazel | 12 + .../py_limited_api/py_limited_api_tests.bzl | 140 +++++++++++ tests/cc/py_extension/some_data.txt | 1 + tests/cc/py_extension/static_dep.c | 5 + tests/cc/py_extension/static_dep.h | 1 + .../bzlmod_lockfile/MODULE.bazel.lock | 2 +- tests/support/cc_toolchains/BUILD.bazel | 2 + .../support/py_cc_toolchain_info_subject.bzl | 28 +++ 48 files changed, 1624 insertions(+), 4 deletions(-) create mode 100644 news/3973.added.md create mode 100644 python/cc/py_extension.bzl create mode 100644 python/private/cc/py_extension_macro.bzl create mode 100644 python/private/cc/py_extension_rule.bzl create mode 100644 tests/cc/py_extension/BUILD.bazel create mode 100644 tests/cc/py_extension/add_one.c create mode 100644 tests/cc/py_extension/add_one.h create mode 100644 tests/cc/py_extension/add_one_helper.c create mode 100644 tests/cc/py_extension/add_one_helper.h create mode 100644 tests/cc/py_extension/dependency_graph/BUILD.bazel create mode 100644 tests/cc/py_extension/dependency_graph/dependency_graph_tests.bzl create mode 100644 tests/cc/py_extension/dependency_graph/test_lib_a.c create mode 100644 tests/cc/py_extension/dependency_graph/test_lib_b.c create mode 100644 tests/cc/py_extension/dependency_graph/test_lib_c.c create mode 100644 tests/cc/py_extension/dependency_graph/test_symbols.h create mode 100644 tests/cc/py_extension/ext_init_in_dep.c create mode 100644 tests/cc/py_extension/ext_limited.c create mode 100644 tests/cc/py_extension/ext_pkg_test.c create mode 100644 tests/cc/py_extension/ext_shared.c create mode 100644 tests/cc/py_extension/ext_source.c create mode 100644 tests/cc/py_extension/ext_static.c create mode 100644 tests/cc/py_extension/py_extension/BUILD.bazel create mode 100644 tests/cc/py_extension/py_extension/py_extension_tests.bzl create mode 100644 tests/cc/py_extension/py_extension_build_test.bzl create mode 100644 tests/cc/py_extension/py_extension_pkg_test.py create mode 100644 tests/cc/py_extension/py_extension_test.py create mode 100644 tests/cc/py_extension/py_limited_api/BUILD.bazel create mode 100644 tests/cc/py_extension/py_limited_api/py_limited_api_tests.bzl create mode 100644 tests/cc/py_extension/some_data.txt create mode 100644 tests/cc/py_extension/static_dep.c create mode 100644 tests/cc/py_extension/static_dep.h diff --git a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py index 1f976f3e79..661a427cc7 100644 --- a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py +++ b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py @@ -74,6 +74,9 @@ def parse_log(log_path): "no such package", "no such target", "exit code", + "exit-code", + "fatal:", + "fatal", "##[error]", "Would reformat:", "would be reformatted", diff --git a/news/3973.added.md b/news/3973.added.md new file mode 100644 index 0000000000..ef3796afca --- /dev/null +++ b/news/3973.added.md @@ -0,0 +1,6 @@ +(cc) Added experimental {obj}`py_extension` macro for creating C/C++ Python +extension modules +([#3283](https://github.com/bazel-contrib/rules_python/issues/3283)). +(cc) Added `abi_flags`, `abi_tag`, `libc`, `platform_machine`, `platform_tag`, +and `sys_platform` attributes and info fields to {obj}`py_cc_toolchain` / +{obj}`PyCcToolchainInfo`. diff --git a/python/cc/BUILD.bazel b/python/cc/BUILD.bazel index f17805d92f..2fa03dcb5a 100644 --- a/python/cc/BUILD.bazel +++ b/python/cc/BUILD.bazel @@ -79,3 +79,10 @@ alias( deprecation = "Use //python/cc:py_cc_toolchain_info instead", visibility = ["//visibility:public"], ) + +bzl_library( + name = "py_extension", + srcs = ["py_extension.bzl"], + visibility = ["//visibility:public"], + deps = ["//python/private/cc:py_extension_macro"], +) diff --git a/python/cc/py_extension.bzl b/python/cc/py_extension.bzl new file mode 100644 index 0000000000..2d5570b546 --- /dev/null +++ b/python/cc/py_extension.bzl @@ -0,0 +1,20 @@ +"""Rules for creating Python C extension modules. + +This module provides `py_extension` for building Python C extension modules +that can be imported by Python targets (`py_binary`, `py_test`, `py_library`). +It manages dynamic linking, symbol exports (`PyInit_*`), platform-specific link +flags, and target dependencies cleanly. + +See the [Python C API documentation](https://docs.python.org/3/c-api/index.html) +for information on writing C extension modules. + +:::{include} /_includes/experimental_api.md +::: +""" + +load( + "//python/private/cc:py_extension_macro.bzl", + _py_extension = "py_extension", +) + +py_extension = _py_extension diff --git a/python/features.bzl b/python/features.bzl index 33323b8b65..9339d7889c 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -102,6 +102,7 @@ _TARGETS = { "//python/cc:current_py_cc_headers_abi3": True, "//python/cc:py_cc_toolchain": True, "//python/cc:py_cc_toolchain_info": True, + "//python/cc:py_extension": True, "//python/config_settings:venv": True, "//python/entry_points:py_console_script_binary": True, "//python/local_toolchains:repos": True, diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index a5f4d2da3e..0b0dc97f66 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -506,6 +506,7 @@ bzl_library( deps = [ ":py_cc_toolchain_rule", ":util", + "//python/private/pypi:pep508_env", ], ) @@ -514,6 +515,7 @@ bzl_library( srcs = ["py_cc_toolchain_rule.bzl"], deps = [ ":common_labels", + ":flags", ":py_cc_toolchain_info", ":sentinel_impl", "@bazel_skylib//rules:common_settings", diff --git a/python/private/cc/BUILD.bazel b/python/private/cc/BUILD.bazel index 8f4fb468a4..a89c47c58f 100644 --- a/python/private/cc/BUILD.bazel +++ b/python/private/cc/BUILD.bazel @@ -1,3 +1,4 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load("@rules_cc//cc:cc_library.bzl", "cc_library") load("//python/private:visibility.bzl", "NOT_ACTUALLY_PUBLIC") @@ -18,3 +19,47 @@ cc_library( name = "empty", visibility = NOT_ACTUALLY_PUBLIC, ) + +# Private alias to avoid "duplicate dependency label" errors when users +# explicitly pass //python/cc:current_py_cc_headers or //python/cc:current_py_cc_libs +# in the deps attribute (including when deps is a select() expression). +alias( + name = "current_py_cc_headers_private_alias", + actual = "//python/cc:current_py_cc_headers", + visibility = NOT_ACTUALLY_PUBLIC, +) + +# Private alias to avoid "duplicate dependency label" errors when users +# explicitly pass //python/cc:current_py_cc_headers or //python/cc:current_py_cc_libs +# in the deps attribute (including when deps is a select() expression). +alias( + name = "current_py_cc_libs_private_alias", + actual = "//python/cc:current_py_cc_libs", + visibility = NOT_ACTUALLY_PUBLIC, +) + +bzl_library( + name = "py_extension_macro", + srcs = ["py_extension_macro.bzl"], + deps = [ + ":py_extension_rule", + "//python/private:util", + "@rules_cc//cc:core_rules", + ], +) + +bzl_library( + name = "py_extension_rule", + srcs = ["py_extension_rule.bzl"], + deps = [ + "//python/private:attr_builders", + "//python/private:attributes", + "//python/private:builders", + "//python/private:py_info", + "//python/private:reexports", + "//python/private:rule_builders", + "//python/private:toolchain_types", + "@bazel_skylib//lib:dicts", + "@rules_cc//cc/common", + ], +) diff --git a/python/private/cc/py_extension_macro.bzl b/python/private/cc/py_extension_macro.bzl new file mode 100644 index 0000000000..7845a09ea2 --- /dev/null +++ b/python/private/cc/py_extension_macro.bzl @@ -0,0 +1,191 @@ +"""Macro for creating Python extensions. + +:::{include} /_includes/experimental_api.md +::: +""" + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_shared_library.bzl", "cc_shared_library") +load("//python/private:common_labels.bzl", "labels") +load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs") +load(":py_extension_rule.bzl", "py_extension_wrapper") + +_EMPTY_CANONICAL_TARGET = str(Label("//python/private/cc:empty")) + +_PY_CC_HEADERS_ALIAS_BASE_TARGET = "//python/private/cc:current_py_cc_headers_private_alias" +_PY_CC_HEADERS_ALIAS_CANONICAL_TARGET = str(Label(_PY_CC_HEADERS_ALIAS_BASE_TARGET)) + +_PY_CC_LIBS_ALIAS_BASE_TARGET = "//python/private/cc:current_py_cc_libs_private_alias" +_PY_CC_LIBS_ALIAS_CANONICAL_TARGET = str(Label(_PY_CC_LIBS_ALIAS_BASE_TARGET)) + +_PY_CC_LIBS_ACTUAL_BASE_TARGET = "//python/cc:current_py_cc_libs" +_PY_CC_LIBS_ACTUAL_CANONICAL_TARGET = str(Label(_PY_CC_LIBS_ACTUAL_BASE_TARGET)) + +def py_extension( + name, + srcs = None, + hdrs = None, + copts = None, + defines = None, + local_defines = None, + includes = None, + linkopts = None, + deps = None, + dynamic_deps = None, + exports_filter = None, + user_link_flags = None, + additional_linker_inputs = None, + module_name = None, + py_limited_api = None, + **kwargs): + """Creates a Python extension module. + + :::{include} /_includes/experimental_api.md + ::: + + By default, extensions are created within their workspace package directory + (e.g., `pkg/ext.so`) and imported using standard Python package paths + (e.g., `from pkg import ext`). + + To customize import path behavior: + - `imports`: Pass `imports = ["..."]` to append custom search directories to + `sys.path` (matching {attr}`py_library.imports`). + - `module_name`: Pass `module_name = "custom_name"` to override the base + module filename. + + Args: + name: {type}`str` Target name. + srcs: {type}`list[Label | str] | None` C/C++ source files to compile + directly for this extension. + hdrs: {type}`list[Label | str] | None` Header files for `srcs`. + copts: {type}`list[str] | None` Compiler flags for `srcs`. + defines: {type}`list[str] | None` Preprocessor defines for `srcs`. + local_defines: {type}`list[str] | None` Preprocessor defines for `srcs` + passed to internal `cc_library`. + includes: {type}`list[str] | None` Header include search paths passed + to internal `cc_library`. + linkopts: {type}`list[str] | None` Link options passed to internal + `cc_library` created for `srcs`/`hdrs`. To pass linker flags to + `cc_shared_library`, use `user_link_flags`. + deps: {type}`list[Label | str] | None` `cc_library` targets to + statically link into the extension. + dynamic_deps: {type}`list[Label | str] | None` `cc_shared_library` + targets to dynamically link. + exports_filter: {type}`list[str] | None` Filter for exported symbols + passed to `cc_shared_library`. + user_link_flags: {type}`list[str] | None` Additional link flags passed + to `cc_shared_library`. To pass linker flags that apply to `srcs`, + use `linkopts`. + additional_linker_inputs: {type}`list[Label | str] | None` Additional + linker inputs passed to `cc_shared_library`. + module_name: {type}`str | None` Custom Python module name. If not set, + defaults to `name`. + py_limited_api: {type}`str | None` Python limited API version string + (e.g., `"3.8"`). + **kwargs: {type}`dict` Additional arguments passed to the underlying + wrapper rule. + """ + add_tag(kwargs, "@rules_python//python/cc:py_extension") + additional_linker_inputs = additional_linker_inputs or [] + copts = copts or [] + deps = deps or [] + user_link_flags = user_link_flags or [] + + csl_deps = [] + + copts = copts + select({ + # -fPIC (Position Independent Code) is required when compiling C/C++ sources into + # dynamic/shared libraries (.so/.dylib/.pyd) so code can be loaded at arbitrary addresses. + # MSVC on Windows does not support or require -fPIC. + labels.PLATFORMS_OS_WINDOWS: [], + "//conditions:default": ["-fPIC"], + }) + + # Private alias targets are appended to avoid "duplicate dependency label" errors + # if a user explicitly passes //python/cc:current_py_cc_headers or //python/cc:current_py_cc_libs + # in their deps attribute (including when deps is a select() expression). + deps = deps + [_PY_CC_HEADERS_ALIAS_CANONICAL_TARGET] + + # 1. If srcs or hdrs are specified, create an implicit cc_library for them + if srcs or hdrs: + impl_lib_name = "_" + name + "_impl" + impl_lib_kwargs = copy_propagating_kwargs(kwargs) + if includes: + impl_lib_kwargs["includes"] = includes + if linkopts: + impl_lib_kwargs["linkopts"] = linkopts + cc_library( + name = impl_lib_name, + srcs = srcs, + hdrs = hdrs, + copts = copts, + defines = defines, + local_defines = local_defines, + deps = deps, + visibility = ["//visibility:private"], + **impl_lib_kwargs + ) + csl_deps.append(":" + impl_lib_name) + + if not csl_deps: + csl_deps = deps + if not csl_deps: + # cc_shared_library requires a dependency, so use an empty library when none are given. + csl_deps = [_EMPTY_CANONICAL_TARGET] + + # 4. Create the underlying cc_shared_library + csl_name = "_" + name + "_csl" + csl_kwargs = copy_propagating_kwargs(kwargs) + + if exports_filter != None: + csl_kwargs["exports_filter"] = exports_filter + + user_link_flags = user_link_flags + select({ + # On macOS, Apple's ld64 linker requires '-undefined dynamic_lookup' so CPython + # C-API symbols (e.g. PyModule_Create) remain unresolved at link time and are + # dynamically resolved at runtime when CPython loads the shared library (.so). + labels.PLATFORMS_OS_MACOS: ["-undefined", "dynamic_lookup"], + "//conditions:default": [], + }) + + # Windows-specific CPython linking requirements: + # 1. Windows requires .lib files when linking, so they must be added to deps. + # 2. CPython import libraries (python3xx.lib) are declared with system_provided = True + # in cc_import, suppressing automatic propagation of the .lib file path to link.exe. + # We explicitly pass $(locations ...) to provide the path of the CPython import library to MSVC link.exe. + # 3. We pass current_py_cc_libs as an additional linker input to ensure the .lib file is available to the link action. + deps = deps + select({ + labels.PLATFORMS_OS_WINDOWS: [_PY_CC_LIBS_ALIAS_CANONICAL_TARGET], + "//conditions:default": [], + }) + user_link_flags = user_link_flags + select({ + labels.PLATFORMS_OS_WINDOWS: ["$(locations " + _PY_CC_LIBS_ACTUAL_BASE_TARGET + ")"], + "//conditions:default": [], + }) + additional_linker_inputs = additional_linker_inputs + select({ + labels.PLATFORMS_OS_WINDOWS: [_PY_CC_LIBS_ACTUAL_CANONICAL_TARGET], + "//conditions:default": [], + }) + + cc_shared_library( + name = csl_name, + deps = csl_deps, + additional_linker_inputs = additional_linker_inputs, + dynamic_deps = dynamic_deps, + user_link_flags = user_link_flags, + visibility = ["//visibility:private"], + **csl_kwargs + ) + + # 5. Filter out C++ specific compilation/linking attributes before invoking wrapper rule + for cc_attr in ("includes", "linkopts", "linkshared", "linkstatic", "features"): + kwargs.pop(cc_attr, None) + + # 6. Wrap with py_extension_wrapper for PEP 3149 naming & PyInfo + py_extension_wrapper( + name = name, + src = ":" + csl_name, + module_name = module_name, + py_limited_api = py_limited_api, + **kwargs + ) diff --git a/python/private/cc/py_extension_rule.bzl b/python/private/cc/py_extension_rule.bzl new file mode 100644 index 0000000000..1c5ee11a06 --- /dev/null +++ b/python/private/cc/py_extension_rule.bzl @@ -0,0 +1,135 @@ +"""Implementation of rules supporting py_extension. + +:::{include} /_includes/experimental_api.md +::: +""" + +load("@bazel_skylib//lib:dicts.bzl", "dicts") +load("@rules_cc//cc/common:cc_shared_library_info.bzl", "CcSharedLibraryInfo") +load("//python/private:attr_builders.bzl", "attrb") +load("//python/private:attributes.bzl", "COMMON_ATTRS", "IMPORTS_ATTRS", "WINDOWS_CONSTRAINTS_ATTRS") +load("//python/private:builders.bzl", "builders") +load("//python/private:common.bzl", "get_imports", "is_windows_platform") +load("//python/private:py_info.bzl", "PyInfo", "PyInfoBuilder") +load("//python/private:rule_builders.bzl", "ruleb") +load("//python/private:toolchain_types.bzl", "CC_TOOLCHAIN_TYPE", "PY_CC_TOOLCHAIN_TYPE") + +def _py_extension_wrapper_impl(ctx): + module_name = ctx.attr.module_name or ctx.label.name + + ext = _get_extension(ctx) + use_py_limited_api = bool(ctx.attr.py_limited_api) + if use_py_limited_api: + output_filename = "{module_name}.abi3.{ext}".format( + module_name = module_name, + ext = ext, + ) + else: + py_toolchain = ctx.toolchains[PY_CC_TOOLCHAIN_TYPE] + py_cc_toolchain = py_toolchain.py_cc_toolchain + platform_tag = _get_platform(ctx) + output_filename = "{module_name}.{abi_tag}-{platform}.{ext}".format( + module_name = module_name, + abi_tag = py_cc_toolchain.abi_tag, + platform = platform_tag, + ext = ext, + ) + + py_dso = ctx.actions.declare_file(output_filename) + + # Symlink the cc_shared_library output to the PEP 3149 / abi3 filename + csl_target = ctx.attr.src + csl_file = csl_target[DefaultInfo].files.to_list()[0] + ctx.actions.symlink( + output = py_dso, + target_file = csl_file, + ) + + runfiles_builder = builders.RunfilesBuilder() + runfiles_builder.add(py_dso) + runfiles_builder.add(ctx.files.data) + runfiles_builder.add_targets(ctx.attr.data) + runfiles_builder.add(csl_target[DefaultInfo].default_runfiles) + runfiles = runfiles_builder.build(ctx) + + py_info_builder = PyInfoBuilder.new() + py_info_builder.transitive_sources.add(py_dso) + py_info_builder.imports.add(get_imports(ctx)) + + return [ + DefaultInfo( + files = depset([py_dso]), + runfiles = runfiles, + ), + py_info_builder.build(), + ] + +PY_EXTENSION_WRAPPER_ATTRS = dicts.add( + COMMON_ATTRS, + IMPORTS_ATTRS, + WINDOWS_CONSTRAINTS_ATTRS, + { + "module_name": lambda: attrb.String( + doc = "Custom Python module name. If not set, defaults to name.", + ), + "py_limited_api": lambda: attrb.String( + default = "", + doc = "Python limited API version string (e.g., '3.8').", + ), + "src": lambda: attrb.Label( + mandatory = True, + providers = [CcSharedLibraryInfo], + doc = "The cc_shared_library target to wrap.", + ), + }, +) + +def create_py_extension_wrapper_rule_builder(): + """Create a rule builder for the private internal wrapper rule.""" + return ruleb.Rule( + doc = "Private internal helper rule for py_extension targets.", + implementation = _py_extension_wrapper_impl, + attrs = PY_EXTENSION_WRAPPER_ATTRS, + provides = [PyInfo], + toolchains = [ + ruleb.ToolchainType(PY_CC_TOOLCHAIN_TYPE), + ruleb.ToolchainType(CC_TOOLCHAIN_TYPE), + ], + fragments = ["cpp"], + ) + +py_extension_wrapper = create_py_extension_wrapper_rule_builder().build() + +def _get_extension(ctx): + """Derives the appropriate file extension for C extensions from target platform. + + Note: On macOS, CPython C extensions use .so (PEP 3149), not .dylib. + Windows uses .pyd. + + Args: + ctx: The rule context. + + Returns: + The extension, e.g. "so" or "pyd" + """ + return "pyd" if is_windows_platform(ctx) else "so" + +def _get_platform(ctx): + """Derives the PEP 3149 platform tag from the active Python C++ toolchain. + + Args: + ctx: The rule context. + + Returns: + The platform tag, e.g. "x86_64-linux-gnu" or "win_amd64" + """ + py_toolchain = ctx.toolchains[PY_CC_TOOLCHAIN_TYPE] + py_cc_toolchain = py_toolchain.py_cc_toolchain + if not py_cc_toolchain.platform_tag: + fail( + ("ERROR: Unable to resolve platform_tag from Python C++ toolchain for {self}. " + + "Please ensure the active py_cc_toolchain provides a non-empty platform_tag.").format( + self = ctx.label, + ), + ) + return py_cc_toolchain.platform_tag diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl index 6cc42ecf8d..db4a00ba0a 100644 --- a/python/private/common_labels.bzl +++ b/python/private/common_labels.bzl @@ -18,6 +18,7 @@ labels = struct( NONE = str(Label("//python:none")), PIP_ENV_MARKER_CONFIG = str(Label("//python/config_settings:pip_env_marker_config")), PIP_WHL_OSX_VERSION = str(Label("//python/config_settings:pip_whl_osx_version")), + PLATFORMS_OS_MACOS = str(Label("@platforms//os:macos")), PLATFORMS_OS_WINDOWS = str(Label("@platforms//os:windows")), PRECOMPILE = str(Label("//python/config_settings:precompile")), PRECOMPILE_SOURCE_RETENTION = str(Label("//python/config_settings:precompile_source_retention")), diff --git a/python/private/current_py_cc_libs.bzl b/python/private/current_py_cc_libs.bzl index ca68346bcb..58ab4b1bd8 100644 --- a/python/private/current_py_cc_libs.bzl +++ b/python/private/current_py_cc_libs.bzl @@ -18,7 +18,39 @@ load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") def _current_py_cc_libs_impl(ctx): py_cc_toolchain = ctx.toolchains["//python/cc:toolchain_type"].py_cc_toolchain - return py_cc_toolchain.libs.providers_map.values() + providers = [p for p in py_cc_toolchain.libs.providers_map.values() if not hasattr(p, "data_runfiles")] + default_runfiles = None + data_runfiles = None + files = [] + for p in py_cc_toolchain.libs.providers_map.values(): + if hasattr(p, "data_runfiles"): + default_runfiles = p.default_runfiles + data_runfiles = p.data_runfiles + + cc_infos = [p for p in py_cc_toolchain.libs.providers_map.values() if hasattr(p, "linking_context")] + if cc_infos: + cc_info = cc_infos[0] + for input in cc_info.linking_context.linker_inputs.to_list(): + for lib in input.libraries: + if lib.static_library: + files.append(lib.static_library) + if lib.interface_library: + files.append(lib.interface_library) + elif lib.dynamic_library: + files.append(lib.dynamic_library) + + # On Windows MSVC, user_link_flags passes $(locations @rules_python//python/cc:current_py_cc_libs) + # to link.exe. MSVC link.exe accepts import libraries (.lib) but fails with + # LNK1107 if passed raw DLL binaries (.dll). We filter out .dll files so + # DefaultInfo.files only contains linkable library files (.lib / .a). + link_files = [f for f in files if not f.path.endswith(".dll")] + + providers.append(DefaultInfo( + files = depset(link_files), + default_runfiles = default_runfiles, + data_runfiles = data_runfiles, + )) + return providers current_py_cc_libs = rule( implementation = _current_py_cc_libs_impl, diff --git a/python/private/flags.bzl b/python/private/flags.bzl index 32769e4084..042e4e9838 100644 --- a/python/private/flags.bzl +++ b/python/private/flags.bzl @@ -247,6 +247,9 @@ FreeThreadedFlag = enum( NO = "no", ) +def _libc_flag_get_value(ctx): + return ctx.attr._py_linux_libc_flag[BuildSettingInfo].value + # Determines which libc flavor is preferred when selecting the toolchain and # linux whl distributions. # @@ -256,4 +259,5 @@ LibcFlag = FlagEnum( GLIBC = "glibc", # Prefer musl wheels (e.g. musllinux_2_17_x86_64) MUSL = "musl", + get_value = _libc_flag_get_value, ) diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl index 34d4acf305..fcf407516c 100644 --- a/python/private/py_cc_toolchain_info.bzl +++ b/python/private/py_cc_toolchain_info.bzl @@ -17,6 +17,16 @@ PyCcToolchainInfo = provider( doc = "C/C++ information about the Python runtime.", fields = { + "abi_flags": """\ +:type: str + +The runtime's ABI flags, i.e. `sys.abiflags` (e.g. 't' for free-threaded builds). +""", + "abi_tag": """\ +:type: str + +The ABI tag for extension modules, e.g. 'cpython-311' or 'cpython-313t'. +""", "headers": """\ :type: struct @@ -91,11 +101,29 @@ If available, information about C libraries, struct with fields: considered private and should be forward along as-is (this better allows e.g. `:current_py_cc_headers` to act as the underlying headers target it represents). +""", + "platform_machine": """ +:type: str + +The [PEP 508](https://peps.python.org/pep-0508/) `platform_machine` marker +value for the target architecture, e.g. 'x86_64', 'aarch64'. +""", + "platform_tag": """\ +:type: str | None + +The PEP 3149 / PEP 425 platform tag for extension modules, e.g. +'x86_64-linux-gnu', 'darwin', or 'win_amd64'. """, "python_version": """ :type: str The Python Major.Minor version. +""", + "sys_platform": """ +:type: str + +The [PEP 508](https://peps.python.org/pep-0508/) `sys_platform` marker value +for the target OS, e.g. 'linux', 'darwin', 'win32'. """, }, ) diff --git a/python/private/py_cc_toolchain_macro.bzl b/python/private/py_cc_toolchain_macro.bzl index 416caac2ab..88223c747c 100644 --- a/python/private/py_cc_toolchain_macro.bzl +++ b/python/private/py_cc_toolchain_macro.bzl @@ -14,6 +14,7 @@ """Fronting macro for the py_cc_toolchain rule.""" +load("//python/private/pypi:pep508_env.bzl", "platform_machine_select_map", "sys_platform_select_map") load(":py_cc_toolchain_rule.bzl", _py_cc_toolchain = "py_cc_toolchain") load(":util.bzl", "add_tag") @@ -30,4 +31,10 @@ def py_cc_toolchain(**kwargs): # This tag is added to easily identify usages through other macros. add_tag(kwargs, "@rules_python//python:py_cc_toolchain") + + if kwargs.get("sys_platform") == None: + kwargs["sys_platform"] = select(sys_platform_select_map) + if kwargs.get("platform_machine") == None: + kwargs["platform_machine"] = select(platform_machine_select_map) + _py_cc_toolchain(**kwargs) diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl index 315de2d3f2..194ce7be78 100644 --- a/python/private/py_cc_toolchain_rule.bzl +++ b/python/private/py_cc_toolchain_rule.bzl @@ -21,9 +21,46 @@ https://github.com/bazel-contrib/rules_python/issues/824 is considered done. load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") load(":common_labels.bzl", "labels") +load(":flags.bzl", "FreeThreadedFlag", "LibcFlag") load(":py_cc_toolchain_info.bzl", "PyCcToolchainInfo") load(":sentinel_impl.bzl", "SentinelInfo") +def _get_platform_tag(sys_platform, platform_machine, libc): + """Derives the PEP 3149 platform tag string. + + Note that these are platform tags for C extension filenames, not + PEP 425 tags for wheels. + + Linux platform tags are standardized here: + - https://peps.python.org/pep-3149/ + Windows platform tags, such as they are, are defined in this issue and + commit (treated as a de facto standard): + - https://github.com/python/cpython/issues/67169 + - https://github.com/python/cpython/commit/03a144bb6ac3d7631a3bdb895e2a1f2d021fb08b + Apple platform tag is always just "darwin", discussed briefly here: + - https://github.com/python/cpython/commit/3b8124884c3655b4cf2629d741b18c1a38181805 + + Args: + sys_platform: Target PEP 508 OS marker, e.g. "win32", "darwin", "linux" + platform_machine: Target PEP 508 CPU marker, e.g. "x86_64", "aarch64", "x86_32" + libc: Target C library variant, e.g. "glibc", "musl" + + Returns: + The platform tag, e.g. "x86_64-linux-gnu", "darwin", or "win_amd64" + """ + if sys_platform == "win32": + if platform_machine in ("x86_64", "amd64"): + return "win_amd64" + if platform_machine in ("aarch64", "arm64"): + return "win_arm64" + return "win32" + if sys_platform == "darwin": + return "darwin" + + machine_val = platform_machine if platform_machine else "x86_64" + libc_val = "gnu" if libc == LibcFlag.GLIBC else libc + return "{}-linux-{}".format(machine_val, libc_val) + def _py_cc_toolchain_impl(ctx): if ctx.attr.libs: libs = struct( @@ -45,7 +82,36 @@ def _py_cc_toolchain_impl(ctx): else: headers_abi3 = None + abi_flags = ctx.attr.abi_flags + if abi_flags == "": + abi_flags = "" + if ctx.attr._py_freethreaded_flag[BuildSettingInfo].value == FreeThreadedFlag.YES: + abi_flags += "t" + + abi_tag = ctx.attr.abi_tag + if not abi_tag: + # Derive default ABI tag: + # On POSIX: cpython-XX[t] (PEP 3149 / PEP 703) + # On Windows: cpXX[t] (PEP 3149 / PEP 703, CPython issue & commit): + # - https://peps.python.org/pep-3149/ + # - https://peps.python.org/pep-0703/ + # - https://github.com/python/cpython/issues/67169 + # - https://github.com/python/cpython/commit/03a144bb6ac3d7631a3bdb895e2a1f2d021fb08b + version_parts = ctx.attr.python_version.split(".") + prefix = "cp" if ctx.attr.sys_platform == "win32" else "cpython-" + abi_tag = "{}{}{}{}".format(prefix, version_parts[0], version_parts[1], abi_flags) + + libc = ctx.attr.libc or LibcFlag.get_value(ctx) + + platform_tag = _get_platform_tag( + sys_platform = ctx.attr.sys_platform, + platform_machine = ctx.attr.platform_machine, + libc = libc, + ) + py_cc_toolchain = PyCcToolchainInfo( + abi_flags = abi_flags, + abi_tag = abi_tag, headers = struct( providers_map = { "CcInfo": ctx.attr.headers[CcInfo], @@ -54,7 +120,10 @@ def _py_cc_toolchain_impl(ctx): ), headers_abi3 = headers_abi3, libs = libs, + platform_machine = ctx.attr.platform_machine, + platform_tag = platform_tag, python_version = ctx.attr.python_version, + sys_platform = ctx.attr.sys_platform, ) extra_kwargs = {} if ctx.attr._visible_for_testing[BuildSettingInfo].value: @@ -67,6 +136,20 @@ def _py_cc_toolchain_impl(ctx): py_cc_toolchain = rule( implementation = _py_cc_toolchain_impl, attrs = { + "abi_flags": attr.string( + default = "", + doc = """ +The runtime's ABI flags, i.e. `sys.abiflags`. + +If not set, or set to ``, the ABI flags are automatically derived +from `--//python/config_settings:py_freethreaded` (e.g., `'t'` when +free-threaded is enabled, or `''` otherwise). +""", + ), + "abi_tag": attr.string( + doc = "The ABI tag for extension modules, e.g. 'cpython-311'", + default = "", + ), "headers": attr.label( doc = ("Target that provides the Python headers. Typically this " + "is a cc_library target."), @@ -87,15 +170,37 @@ attribute is available or not. default = "//python:none", providers = [[SentinelInfo], [CcInfo]], ), + "libc": attr.string( + doc = "Target C library variant, e.g. 'glibc', 'musl'", + default = "", + ), "libs": attr.label( doc = ("Target that provides the Python runtime libraries for linking. " + "Typically this is a cc_library target of `.so` files."), providers = [CcInfo], ), + "platform_machine": attr.string( + doc = """ +Target architecture as a PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarch64', 'x86_32'. +""", + default = "", + ), "python_version": attr.string( doc = "The Major.minor Python version, e.g. 3.11", mandatory = True, ), + "sys_platform": attr.string( + doc = """ +Target OS as a PEP 508 `sys_platform` marker, e.g. 'linux', 'darwin', 'win32'. +""", + default = "", + ), + "_py_freethreaded_flag": attr.label( + default = labels.PY_FREETHREADED, + ), + "_py_linux_libc_flag": attr.label( + default = labels.PY_LINUX_LIBC, + ), "_visible_for_testing": attr.label( default = labels.VISIBLE_FOR_TESTING, ), diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 242b09344e..1ee6fd7c99 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -68,7 +68,7 @@ load(":py_interpreter_program.bzl", "PyInterpreterProgramInfo") load(":py_runtime_info.bzl", "DEFAULT_STUB_SHEBANG") load(":reexports.bzl", "BuiltinPyInfo", "BuiltinPyRuntimeInfo") load(":rule_builders.bzl", "ruleb") -load(":toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE", "LAUNCHER_MAKER_TOOLCHAIN_TYPE", TOOLCHAIN_TYPE = "TARGET_TOOLCHAIN_TYPE") +load(":toolchain_types.bzl", "CC_TOOLCHAIN_TYPE", "EXEC_TOOLS_TOOLCHAIN_TYPE", "LAUNCHER_MAKER_TOOLCHAIN_TYPE", TOOLCHAIN_TYPE = "TARGET_TOOLCHAIN_TYPE") load(":transition_labels.bzl", "TRANSITION_LABELS") load(":venv_runfiles.bzl", "create_venv_app_files") @@ -2204,7 +2204,7 @@ def create_executable_rule_builder(implementation, **kwargs): toolchains = [ ruleb.ToolchainType(TOOLCHAIN_TYPE), ruleb.ToolchainType(EXEC_TOOLS_TOOLCHAIN_TYPE, mandatory = False), - ruleb.ToolchainType("@bazel_tools//tools/cpp:toolchain_type", mandatory = False), + ruleb.ToolchainType(CC_TOOLCHAIN_TYPE, mandatory = False), ] + ([ruleb.ToolchainType(LAUNCHER_MAKER_TOOLCHAIN_TYPE)] if rp_config.bazel_9_or_later else []), cfg = dict( implementation = _transition_executable_impl, diff --git a/python/private/toolchain_types.bzl b/python/private/toolchain_types.bzl index 5b5cce90ee..a3a52409a6 100644 --- a/python/private/toolchain_types.bzl +++ b/python/private/toolchain_types.bzl @@ -22,3 +22,4 @@ TARGET_TOOLCHAIN_TYPE = Label("//python:toolchain_type") EXEC_TOOLS_TOOLCHAIN_TYPE = Label("//python:exec_tools_toolchain_type") PY_CC_TOOLCHAIN_TYPE = Label("//python/cc:toolchain_type") LAUNCHER_MAKER_TOOLCHAIN_TYPE = Label("@bazel_tools//tools/launcher:launcher_maker_toolchain_type") +CC_TOOLCHAIN_TYPE = Label("@bazel_tools//tools/cpp:toolchain_type") diff --git a/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl b/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl index 975f0d56b5..5b57810d0d 100644 --- a/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl +++ b/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl @@ -47,6 +47,7 @@ def _test_py_cc_toolchain_impl(env, target): meta = env.expect.meta.derive(expr = "py_cc_toolchain_info"), ) toolchain.python_version().equals("3.999") + toolchain.abi_flags().equals("") # ===== Verify headers info ===== headers_providers = toolchain.headers().providers_map() @@ -118,8 +119,38 @@ def _test_py_cc_toolchain_impl(env, target): matching.str_matches("/libpython3."), ) + # ===== Verify PEP 508 platform markers ===== + toolchain.sys_platform().equals("linux") + toolchain.platform_machine().equals("x86_64") + toolchain.platform_tag().equals("x86_64-linux-gnu") + _tests.append(_test_py_cc_toolchain) +def _test_custom_pep508_markers(name): + py_cc_toolchain( + name = name + "_subject", + headers = "//tests/support/cc_toolchains:py_headers", + platform_machine = "arm64", + python_version = "3.11", + sys_platform = "darwin", + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_custom_pep508_markers_impl, + ) + +def _test_custom_pep508_markers_impl(env, target): + toolchain = PyCcToolchainInfoSubject.new( + target[platform_common.ToolchainInfo].py_cc_toolchain, + meta = env.expect.meta.derive(expr = "py_cc_toolchain_info"), + ) + toolchain.sys_platform().equals("darwin") + toolchain.platform_machine().equals("arm64") + toolchain.platform_tag().equals("darwin") + +_tests.append(_test_custom_pep508_markers) + def _test_libs_optional(name): py_cc_toolchain( name = name + "_subject", diff --git a/tests/cc/py_extension/BUILD.bazel b/tests/cc/py_extension/BUILD.bazel new file mode 100644 index 0000000000..e6edde36e6 --- /dev/null +++ b/tests/cc/py_extension/BUILD.bazel @@ -0,0 +1,194 @@ +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_shared_library.bzl", "cc_shared_library") +load("//python:py_test.bzl", "py_test") + +# buildifier: disable=bzl-visibility +load("//python/cc:py_extension.bzl", "py_extension") +load(":py_extension_build_test.bzl", "py_extension_build_test") + +package( + default_visibility = ["//tests/cc/py_extension:__subpackages__"], +) + +licenses(["notice"]) + +##### + +py_extension_build_test( + # An extension defined solely by source files, with no deps + name = "ext_source", + srcs = ["ext_source.c"], +) + +##### + +py_extension_build_test( + # A python extension that explicitly includes current_py_cc_headers in deps. + # Verifies that explicit current_py_cc_headers does not cause a duplicate label error. + name = "ext_explicit_headers", + srcs = ["ext_source.c"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], +) + +##### + +py_extension_build_test( + # A python extension that includes current_py_cc_headers inside a select() in deps. + # Verifies that select() in deps works cleanly without duplicate label or type error. + name = "ext_explicit_headers_select", + srcs = ["ext_source.c"], + deps = select({ + "//conditions:default": ["@rules_python//python/cc:current_py_cc_headers"], + }), +) + +##### + +py_extension_build_test( + # A python extension that gets its code from a statically-linked library + name = "ext_static", + deps = [":static_dep"], +) + +cc_library( + name = "static_dep", + srcs = ["static_dep.c"], + hdrs = ["static_dep.h"], +) + +##### + +py_extension_build_test( + # An extension that also depends on a data file + name = "ext_with_data", + data = ["some_data.txt"], + deps = [":static_dep"], +) + +##### + +py_extension_build_test( + # A python extension that dynamically links to another shared library + name = "ext_shared", + dynamic_deps = [ + ":add_one_shared", + ], + imports = ["."], + deps = [ + # + ":ext_shared_impl", + ], +) + +cc_library( + name = "ext_shared_impl", + srcs = ["ext_shared.c"], + copts = [ + # Gemini says PIC is needed + "-fPIC", + "-fvisibility=hidden", + ], + deps = [ + #":add_one_headers", + # todo: if we put this here, we statically link add_one into the + # extension. + ":add_one_impl", + "@rules_python//python/cc:current_py_cc_headers", + ], +) + +cc_shared_library( + name = "add_one_shared", + user_link_flags = select({ + "@platforms//os:windows": ["/EXPORT:add_one"], + "//conditions:default": [], + }), + deps = [":add_one_impl"], +) + +cc_library( + name = "add_one_headers", + hdrs = ["add_one.h"], +) + +cc_library( + name = "add_one_impl", + srcs = ["add_one.c"], + deps = [ + ":add_one_headers", + ":add_one_helper", + ], +) + +cc_library( + name = "add_one_helper", + srcs = ["add_one_helper.c"], + hdrs = ["add_one_helper.h"], +) + +##### + +py_extension_build_test( + # An extension that uses the Python limited API + name = "ext_limited", + py_limited_api = "3.8", + deps = [":ext_limited_impl"], +) + +cc_library( + name = "ext_limited_impl", + srcs = ["ext_limited.c"], + copts = [ + "-fPIC", + "-fvisibility=hidden", + ], + defines = ["Py_LIMITED_API=0x3080000"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], +) + +##### + +py_extension_build_test( + # An extension with its PyInit_* function in a static dependency + name = "ext_init_in_dep", + deps = [":ext_init_in_dep_impl"], +) + +cc_library( + name = "ext_init_in_dep_impl", + srcs = ["ext_init_in_dep.c"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], +) + +##### + +py_test( + name = "py_extension_test", + srcs = ["py_extension_test.py"], + deps = [ + ":ext_shared", + "@dev_pip//pyelftools", + "@rules_python//python/runfiles", + ], +) + +##### + +py_extension( + name = "ext_pkg_test", + srcs = ["ext_pkg_test.c"], +) + +py_test( + name = "py_extension_pkg_test", + srcs = ["py_extension_pkg_test.py"], + deps = [ + ":ext_pkg_test", + ], +) diff --git a/tests/cc/py_extension/add_one.c b/tests/cc/py_extension/add_one.c new file mode 100644 index 0000000000..7da8f6796e --- /dev/null +++ b/tests/cc/py_extension/add_one.c @@ -0,0 +1,7 @@ + +#include "add_one_helper.h" + +int add_one(int x) { + x = add_one_helper(x); + return x + 1; +} diff --git a/tests/cc/py_extension/add_one.h b/tests/cc/py_extension/add_one.h new file mode 100644 index 0000000000..eda0abf7e5 --- /dev/null +++ b/tests/cc/py_extension/add_one.h @@ -0,0 +1,6 @@ +#ifndef TESTS_CC_PY_EXTENSION_DYN_DEP_A_H_ +#define TESTS_CC_PY_EXTENSION_DYN_DEP_A_H_ + +int add_one(int x); + +#endif // TESTS_CC_PY_EXTENSION_DYN_DEP_A_H_ diff --git a/tests/cc/py_extension/add_one_helper.c b/tests/cc/py_extension/add_one_helper.c new file mode 100644 index 0000000000..21d19a5823 --- /dev/null +++ b/tests/cc/py_extension/add_one_helper.c @@ -0,0 +1,7 @@ + + +#include "add_one_helper.h" + +int add_one_helper(int i) { + return i + 1; +} diff --git a/tests/cc/py_extension/add_one_helper.h b/tests/cc/py_extension/add_one_helper.h new file mode 100644 index 0000000000..5524d3077f --- /dev/null +++ b/tests/cc/py_extension/add_one_helper.h @@ -0,0 +1,2 @@ + +int add_one_helper(int i); diff --git a/tests/cc/py_extension/dependency_graph/BUILD.bazel b/tests/cc/py_extension/dependency_graph/BUILD.bazel new file mode 100644 index 0000000000..47bb5062a0 --- /dev/null +++ b/tests/cc/py_extension/dependency_graph/BUILD.bazel @@ -0,0 +1,12 @@ +load(":dependency_graph_tests.bzl", "dependency_graph_test_suite") + +package( + default_testonly = True, + default_visibility = ["//visibility:private"], +) + +licenses(["notice"]) + +dependency_graph_test_suite( + name = "dependency_graph_tests", +) diff --git a/tests/cc/py_extension/dependency_graph/dependency_graph_tests.bzl b/tests/cc/py_extension/dependency_graph/dependency_graph_tests.bzl new file mode 100644 index 0000000000..269c29eacb --- /dev/null +++ b/tests/cc/py_extension/dependency_graph/dependency_graph_tests.bzl @@ -0,0 +1,236 @@ +# Copyright 2025 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. + +"""Parity tests comparing cc_shared_library and py_extension behavior.""" + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_shared_library.bzl", "cc_shared_library") +load("@rules_cc//cc/common:cc_shared_library_info.bzl", "CcSharedLibraryInfo") +load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite") +load("@rules_testing//lib:util.bzl", "util") + +_tests = [] + +# For tests 1 and 2 +def _create_dynamic_deps_helpers(name): + util.helper_target( + cc_library, + name = name + "_libC", + srcs = ["test_lib_c.c"], + hdrs = ["test_symbols.h"], + copts = ["-fPIC"], + ) + util.helper_target( + cc_shared_library, + name = name + "_cslC", + deps = [":" + name + "_libC"], + ) + util.helper_target( + cc_library, + name = name + "_libB", + srcs = ["test_lib_b.c"], + hdrs = ["test_symbols.h"], + copts = ["-fPIC"], + deps = [":" + name + "_libC"], + ) + util.helper_target( + cc_shared_library, + name = name + "_cslB", + deps = [":" + name + "_libB"], + dynamic_deps = [":" + name + "_cslC"], + ) + util.helper_target( + cc_library, + name = name + "_libA", + srcs = ["test_lib_a.c"], + hdrs = ["test_symbols.h"], + copts = ["-fPIC"], + deps = [":" + name + "_libB", ":" + name + "_libC"], + ) + +# Test 1: CSL A -> CSL B -> CSL C (Dynamic deps) +def _test_csl_dynamic_deps_top(name): + _create_dynamic_deps_helpers(name) + util.helper_target( + cc_shared_library, + name = name + "_cslA", + deps = [":" + name + "_libA"], + dynamic_deps = [":" + name + "_cslB", ":" + name + "_cslC"], + ) + analysis_test( + name = name, + target = name + "_cslA", + impl = _csl_dynamic_deps_test_impl, + ) + +_tests.append(_test_csl_dynamic_deps_top) + +def _csl_dynamic_deps_test_impl(env, target): + env.expect.that_target(target).has_provider(CcSharedLibraryInfo) + csl_info = target[CcSharedLibraryInfo] + + # Derive labels + test_name = target.label.name[:-5] # remove "_cslA" + lib_a_label = target.label.same_package_label(test_name + "_libA") + lib_b_label = target.label.same_package_label(test_name + "_libB") + lib_c_label = target.label.same_package_label(test_name + "_libC") + + env.expect.that_collection([str(e) for e in csl_info.exports]).contains_exactly([str(lib_a_label)]) + static_libs = [str(lbl) for lbl in csl_info.link_once_static_libs] + env.expect.that_collection(static_libs).contains(str(lib_a_label)) + env.expect.that_collection(static_libs).contains_none_of([str(lib_b_label), str(lib_c_label)]) + +# Test 2: py_extension A -> CSL B -> CSL C (Dynamic deps) +def _test_pyext_dynamic_deps_cslB(name): + _create_dynamic_deps_helpers(name) + analysis_test( + name = name, + target = name + "_cslB", + impl = _cslB_deps_test_impl, + ) + +_tests.append(_test_pyext_dynamic_deps_cslB) + +def _test_pyext_dynamic_deps_cslC(name): + _create_dynamic_deps_helpers(name) + analysis_test( + name = name, + target = name + "_cslC", + impl = _cslC_deps_test_impl, + ) + +_tests.append(_test_pyext_dynamic_deps_cslC) + +def _cslC_deps_test_impl(env, target): + env.expect.that_target(target).has_provider(CcSharedLibraryInfo) + csl_info = target[CcSharedLibraryInfo] + + # Derive labels + test_name = target.label.name[:-5] # remove "_cslC" + lib_c_label = target.label.same_package_label(test_name + "_libC") + + env.expect.that_collection([str(e) for e in csl_info.exports]).contains_exactly([str(lib_c_label)]) + env.expect.that_collection([str(lbl) for lbl in csl_info.link_once_static_libs]).contains_exactly([str(lib_c_label)]) + +def _cslB_deps_test_impl(env, target): + env.expect.that_target(target).has_provider(CcSharedLibraryInfo) + csl_info = target[CcSharedLibraryInfo] + + # Derive labels + test_name = target.label.name[:-5] # remove "_cslB" + lib_b_label = target.label.same_package_label(test_name + "_libB") + lib_c_label = target.label.same_package_label(test_name + "_libC") + csl_c_label = target.label.same_package_label(test_name + "_cslC") + + env.expect.that_collection([str(e) for e in csl_info.exports]).contains_exactly([str(lib_b_label)]) + static_libs = [str(lbl) for lbl in csl_info.link_once_static_libs] + env.expect.that_collection(static_libs).contains(str(lib_b_label)) + env.expect.that_collection(static_libs).contains_none_of([str(lib_c_label)]) + + dynamic_deps = [str(d.linker_input.owner) for d in csl_info.dynamic_deps.to_list()] + env.expect.that_collection(dynamic_deps).contains(str(csl_c_label)) + +# For tests 3 and 4 +def _create_static_sharing_helpers(name): + util.helper_target( + cc_library, + name = name + "_libC", + srcs = ["test_lib_c.c"], + hdrs = ["test_symbols.h"], + copts = ["-fPIC"], + ) + util.helper_target( + cc_library, + name = name + "_libB", + srcs = ["test_lib_b.c"], + hdrs = ["test_symbols.h"], + copts = ["-fPIC"], + deps = [":" + name + "_libC"], + ) + util.helper_target( + cc_shared_library, + name = name + "_cslB", + deps = [":" + name + "_libB", ":" + name + "_libC"], + ) + util.helper_target( + cc_library, + name = name + "_libA", + srcs = ["test_lib_a.c"], + hdrs = ["test_symbols.h"], + copts = ["-fPIC"], + deps = [":" + name + "_libB", ":" + name + "_libC"], + ) + +# Test 3: CSL A -> CSL B, CL C (Static sharing) +def _test_csl_static_sharing_top(name): + _create_static_sharing_helpers(name) + util.helper_target( + cc_shared_library, + name = name + "_cslA", + deps = [":" + name + "_libA"], + dynamic_deps = [":" + name + "_cslB"], + ) + analysis_test( + name = name, + target = name + "_cslA", + impl = _csl_static_sharing_test_impl, + ) + +_tests.append(_test_csl_static_sharing_top) + +def _csl_static_sharing_test_impl(env, target): + env.expect.that_target(target).has_provider(CcSharedLibraryInfo) + csl_info = target[CcSharedLibraryInfo] + + # Derive labels + test_name = target.label.name[:-5] # remove "_cslA" + lib_a_label = target.label.same_package_label(test_name + "_libA") + lib_b_label = target.label.same_package_label(test_name + "_libB") + lib_c_label = target.label.same_package_label(test_name + "_libC") + + env.expect.that_collection([str(e) for e in csl_info.exports]).contains_exactly([str(lib_a_label)]) + static_libs = [str(lbl) for lbl in csl_info.link_once_static_libs] + env.expect.that_collection(static_libs).contains(str(lib_a_label)) + env.expect.that_collection(static_libs).contains_none_of([str(lib_b_label), str(lib_c_label)]) + +# Test 4: Same as 3, but A is py_extension + +def _test_pyext_static_sharing_cslB(name): + _create_static_sharing_helpers(name) + analysis_test( + name = name, + target = name + "_cslB", + impl = _cslB_static_sharing_test_impl, + ) + +_tests.append(_test_pyext_static_sharing_cslB) + +def _cslB_static_sharing_test_impl(env, target): + env.expect.that_target(target).has_provider(CcSharedLibraryInfo) + csl_info = target[CcSharedLibraryInfo] + + # Derive labels + test_name = target.label.name[:-5] # remove "_cslB" + lib_b_label = target.label.same_package_label(test_name + "_libB") + lib_c_label = target.label.same_package_label(test_name + "_libC") + + env.expect.that_collection([str(e) for e in csl_info.exports]).contains_exactly([str(lib_b_label), str(lib_c_label)]) + static_libs = [str(lbl) for lbl in csl_info.link_once_static_libs] + env.expect.that_collection(static_libs).contains_exactly([str(lib_b_label), str(lib_c_label)]) + +def dependency_graph_test_suite(name): + test_suite( + name = name, + tests = _tests, + ) diff --git a/tests/cc/py_extension/dependency_graph/test_lib_a.c b/tests/cc/py_extension/dependency_graph/test_lib_a.c new file mode 100644 index 0000000000..19e2f489bf --- /dev/null +++ b/tests/cc/py_extension/dependency_graph/test_lib_a.c @@ -0,0 +1,6 @@ +#include "test_symbols.h" + +void fnA() { + fnB(); + fnC(); +} diff --git a/tests/cc/py_extension/dependency_graph/test_lib_b.c b/tests/cc/py_extension/dependency_graph/test_lib_b.c new file mode 100644 index 0000000000..3621587c1c --- /dev/null +++ b/tests/cc/py_extension/dependency_graph/test_lib_b.c @@ -0,0 +1,5 @@ +#include "test_symbols.h" + +void fnB() { + fnC(); +} diff --git a/tests/cc/py_extension/dependency_graph/test_lib_c.c b/tests/cc/py_extension/dependency_graph/test_lib_c.c new file mode 100644 index 0000000000..99941576dd --- /dev/null +++ b/tests/cc/py_extension/dependency_graph/test_lib_c.c @@ -0,0 +1,6 @@ +#include "test_symbols.h" +#include + +void fnC() { + printf("fnC\n"); +} diff --git a/tests/cc/py_extension/dependency_graph/test_symbols.h b/tests/cc/py_extension/dependency_graph/test_symbols.h new file mode 100644 index 0000000000..59ab3b02e7 --- /dev/null +++ b/tests/cc/py_extension/dependency_graph/test_symbols.h @@ -0,0 +1,8 @@ +#ifndef TEST_SYMBOLS_H +#define TEST_SYMBOLS_H + +void fnC(); +void fnB(); +void fnA(); + +#endif // TEST_SYMBOLS_H diff --git a/tests/cc/py_extension/ext_init_in_dep.c b/tests/cc/py_extension/ext_init_in_dep.c new file mode 100644 index 0000000000..a6d32ba0b2 --- /dev/null +++ b/tests/cc/py_extension/ext_init_in_dep.c @@ -0,0 +1,20 @@ + +#include + +// No methods defined; we're just testing the init function. +static PyMethodDef ModuleMethods[] = { + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +static struct PyModuleDef ext_init_in_dep_module = { + PyModuleDef_HEAD_INIT, + "ext_init_in_dep", /* name of module */ + NULL, /* module documentation, may be NULL */ + -1, /* size of per-interpreter state of the module, + or -1 if the module keeps state in global variables. */ + ModuleMethods +}; + +PyMODINIT_FUNC PyInit_ext_init_in_dep(void) { + return PyModule_Create(&ext_init_in_dep_module); +} diff --git a/tests/cc/py_extension/ext_limited.c b/tests/cc/py_extension/ext_limited.c new file mode 100644 index 0000000000..f3622c5824 --- /dev/null +++ b/tests/cc/py_extension/ext_limited.c @@ -0,0 +1,22 @@ +#include + +static PyObject* get_limited_api_version(PyObject* self, PyObject* args) { + return PyUnicode_FromFormat("0x%08x", Py_LIMITED_API); +} + +static PyMethodDef ModuleMethods[] = { + {"get_limited_api_version", get_limited_api_version, METH_NOARGS, "Get the version of the limited API this extension was compiled against."}, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef ext_limited_module = { + PyModuleDef_HEAD_INIT, + "ext_limited", + NULL, + -1, + ModuleMethods +}; + +PyMODINIT_FUNC PyInit_ext_limited(void) { + return PyModule_Create(&ext_limited_module); +} diff --git a/tests/cc/py_extension/ext_pkg_test.c b/tests/cc/py_extension/ext_pkg_test.c new file mode 100644 index 0000000000..c151f0162b --- /dev/null +++ b/tests/cc/py_extension/ext_pkg_test.c @@ -0,0 +1,22 @@ +#include + +static PyObject* get_magic_number(PyObject* self, PyObject* args) { + return PyLong_FromLong(42); +} + +static PyMethodDef ModuleMethods[] = { + {"get_magic_number", get_magic_number, METH_NOARGS, "Returns 42."}, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef ext_pkg_test_module = { + PyModuleDef_HEAD_INIT, + "ext_pkg_test", + NULL, + -1, + ModuleMethods +}; + +PyMODINIT_FUNC PyInit_ext_pkg_test(void) { + return PyModule_Create(&ext_pkg_test_module); +} diff --git a/tests/cc/py_extension/ext_shared.c b/tests/cc/py_extension/ext_shared.c new file mode 100644 index 0000000000..4b79a6da2f --- /dev/null +++ b/tests/cc/py_extension/ext_shared.c @@ -0,0 +1,33 @@ +#include + +#include "tests/cc/py_extension/add_one.h" + +// A simple function that returns a Python integer. +static PyObject* do_alpha(PyObject* self, PyObject* args) { + return PyLong_FromLong(add_one(41)); +} + +// Method definition object for this extension, these are the functions +// that will be available in the module. +static PyMethodDef ModuleMethods[] = { + {"do_alpha", do_alpha, METH_NOARGS, "A simple C function."}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +// Module definition +// The arguments of this structure tell Python what to call your extension, +// what its methods are and where to look for its method definitions. +static struct PyModuleDef ext_shared_module = { + PyModuleDef_HEAD_INIT, + "ext_shared", /* name of module */ + NULL, /* module documentation, may be NULL */ + -1, /* size of per-interpreter state of the module, + or -1 if the module keeps state in global variables. */ + ModuleMethods +}; + +// The module init function. This must be exported and retained in the +// shared library output. +PyMODINIT_FUNC PyInit_ext_shared(void) { + return PyModule_Create(&ext_shared_module); +} diff --git a/tests/cc/py_extension/ext_source.c b/tests/cc/py_extension/ext_source.c new file mode 100644 index 0000000000..df0239df3a --- /dev/null +++ b/tests/cc/py_extension/ext_source.c @@ -0,0 +1,33 @@ + +#include + + +static PyObject* calc_one_plus_two(PyObject* self, PyObject* args) { + return PyLong_FromLong(1 + 2); +} + + +// Method definition object for this extension, these are the functions +// that will be available in the module. +static PyMethodDef ModuleMethods[] = { + {"calc_one_plus_two", calc_one_plus_two, METH_NOARGS, "A simple C function."}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +// Module definition +// The arguments of this structure tell Python what to call your extension, +// what its methods are and where to look for its method definitions. +static struct PyModuleDef ext_source_module = { + PyModuleDef_HEAD_INIT, + "ext_source", /* name of module */ + NULL, /* module documentation, may be NULL */ + -1, /* size of per-interpreter state of the module, + or -1 if the module keeps state in global variables. */ + ModuleMethods +}; + +// The module init function. This must be exported and retained in the +// shared library output. +PyMODINIT_FUNC PyInit_ext_source(void) { + return PyModule_Create(&ext_source_module); +} diff --git a/tests/cc/py_extension/ext_static.c b/tests/cc/py_extension/ext_static.c new file mode 100644 index 0000000000..500c52db00 --- /dev/null +++ b/tests/cc/py_extension/ext_static.c @@ -0,0 +1 @@ +/* A no-op C extension for static linking tests. */ diff --git a/tests/cc/py_extension/py_extension/BUILD.bazel b/tests/cc/py_extension/py_extension/BUILD.bazel new file mode 100644 index 0000000000..0d64c2bf9d --- /dev/null +++ b/tests/cc/py_extension/py_extension/BUILD.bazel @@ -0,0 +1,12 @@ +load(":py_extension_tests.bzl", "py_extension_analysis_test_suite") + +package( + default_testonly = True, + default_visibility = ["//visibility:private"], +) + +licenses(["notice"]) + +py_extension_analysis_test_suite( + name = "py_extension_analysis_tests", +) diff --git a/tests/cc/py_extension/py_extension/py_extension_tests.bzl b/tests/cc/py_extension/py_extension/py_extension_tests.bzl new file mode 100644 index 0000000000..ef2a2d8b6e --- /dev/null +++ b/tests/cc/py_extension/py_extension/py_extension_tests.bzl @@ -0,0 +1,102 @@ +# Copyright 2025 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. + +"""Tests for py_extension.""" + +load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite") +load("@rules_testing//lib:truth.bzl", "matching") +load("//python/private:py_info.bzl", "PyInfo") # buildifier: disable=bzl-visibility + +_tests = [] + +def _test_static_deps_impl(env, target): + env.expect.that_target(target).has_provider(PyInfo) + py_info = target[PyInfo] + + # The extension should be in PyInfo + env.expect.that_collection(py_info.transitive_sources.to_list()).has_size(1) + env.expect.that_depset_of_files(py_info.transitive_sources).contains_predicate( + matching.file_path_matches("ext_static.*311-*"), + ) + +def _test_static_deps(name): + analysis_test( + name = name, + impl = _test_static_deps_impl, + target = "//tests/cc/py_extension:ext_static", + ) + +_tests.append(_test_static_deps) + +def _test_data_deps_impl(env, target): + env.expect.that_target(target).has_provider(PyInfo) + + # Check that data file is in runfiles + default_info = target[DefaultInfo] + env.expect.that_depset_of_files(default_info.default_runfiles.files).contains_predicate( + matching.file_basename_equals("some_data.txt"), + ) + +def _test_data_deps(name): + analysis_test( + name = name, + impl = _test_data_deps_impl, + target = "//tests/cc/py_extension:ext_with_data", + ) + +_tests.append(_test_data_deps) + +def _test_dynamic_deps_impl(env, target): + env.expect.that_target(target).has_provider(PyInfo) + py_info = target[PyInfo] + + # The extension should be in PyInfo + env.expect.that_collection(py_info.transitive_sources.to_list()).has_size(1) + env.expect.that_depset_of_files(py_info.transitive_sources).contains_predicate( + matching.file_path_matches("ext_shared.*311-*"), + ) + +def _test_dynamic_deps(name): + analysis_test( + name = name, + impl = _test_dynamic_deps_impl, + target = "//tests/cc/py_extension:ext_shared", + ) + +_tests.append(_test_dynamic_deps) + +def _test_musl_platform_impl(env, target): + env.expect.that_target(target).has_provider(PyInfo) + py_info = target[PyInfo] + env.expect.that_depset_of_files(py_info.transitive_sources).contains_predicate( + matching.file_path_matches("ext_static.*311-*"), + ) + +def _test_musl_platform(name): + analysis_test( + name = name, + impl = _test_musl_platform_impl, + target = "//tests/cc/py_extension:ext_static", + config_settings = { + str(Label("//python/config_settings:py_linux_libc")): "musl", + }, + ) + +_tests.append(_test_musl_platform) + +def py_extension_analysis_test_suite(name): + test_suite( + name = name, + tests = _tests, + ) diff --git a/tests/cc/py_extension/py_extension_build_test.bzl b/tests/cc/py_extension/py_extension_build_test.bzl new file mode 100644 index 0000000000..18cca2204b --- /dev/null +++ b/tests/cc/py_extension/py_extension_build_test.bzl @@ -0,0 +1,15 @@ +"""Macro helper for py_extension build testing.""" + +load("@bazel_skylib//rules:build_test.bzl", "build_test") +load("//python/cc:py_extension.bzl", "py_extension") + +def py_extension_build_test(name, **kwargs): + """Creates a py_extension target and a build_test verifying it builds.""" + py_extension( + name = name, + **kwargs + ) + build_test( + name = name + "_build_test", + targets = [":" + name], + ) diff --git a/tests/cc/py_extension/py_extension_pkg_test.py b/tests/cc/py_extension/py_extension_pkg_test.py new file mode 100644 index 0000000000..e3176d6a6c --- /dev/null +++ b/tests/cc/py_extension/py_extension_pkg_test.py @@ -0,0 +1,16 @@ +import unittest + +from tests.cc.py_extension import ext_pkg_test + + +class PyExtensionPkgTest(unittest.TestCase): + def test_import_via_package(self): + self.assertEqual(ext_pkg_test.get_magic_number(), 42) + + def test_direct_import(self): + with self.assertRaises(ModuleNotFoundError): + import ext_pkg_test # buildifier: disable=g-import-not-at-top # noqa: F401 + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cc/py_extension/py_extension_test.py b/tests/cc/py_extension/py_extension_test.py new file mode 100644 index 0000000000..d82fe22bcc --- /dev/null +++ b/tests/cc/py_extension/py_extension_test.py @@ -0,0 +1,47 @@ +import os +import sys +import unittest + +import ext_shared +from elftools.elf.dynamic import DynamicSection +from elftools.elf.elffile import ELFFile + + +class PyExtensionTest(unittest.TestCase): + @unittest.skipIf( + sys.platform != "linux", "ELF inspection is only supported on Linux" + ) + def test_inspect_elf(self): + ext_path = ext_shared.__file__ + self.assertTrue( + os.path.exists(ext_path), f"Could not find ext_shared.so at {ext_path}" + ) + + with open(ext_path, "rb") as f: + elf = ELFFile(f) + + # Check for DT_NEEDED entry for the dynamic library + dynamic_section = elf.get_section_by_name(".dynamic") + self.assertIsNotNone(dynamic_section) + self.assertTrue(isinstance(dynamic_section, DynamicSection)) + + needed_libs = [ + tag.needed + for tag in dynamic_section.iter_tags() + if tag.entry.d_tag == "DT_NEEDED" + ] + self.assertIn("libadd_one_shared.so", needed_libs) + + # Check for the PyInit symbol + dynsym_section = elf.get_section_by_name(".dynsym") + self.assertIsNotNone(dynsym_section) + + symbols = [s.name for s in dynsym_section.iter_symbols()] + self.assertIn("PyInit_ext_shared", symbols) + + def test_import_and_call(self): + self.assertEqual(ext_shared.do_alpha(), 43) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cc/py_extension/py_limited_api/BUILD.bazel b/tests/cc/py_extension/py_limited_api/BUILD.bazel new file mode 100644 index 0000000000..f548f3040f --- /dev/null +++ b/tests/cc/py_extension/py_limited_api/BUILD.bazel @@ -0,0 +1,12 @@ +load(":py_limited_api_tests.bzl", "py_limited_api_test_suite") + +package( + default_testonly = True, + default_visibility = ["//visibility:private"], +) + +licenses(["notice"]) + +py_limited_api_test_suite( + name = "py_limited_api_tests", +) diff --git a/tests/cc/py_extension/py_limited_api/py_limited_api_tests.bzl b/tests/cc/py_extension/py_limited_api/py_limited_api_tests.bzl new file mode 100644 index 0000000000..fbe92b936a --- /dev/null +++ b/tests/cc/py_extension/py_limited_api/py_limited_api_tests.bzl @@ -0,0 +1,140 @@ +# Copyright 2025 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. + +"""Tests for the py_limited_api attribute for py_extension.""" + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite") +load("@rules_testing//lib:truth.bzl", "matching") +load("@rules_testing//lib:util.bzl", "util") +load("//python/cc:py_extension.bzl", "py_extension") + +def _test_limited_pass_impl(env, target): + env.expect.that_depset_of_files(target[DefaultInfo].files).contains_predicate( + matching.file_path_matches("tests/cc/py_extension/py_limited_api/{}.abi3.*".format(target.label.name)), + ) + +def _test_limited_same_version(name): + util.helper_target( + cc_library, + name = name + "_csl", + defines = ["Py_LIMITED_API=0x03080000"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + deps = [":" + name + "_csl"], + py_limited_api = "3.8", + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_limited_pass_impl, + ) + +def _test_limited_older_dep(name): + util.helper_target( + cc_library, + name = name + "_csl", + defines = ["Py_LIMITED_API=0x03080000"], # 3.8 + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + deps = [":" + name + "_csl"], + py_limited_api = "3.9", # 3.9 + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_limited_pass_impl, + ) + +def _test_no_limited_api(name): + util.helper_target( + cc_library, + name = name + "_csl", + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + deps = [":" + name + "_csl"], + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_no_limited_api_impl, + ) + +def _test_no_limited_api_impl(env, target): + # Should pass, nothing to assert on filename since it is platform-specific + _ = env # @unused + _ = target # @unused + +def _test_no_limited_api_dep_has_limited(name): + util.helper_target( + cc_library, + name = name + "_csl", + defines = ["Py_LIMITED_API=0x03080000"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + deps = [":" + name + "_csl"], + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_no_limited_api_dep_has_limited_impl, + ) + +def _test_no_limited_api_dep_has_limited_impl(env, target): + _ = env # @unused + _ = target # @unused + +def _test_limited_api_dep_has_no_python(name): + util.helper_target( + cc_library, + name = name + "_csl", + ) + py_extension( + name = name + "_pyext", + deps = [":" + name + "_csl"], + py_limited_api = "3.8", + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_limited_pass_impl, + ) + +def py_limited_api_test_suite(name): + test_suite( + name = name, + tests = [ + _test_limited_same_version, + _test_limited_older_dep, + _test_no_limited_api, + _test_no_limited_api_dep_has_limited, + _test_limited_api_dep_has_no_python, + ], + ) diff --git a/tests/cc/py_extension/some_data.txt b/tests/cc/py_extension/some_data.txt new file mode 100644 index 0000000000..4b5dc1d64c --- /dev/null +++ b/tests/cc/py_extension/some_data.txt @@ -0,0 +1 @@ +This is a data file diff --git a/tests/cc/py_extension/static_dep.c b/tests/cc/py_extension/static_dep.c new file mode 100644 index 0000000000..fa95e4dacc --- /dev/null +++ b/tests/cc/py_extension/static_dep.c @@ -0,0 +1,5 @@ +#include "static_dep.h" + +int my_lib_func() { + return 42; +} diff --git a/tests/cc/py_extension/static_dep.h b/tests/cc/py_extension/static_dep.h new file mode 100644 index 0000000000..d0f272abd7 --- /dev/null +++ b/tests/cc/py_extension/static_dep.h @@ -0,0 +1 @@ +int my_lib_func(); diff --git a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock index 686ee0ee40..6b293283ae 100644 --- a/tests/integration/bzlmod_lockfile/MODULE.bazel.lock +++ b/tests/integration/bzlmod_lockfile/MODULE.bazel.lock @@ -252,7 +252,7 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "OC4ZhWl8jX9wvFicn83AioC9J7Dx6Us2/+EzoGDlPzU=", + "bzlTransitiveDigest": "DafUArm1CsjJZcemT0FvUq9rUch+pq38+JqfOBJCpm8=", "usagesDigest": "6yXGw7XDyXjOfqBL0SBu1YBEMMYPQzCE3jTzUCkxPgg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", diff --git a/tests/support/cc_toolchains/BUILD.bazel b/tests/support/cc_toolchains/BUILD.bazel index 1c1a714626..a74a8016fc 100644 --- a/tests/support/cc_toolchains/BUILD.bazel +++ b/tests/support/cc_toolchains/BUILD.bazel @@ -57,7 +57,9 @@ py_cc_toolchain( headers = ":py_headers", headers_abi3 = ":py_headers_abi3", libs = ":fake_libs", + platform_machine = "x86_64", python_version = "3.999", + sys_platform = "linux", tags = PREVENT_IMPLICIT_BUILDING_TAGS, ) diff --git a/tests/support/py_cc_toolchain_info_subject.bzl b/tests/support/py_cc_toolchain_info_subject.bzl index 3820e04e90..fbf5a1a0ad 100644 --- a/tests/support/py_cc_toolchain_info_subject.bzl +++ b/tests/support/py_cc_toolchain_info_subject.bzl @@ -18,15 +18,25 @@ load("@rules_testing//lib:truth.bzl", "subjects") def _py_cc_toolchain_info_subject_new(info, *, meta): # buildifier: disable=uninitialized public = struct( + abi_flags = lambda *a, **k: _py_cc_toolchain_info_subject_abi_flags(self, *a, **k), headers = lambda *a, **k: _py_cc_toolchain_info_subject_headers(self, *a, **k), headers_abi3 = lambda *a, **k: _py_cc_toolchain_info_subject_headers_abi3(self, *a, **k), libs = lambda *a, **k: _py_cc_toolchain_info_subject_libs(self, *a, **k), + platform_machine = lambda *a, **k: _py_cc_toolchain_info_subject_platform_machine(self, *a, **k), + platform_tag = lambda *a, **k: _py_cc_toolchain_info_subject_platform_tag(self, *a, **k), python_version = lambda *a, **k: _py_cc_toolchain_info_subject_python_version(self, *a, **k), + sys_platform = lambda *a, **k: _py_cc_toolchain_info_subject_sys_platform(self, *a, **k), actual = info, ) self = struct(actual = info, meta = meta) return public +def _py_cc_toolchain_info_subject_abi_flags(self): + return subjects.str( + self.actual.abi_flags, + meta = self.meta.derive("abi_flags()"), + ) + def _py_cc_toolchain_info_subject_headers(self): return subjects.struct( self.actual.headers, @@ -54,12 +64,30 @@ def _py_cc_toolchain_info_subject_libs(self): ), ) +def _py_cc_toolchain_info_subject_platform_machine(self): + return subjects.str( + self.actual.platform_machine, + meta = self.meta.derive("platform_machine()"), + ) + +def _py_cc_toolchain_info_subject_platform_tag(self): + return subjects.str( + self.actual.platform_tag, + meta = self.meta.derive("platform_tag()"), + ) + def _py_cc_toolchain_info_subject_python_version(self): return subjects.str( self.actual.python_version, meta = self.meta.derive("python_version()"), ) +def _py_cc_toolchain_info_subject_sys_platform(self): + return subjects.str( + self.actual.sys_platform, + meta = self.meta.derive("sys_platform()"), + ) + # Disable this to aid doc generation # buildifier: disable=name-conventions PyCcToolchainInfoSubject = struct( From 2b482dad110470a28dc3e407d6165fba8257c92e Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 11:52:14 -0700 Subject: [PATCH 133/179] agents: add technical rules for Starlark, Windows, C++, and testing (#3986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To support upcoming C/C++ Python extension features (`py_extension`), autonomous agents require explicit domain-specific guardrails and invariants. Without these rules, agents frequently encounter recurring pitfalls—such as target analysis errors from duplicate dependency labels, invalid use of `--config=fast-tests` on documentation targets, non-canonical macro target strings, or breaking review threads by amending PR commits. This change modularizes agent guidance into dedicated rule files under `.agents/rules/` and updates the `create-pr` skill to enforce PR and news entry conventions across autonomous workflows. ### Detailed Breakdown of Rule Additions * `.agents/rules/cc.md`: Documents C/C++ target rules and Python extension module compilation and linking guardrails. * `.agents/rules/news.md`: Establishes news entry conventions, requiring Sphinx MyST cross-reference syntax (`{obj}`) for code symbols and markdown issue links. * `.agents/rules/pr.md`: Enforces PR description conventions, mandatory `@CONTRIBUTING.md` inclusion, and the commit invariant (never amend or rebase active PR commits). Replaces `.agents/rules/pr_and_news.md`. * `.agents/rules/python.md`: Defines pytest fixture registration rules via `pytest_plugins` and `fixture_` function naming conventions. * `.agents/rules/starlark.md`: Specifies Starlark macro target canonicalization using `str(Label(...))`, private alias patterns for internal helper `deps`, input attribute normalization at function entry, iterative algorithm loop restrictions, 80-column line wrapping, and `@rules_testing` test structures. * `.agents/rules/testing.md`: Enforces `--config=fast-tests` usage guardrails (never use on non-test targets like `//docs:docs`), lockfile updates, and retry behavior for Sphinx documentation build flakes. * `.agents/rules/windows.md`: Documents Windows MSVC toolchain embedded `#pragma comment(lib, ...)` library linking, PEP 3149 ABI tag differences (`cp` vs `cpython-`), and platform tags. * `.agents/skills/create-pr/SKILL.md`: Updates frontmatter to support propose and draft PR workflows, and instructs subagents to include `@/.agents/rules/pr.md`. --- .agents/rules/cc.md | 22 ++++++++++++++ .agents/rules/news.md | 20 +++++++++++++ .agents/rules/pr.md | 20 +++++++++++++ .agents/rules/pr_and_news.md | 12 -------- .agents/rules/python.md | 8 ++++++ .agents/rules/starlark.md | 48 +++++++++++++++++++++++++++++++ .agents/rules/testing.md | 19 ++++++++++++ .agents/rules/windows.md | 21 ++++++++++++++ .agents/skills/create-pr/SKILL.md | 8 ++++-- 9 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 .agents/rules/cc.md create mode 100644 .agents/rules/news.md create mode 100644 .agents/rules/pr.md delete mode 100644 .agents/rules/pr_and_news.md create mode 100644 .agents/rules/python.md create mode 100644 .agents/rules/starlark.md create mode 100644 .agents/rules/testing.md create mode 100644 .agents/rules/windows.md diff --git a/.agents/rules/cc.md b/.agents/rules/cc.md new file mode 100644 index 0000000000..5f2c2b43c2 --- /dev/null +++ b/.agents/rules/cc.md @@ -0,0 +1,22 @@ +# C/C++ Rules Design & Bazel rules_cc Integration + +## Concise `cc_shared_library` Interface & Export Rules +* In `cc_shared_library`: + * Targets directly placed in `deps` are **automatically exported** by Bazel; + they do not need to be listed in `exports_filter`. + * `exports_filter` is defined in `rules_cc` as an `attr.string_list()`, **not** + a `Label` attribute. Passing Starlark `Label` objects directly causes an + immediate Bazel analysis type error. + * **Citation**: [rules_cc `cc_shared_library.bzl`](https://github.com/bazelbuild/rules_cc/blob/main/cc/private/rules_impl/cc_shared_library.bzl) + (*"exports_filter is a list of strings attribute"*). + +## macOS Dynamic Lookup Link Flag +* Apple's `ld64`/`lld` linkers require `-undefined dynamic_lookup` in + `user_link_flags` on macOS so CPython C-API symbols remain unresolved at + link time and resolve dynamically at runtime. + +## Linux Platform Tag Composition +* Linux platform tags are formatted as `{platform_machine}-linux-{libc}` (e.g., + `x86_64-linux-gnu` or `aarch64-linux-musl`). +* **Citation**: [PEP 600 — Perennial manylinux](https://peps.python.org/pep-0600/) + & [PEP 656 — musllinux](https://peps.python.org/pep-0656/). diff --git a/.agents/rules/news.md b/.agents/rules/news.md new file mode 100644 index 0000000000..ce8a844439 --- /dev/null +++ b/.agents/rules/news.md @@ -0,0 +1,20 @@ +--- +trigger: news/*.md +description: Apply when drafting news entries. +--- + +@CONTRIBUTING.md + +# News Entry Conventions + +Before drafting any news entry, strictly adhere to the rules in `CONTRIBUTING.md` +above. + +## Sphinx MyST Cross-Reference Syntax (`{obj}`) +* Use `{obj}\`\`` in news entries for rules, macros, targets, providers, + attributes, args, and any other cross-referencable Starlark or Python + objects. + +## GitHub Issue Link Formatting +* Append GitHub issue cross-references at the end of news entries in markdown + link format: `([#3283](https://github.com/bazel-contrib/rules_python/issues/3283))`. diff --git a/.agents/rules/pr.md b/.agents/rules/pr.md new file mode 100644 index 0000000000..ba8236d072 --- /dev/null +++ b/.agents/rules/pr.md @@ -0,0 +1,20 @@ +--- +trigger: model_decision +description: Apply when drafting pull request descriptions. +--- + +@CONTRIBUTING.md + +# Pull Request Conventions + +Before drafting any pull request description, strictly adhere to the rules in +`CONTRIBUTING.md` above. + +## Prevent Agent Oversight of `CONTRIBUTING.md` +* Always include `@CONTRIBUTING.md` in rule definitions so that PR formatting + rules are injected into context whenever PR workflows run. + +## PR Commit Workflow Invariant +* Once a Pull Request is created, always make new commits or merge commits. +* **NEVER** amend or rebase commits on an active PR branch to avoid breaking + code review threads. diff --git a/.agents/rules/pr_and_news.md b/.agents/rules/pr_and_news.md deleted file mode 100644 index 0eb7726efc..0000000000 --- a/.agents/rules/pr_and_news.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -trigger: model_decision -description: Apply when drafting pull request descriptions or news entries. ---- - -# PR Descriptions and News Entries - -Before drafting any pull request description or news entry, you MUST read -`CONTRIBUTING.md` (specifically the sections on **Commit messages and PR -descriptions** and **Documenting changes**) and strictly adhere to its style, -formatting, and structure rules. Do not generate PR descriptions or news -entries without consulting `CONTRIBUTING.md` first. diff --git a/.agents/rules/python.md b/.agents/rules/python.md new file mode 100644 index 0000000000..523cc923cd --- /dev/null +++ b/.agents/rules/python.md @@ -0,0 +1,8 @@ +# Python Conventions + +## pytest +* **Fixture Registration via `pytest_plugins`**: When registering pytest helper + modules in test files, use `pytest_plugins = [""]`. +* **Fixture Naming Conventions**: Name fixture functions with a `fixture_` prefix + (e.g. `def fixture_foo():`), and pass the public fixture name using the `name` + parameter in `@pytest.fixture(name="foo")`. diff --git a/.agents/rules/starlark.md b/.agents/rules/starlark.md new file mode 100644 index 0000000000..430284bcd5 --- /dev/null +++ b/.agents/rules/starlark.md @@ -0,0 +1,48 @@ +# Starlark Language & Macro Invariants + +## Macro Target Canonicalization +* In macro implementations, internal repository target references **MUST** be + canonicalized using `str(Label("//path/to:target"))` so they resolve in the + macro's module context rather than the caller's repository context. +* Note that `python/private/common_labels.bzl` defines `labels`, a struct + containing common canonicalized label strings used across the project. + +## Private Alias Pattern when Appending to User `deps` +* When macros append internal helper targets to user-provided dependency lists + (`deps`), use private alias targets (e.g. + `//python/private/cc:current_py_cc_headers_private_alias`) to prevent + "duplicate dependency label" analysis errors if the user also explicitly + passes the public target label in their `deps` (including in `select()` + expressions). + +## Attribute Normalization at Function Entry +* Always normalize input list attributes at the start of macro definitions + (e.g., `deps = deps or []`, `copts = copts or []`). +* **Why**: This avoids inlining the normalization as part of a complex + expression later in the macro expansion. + +## Control Flow & Algorithmic Restrictions +* **Iterative Algorithms Only (No Recursion)**: Starlark does not support + recursive function calls; always implement iterative algorithms using bounded + loops. +* **Iterable `for` Loops Only (No `while` Loops)**: Starlark does not support + `while` loops; iterate over fixed-size ranges or explicit collections. + +## Code Style & Conventions +* **Docstring Formatting Invariants**: Use triple-quoted strings for multi-line + docstrings without trailing backslashes (`\`) for line continuation. +* **No Bazel Copyright Headers**: Do not add Bazel copyright headers to new or + existing files unless explicitly directed by the user. +* **Line Length & Wrapping**: Wrap Markdown and Starlark lines to 80 columns in + accordance with `.editorconfig`. + +## Starlark Testing (`rules_testing`) +* **`rules_testing` over `bazel_skylib`**: Always use `@rules_testing` (analysis + tests with `env.expect.that_...`) rather than `bazel_skylib` for Starlark + rule analysis tests. +* **Analysis Test Two-Part Structure**: Separate tests into a setup target + function `def _test_foo(name)` calling `analysis_test` and an implementation + function `def _test_foo_impl(env, target)`. +* **Test Suite Registration**: Collect test setup functions in a private + `_tests` list and register them cleanly via `test_suite(name = name, tests = + _tests)`. diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md new file mode 100644 index 0000000000..0f369b3861 --- /dev/null +++ b/.agents/rules/testing.md @@ -0,0 +1,19 @@ +# Testing & Validation Guardrails + +## `--config=fast-tests` for Test Targets +* Always pass `--config=fast-tests` when running or building test targets to + avoid running expensive, flaky integration tests. + +## CRITICAL: Never use `--config=fast-tests` on Non-Test Targets +* `--config=fast-tests` sets `--build_tests_only=true`, which silently ignores + non-test targets (such as `//docs:docs` or package libraries), resulting in 0 + targets built! + +## Lockfile Testing (`MODULE.bazel.lock`) +* Changes to transitive module extension dependencies or `.bzl` files loaded by + extensions update Bazel 9 lockfile hashes, requiring `bazel mod deps + --lockfile_mode=update` in integration test workspaces. + +## Documentation Flake Handling +* When building `//docs:docs` fails with exit code 2, treat it as a known + Sphinx/Bazel flake and retry the build. diff --git a/.agents/rules/windows.md b/.agents/rules/windows.md new file mode 100644 index 0000000000..5948092902 --- /dev/null +++ b/.agents/rules/windows.md @@ -0,0 +1,21 @@ +# Windows MSVC Toolchains & Linking + +## MSVC `#pragma comment(lib, ...)` Embedded References +* CPython's MSVC headers embed `#pragma comment(lib, "python3xx.lib")` into + generated `.obj` files. MSVC `link.exe` automatically searches for + `python3xx.lib` in library search paths during linking. +* **Citation**: [Microsoft Learn `comment` pragma](https://learn.microsoft.com/en-us/cpp/preprocessor/comment-c-cpp) + (*"Places a library-search record in the object file... The linker searches for + this library the same way as if you had named it on the command line"*). + +## PEP 3149 ABI Tag Prefix (`cp` vs. `cpython-`) +* Windows CPython ABI tags use the `cp` prefix (e.g., `cp311`, `cp312t`) + whereas POSIX platforms use `cpython-` (e.g., `cpython-311`). +* **Citation**: [PEP 3149 — ABI version tagged .so files](https://peps.python.org/pep-3149/) + (*"The tag starts with `cpython-` followed by the Python major and minor version + without dots... followed by build flags"*). + +## Windows Platform Tag Derivation +* Windows platform tags for Python C extensions evaluate to `win_amd64` + (x86_64/amd64), `win_arm64` (aarch64/arm64), or `win32` (32-bit x86). +* **Citation**: [PEP 425 — Compatibility Tags for Built Distributions](https://peps.python.org/pep-0425/). diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md index 822ae1bdf8..9c2f454000 100644 --- a/.agents/skills/create-pr/SKILL.md +++ b/.agents/skills/create-pr/SKILL.md @@ -1,6 +1,7 @@ --- name: create-pr -description: Create a pull request by delegating to a subagent +description: Propose, draft, or create a pull request by delegating to a + subagent --- When creating a Pull Request for local changes or a branch, invoke a subagent @@ -11,8 +12,9 @@ to handle PR creation or description drafting. 1. Launch a subagent using `invoke_subagent` with `TypeName: "self"` (or `agentapi new-conversation`). 2. Provide a prompt to the subagent directing it to: - - Read `CONTRIBUTING.md` (specifically the sections on **Commit messages - and PR descriptions** and **Documenting changes**) before drafting. + - Include `@/.agents/rules/pr.md` and read `CONTRIBUTING.md` (specifically + the sections on **Commit messages and PR descriptions** and + **Documenting changes**) before drafting. - Strictly adhere to `CONTRIBUTING.md` rules for: - **PR Title**: Follow conventional commit style and title formatting. For agent rules, skills, and system updates, use `agents:` prefix. From 3c9423a076b6fb1aeade213d77aeaf6020da3b29 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 14:06:03 -0700 Subject: [PATCH 134/179] docs: convert raw URLs in generated docs and docstrings to hyperlinks (#3987) Plain text URLs in documentation Markdown files and Starlark/Python docstrings were rendered as non-clickable text in the generated HTML output, making navigation cumbersome. To fix this, raw URLs are converted into MyST autolinks (), Markdown links, and {gh-issue} roles. A custom {pep} Sphinx role is also introduced in docs/conf.py to dynamically resolve PEP numbers to https://peps.python.org/pep-XXXX/, and existing PEP references in docstrings are updated to use {pep}. --- CHANGELOG.md | 12 ++++++------ docs/conf.py | 13 +++++++++++++ docs/extending.md | 2 +- docs/toolchains.md | 2 +- gazelle/docs/directives.md | 2 +- python/packaging.bzl | 10 +++++----- python/private/attributes.bzl | 6 +++--- python/private/py_cc_toolchain_info.bzl | 4 ++-- python/private/py_cc_toolchain_rule.bzl | 2 +- python/private/py_exec_tools_info.bzl | 2 +- python/private/py_executable.bzl | 4 ++-- python/private/py_runtime_rule.bzl | 2 +- python/private/py_wheel.bzl | 8 ++++---- python/private/pypi/BUILD.bazel | 12 ++++++------ python/private/pypi/extension.bzl | 6 +++--- python/private/pypi/pip_compile.bzl | 4 ++-- python/private/pypi/pip_repository.bzl | 4 ++-- python/private/zipapp/py_zipapp_rule.bzl | 2 +- python/runfiles/runfiles.py | 2 +- 19 files changed, 56 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f176e5b2..f6dc36ee73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -721,7 +721,7 @@ Other changes: * (core) `#!/usr/bin/env bash` is now used as a shebang in the stage1 bootstrap template. * (gazelle:docs) The Gazelle docs have been migrated from {gh-path}`gazelle/README.md` to {gh-path}`gazelle/docs` and are now available on the primary documentation site - at https://rules-python.readthedocs.io/en/latest/gazelle/docs/index.html + at [20250808]: https://github.com/astral-sh/python-build-standalone/releases/tag/20250808 @@ -1133,7 +1133,7 @@ Other changes: {#v1-2-0-changed} ### Changed * (rules) `py_proto_library` is deprecated in favour of the - implementation in https://github.com/protocolbuffers/protobuf. It will be + implementation in . It will be removed in the future release. * (pypi) {obj}`pip.override` will now be ignored instead of raising an error, fixes [#2550](https://github.com/bazel-contrib/rules_python/issues/2550). @@ -2117,7 +2117,7 @@ Other changes: `common --@rules_python//python/config_settings:python_version=X.Y.Z`. * New Python versions available: `3.11.7`, `3.12.1` using - https://github.com/indygreg/python-build-standalone/releases/tag/20240107. + . * (toolchain) Allow setting `x.y` as the `python_version` parameter in the version-aware `py_binary` and `py_test` rules. This allows users to @@ -2269,7 +2269,7 @@ Other changes: * (docs) bzlmod extensions are now documented on rules-python.readthedocs.io * (docs) Support and backwards compatibility policies have been documented. - See https://rules-python.readthedocs.io/en/latest/support.html + See * (gazelle) `file` generation mode can now also add `__init__.py` to the srcs attribute for every target in the package. This is enabled through a separate directive `python_generation_mode_per_file_include_init`. @@ -2401,7 +2401,7 @@ Breaking changes: the `py_binary` rule used to build it. * New Python versions available: `3.8.17`, `3.11.5` using - https://github.com/indygreg/python-build-standalone/releases/tag/20230826. + . * (gazelle) New `# gazelle:python_generation_mode file` directive to support generating one `py_library` per file. @@ -2421,7 +2421,7 @@ Breaking changes: time being. * New Python versions available: `3.8.18`, `3.9.18`, `3.10.13`, `3.11.6`, `3.12.0` using - https://github.com/indygreg/python-build-standalone/releases/tag/20231002. + . `3.12.0` support is considered beta and may have issues. ### Removed diff --git a/docs/conf.py b/docs/conf.py index 17b3c17106..750e344285 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,9 @@ # Configuration file for the Sphinx documentation builder. import os +import re + +from docutils import nodes # -- Project information project = "rules_python" @@ -219,9 +222,19 @@ ] +def _pep_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + match = re.search(r"\d+", text) + pep_num = match.group(0) if match else text + pep_url = f"https://peps.python.org/pep-{pep_num.zfill(4)}/" + display_text = text if text.startswith("PEP") else f"PEP {pep_num}" + node = nodes.reference(rawtext, display_text, refuri=pep_url, **options) + return [node], [] + + def setup(app): # Pygments says it supports starlark, but it doesn't seem to actually # recognize `starlark` as a name. So just manually map it to python. from sphinx.highlighting import lexer_classes app.add_lexer("starlark", lexer_classes["python"]) + app.add_role("pep", _pep_role) diff --git a/docs/extending.md b/docs/extending.md index 00018fbd74..1c91b01795 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -23,7 +23,7 @@ Extending the core rules is most useful when you want all or most of the behavior of a core rule. ::: -Follow or comment on https://github.com/bazel-contrib/rules_python/issues/1647 +Follow or comment on {gh-issue}`1647` for the development of APIs to support custom derived rules. ## Creating custom rules diff --git a/docs/toolchains.md b/docs/toolchains.md index 98534aee63..f154fc29ed 100644 --- a/docs/toolchains.md +++ b/docs/toolchains.md @@ -473,7 +473,7 @@ pip_parse( ``` After registration, your Python targets will use the toolchain's interpreter during execution, but a system-installed interpreter -is still used to "bootstrap" Python targets (see https://github.com/bazel-contrib/rules_python/issues/691). +is still used to "bootstrap" Python targets (see {gh-issue}`691`). You may also find some quirks while using this toolchain. Please refer to [python-build-standalone documentation's _Quirks_ section](https://gregoryszorc.com/docs/python-build-standalone/main/quirks.html). ## Local toolchain diff --git a/gazelle/docs/directives.md b/gazelle/docs/directives.md index ce8c03b9cd..60f948e4af 100644 --- a/gazelle/docs/directives.md +++ b/gazelle/docs/directives.md @@ -837,7 +837,7 @@ Version 1.9.0 includes a fix ({gh-pr}`3498`) for a long-standing issue ({gh-issue}`3497`) where ancestor `conftest.py` files were not automatically added as dependencies of {bzl:obj}`py_test` targets. -However, some people may not want this behavior (see https://xkcd.com/1172/). +However, some people may not want this behavior (see ). Thus the `python_include_ancestor_conftest` directive controls this behavior. It defaults to `true`, which causes all ancestor `conftest.py` files to be included as dependencies for {bzl:obj}`py_test` targets. diff --git a/python/packaging.bzl b/python/packaging.bzl index 537aa6090f..fb333ca72f 100644 --- a/python/packaging.bzl +++ b/python/packaging.bzl @@ -63,7 +63,7 @@ py_wheel_dist = rule( doc = """\ Prepare a dist/ folder, following Python's packaging standard practice. -See https://packaging.python.org/en/latest/tutorials/packaging-projects/#generating-distribution-archives +See which recommends a dist/ folder containing the wheel file(s), source distributions, etc. This also has the advantage that stamping information is included in the wheel's filename. @@ -94,7 +94,7 @@ def py_wheel( **kwargs): """Builds a Python Wheel. - Wheels are Python distribution format defined in https://www.python.org/dev/peps/pep-0427/. + Wheels are Python distribution format defined in {pep}`PEP 427`. This macro packages a set of targets into a single wheel. It wraps the [py_wheel rule](#py_wheel_rule). @@ -144,7 +144,7 @@ def py_wheel( To publish the wheel to PyPI, the twine package is required and it is installed by default on `bzlmod` setups. On legacy `WORKSPACE`, `rules_python` doesn't provide `twine` itself - (see https://github.com/bazel-contrib/rules_python/issues/1016), but + (see {gh-issue}`1016`), but you can install it with `pip_parse`, just like we do any other dependencies. Once you've installed twine, you can pass its label to the `twine` @@ -160,7 +160,7 @@ def py_wheel( ) ``` - Now you can run a command like the following, which publishes to https://test.pypi.org/ + Now you can run a command like the following, which publishes to ```sh % TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-*** \\ @@ -172,7 +172,7 @@ def py_wheel( name: A unique name for this target. twine: A label of the external location of the py_library target for twine twine_binary: A label of the external location of a binary target for twine. - publish_args: arguments passed to twine, e.g. ["--repository-url", "https://pypi.my.org/simple/"]. + publish_args: arguments passed to twine, e.g. `["--repository-url", "https://pypi.my.org/simple/"]`. These are subject to make var expansion, as with the `args` attribute. Note that you can also pass additional args to the bazel run command as in the example above. **kwargs: other named parameters passed to the underlying [py_wheel rule](#py_wheel_rule) diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl index 61b41d527c..5909d8872b 100644 --- a/python/private/attributes.bzl +++ b/python/private/attributes.bzl @@ -271,7 +271,7 @@ source files. Possible values are: the source file. This is most useful when the code won't be modified. For more information on pyc invalidation modes, see -https://docs.python.org/3/library/py_compile.html#py_compile.PycInvalidationMode + """, default = PrecompileInvalidationModeAttr.AUTO, values = sorted(PrecompileInvalidationModeAttr.__members__.values()), @@ -281,7 +281,7 @@ https://docs.python.org/3/library/py_compile.html#py_compile.PycInvalidationMode The optimization level for precompiled files. For more information about optimization levels, see the `compile()` function's -`optimize` arg docs at https://docs.python.org/3/library/functions.html#compile +`optimize` arg docs at NOTE: The value `-1` means "current interpreter", which will be the interpreter used _at build time when pycs are generated_, not the interpreter used at @@ -422,7 +422,7 @@ These values are transitioned on, so will affect the analysis graph and the associated memory overhead. The more unique configurations in your overall build, the more memory and (often unnecessary) re-analysis and re-building can occur. See -https://bazel.build/extending/config#memory-performance-considerations for + for more information about risks and considerations. ::: diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl index fcf407516c..7f443d05a9 100644 --- a/python/private/py_cc_toolchain_info.bzl +++ b/python/private/py_cc_toolchain_info.bzl @@ -105,7 +105,7 @@ If available, information about C libraries, struct with fields: "platform_machine": """ :type: str -The [PEP 508](https://peps.python.org/pep-0508/) `platform_machine` marker +The {pep}`PEP 508` `platform_machine` marker value for the target architecture, e.g. 'x86_64', 'aarch64'. """, "platform_tag": """\ @@ -122,7 +122,7 @@ The Python Major.Minor version. "sys_platform": """ :type: str -The [PEP 508](https://peps.python.org/pep-0508/) `sys_platform` marker value +The {pep}`0508` `sys_platform` marker value for the target OS, e.g. 'linux', 'darwin', 'win32'. """, }, diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl index 194ce7be78..76528654b1 100644 --- a/python/private/py_cc_toolchain_rule.bzl +++ b/python/private/py_cc_toolchain_rule.bzl @@ -15,7 +15,7 @@ """Implementation of py_cc_toolchain rule. NOTE: This is a beta-quality feature. APIs subject to change until -https://github.com/bazel-contrib/rules_python/issues/824 is considered done. +{gh-issue}`824` is considered done. """ load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") diff --git a/python/private/py_exec_tools_info.bzl b/python/private/py_exec_tools_info.bzl index 470c0c1bf0..43213d5a13 100644 --- a/python/private/py_exec_tools_info.bzl +++ b/python/private/py_exec_tools_info.bzl @@ -41,7 +41,7 @@ toolchain. :::{warning} This does not work correctly with RBE. Use {obj}`exec_runtime` instead. -Once https://github.com/bazelbuild/bazel/issues/23620 is resolved this warning +Once [bazelbuild/bazel#23620](https://github.com/bazelbuild/bazel/issues/23620) is resolved this warning may be removed. ::: """, diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 1ee6fd7c99..2ac2423b86 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -98,7 +98,7 @@ EXECUTABLE_ATTRS = dicts.add( Arguments that are only applicable to the interpreter. The args an interpreter supports are specific to the interpreter. For -CPython, see https://docs.python.org/3/using/cmdline.html. +CPython, see . :::{note} Only supported for {obj}`--bootstrap_impl=script`. Ignored otherwise. @@ -147,7 +147,7 @@ Module name to execute as the main program. When set, `srcs` is not required, and it is assumed the module is provided by a dependency. -See https://docs.python.org/3/using/cmdline.html#cmdoption-m for more +See for more information about running modules as the main program. This is mutually exclusive with {obj}`main`. diff --git a/python/private/py_runtime_rule.bzl b/python/private/py_runtime_rule.bzl index b3399a24fb..4f450c4c9b 100644 --- a/python/private/py_runtime_rule.bzl +++ b/python/private/py_runtime_rule.bzl @@ -422,7 +422,7 @@ The template to use when two stage bootstrapping is enabled "Shebang" expression prepended to the bootstrapping Python stub script used when executing {rule}`py_binary` targets. -See https://github.com/bazelbuild/bazel/issues/8685 for +See [bazelbuild/bazel#8685](https://github.com/bazelbuild/bazel/issues/8685) for motivation. Does not apply to Windows. diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl index b622411c56..c28aefff48 100644 --- a/python/private/py_wheel.bzl +++ b/python/private/py_wheel.bzl @@ -54,7 +54,7 @@ Workspace status keys are expanded using `{NAME}` format, for example: - `distribution = "package.{CLASSIFIER}"` - `distribution = "{DISTRIBUTION}"` -For the available keys, see https://bazel.build/docs/user-manual#workspace-status +For the available keys, see """, ), "platform": attr.string( @@ -196,7 +196,7 @@ The {attr}`add_path_prefix` attribute was added. default = "", ), "classifiers": attr.string_list( - doc = "A list of strings describing the categories for the package. For valid classifiers see https://pypi.org/classifiers", + doc = "A list of strings describing the categories for the package. For valid classifiers see ", ), "data_files": attr.label_keyed_string_dict( doc = (""" @@ -233,7 +233,7 @@ be moved under that directory. "description_content_type": attr.string( doc = ("The type of contents in description_file. " + "If not provided, the type will be inferred from the extension of description_file. " + - "Also see https://packaging.python.org/en/latest/specifications/core-metadata/#description-content-type"), + "Also see "), ), "description_file": attr.label( doc = "A file containing text describing the package.", @@ -332,7 +332,7 @@ def _escape_filename_distribution_name(name): def _escape_filename_segment(segment): """Escape a segment of the wheel filename. - See https://www.python.org/dev/peps/pep-0427/#escaping-and-unicode + See {pep}`PEP 427` """ # TODO: this is wrong, isalnum replaces non-ascii letters, while we should diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index 775aa0d9d2..bb84ff9280 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -522,6 +522,12 @@ bzl_library( deps = ["//python/private:envsubst"], ) +bzl_library( + name = "index_sources", + srcs = ["index_sources.bzl"], + deps = [":hash"], +) + bzl_library( name = "argparse", srcs = ["argparse.bzl"], @@ -542,12 +548,6 @@ bzl_library( srcs = ["hash.bzl"], ) -bzl_library( - name = "index_sources", - srcs = ["index_sources.bzl"], - deps = [":hash"], -) - bzl_library( name = "labels", srcs = ["labels.bzl"], diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index 210e169d4d..caca92a5f7 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -690,7 +690,7 @@ This value is going to be subject to `envsubst` substitutions if necessary, look {attr}`pip.parse.envsubst` documentation for more information.. The indexes must support Simple API as described here: -https://packaging.python.org/en/latest/specifications/simple-repository-api/ + Index metadata will be used to get the hash digest values for packages even if the `--hash` values are not present in the requirements.txt lock file. @@ -867,10 +867,10 @@ index URL. This design pattern has been chosen in order to be fully deterministic about which packages come from which source. We want to avoid issues similar to what happened in -https://pytorch.org/blog/compromised-nightly-dependency/. +. The indexes must support Simple API as described here: -https://packaging.python.org/en/latest/specifications/simple-repository-api/ + """, ), "hub_name": attr.string( diff --git a/python/private/pypi/pip_compile.bzl b/python/private/pypi/pip_compile.bzl index 58f7ba3a59..1bec3435a4 100644 --- a/python/private/pypi/pip_compile.bzl +++ b/python/private/pypi/pip_compile.bzl @@ -69,12 +69,12 @@ def pip_compile( defaults to `["pyproject.toml"]`. Supported formats are: * a requirements text file, usually named `requirements.in` * A `.toml` file, where the `project.dependencies` list is used as per - [PEP621](https://peps.python.org/pep-0621/). + {pep}`PEP 621`. src: file containing inputs to dependency resolution. If not specified, defaults to `pyproject.toml`. Supported formats are: * a requirements text file, usually named `requirements.in` * A `.toml` file, where the `project.dependencies` list is used as per - [PEP621](https://peps.python.org/pep-0621/). + {pep}`PEP 621`. extra_args: passed to pip-compile (aka `piptools`). See the [pip-compile docs](https://pip-tools.readthedocs.io/en/latest/cli/pip-compile) for args and meaning (passing `-h` and/or `--version` can help diff --git a/python/private/pypi/pip_repository.bzl b/python/private/pypi/pip_repository.bzl index 5fcd351958..436af73c8d 100644 --- a/python/private/pypi/pip_repository.bzl +++ b/python/private/pypi/pip_repository.bzl @@ -360,13 +360,13 @@ In some cases you may not want to generate the requirements.bzl file as a reposi while Bazel is fetching dependencies. For example, if you produce a reusable Bazel module such as a ruleset, you may want to include the requirements.bzl file rather than make your users install the WORKSPACE setup to generate it. -See https://github.com/bazel-contrib/rules_python/issues/608 +See {gh-issue}`608` This is the same workflow as Gazelle, which creates `go_repository` rules with [`update-repos`](https://github.com/bazelbuild/bazel-gazelle#update-repos) To do this, use the "write to source file" pattern documented in -https://blog.aspect.dev/bazel-can-write-to-the-source-folder + to put a copy of the generated requirements.bzl into your project. Then load the requirements.bzl file directly rather than from the generated repository. See the example in rules_python/examples/pip_parse_vendored. diff --git a/python/private/zipapp/py_zipapp_rule.bzl b/python/private/zipapp/py_zipapp_rule.bzl index 4d31c99311..b1a9399e90 100644 --- a/python/private/zipapp/py_zipapp_rule.bzl +++ b/python/private/zipapp/py_zipapp_rule.bzl @@ -344,7 +344,7 @@ These values are transitioned on, so will affect the analysis graph and the associated memory overhead. The more unique configurations in your overall build, the more memory and (often unnecessary) re-analysis and re-building can occur. See -https://bazel.build/extending/config#memory-performance-considerations for + for more information about risks and considerations. ::: """, diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index 7236d4b851..16afeea47c 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -614,7 +614,7 @@ def CurrentRepository(self, frame: int = 1) -> str: More information about the difference between canonical repository names and the `@repo` part of labels is available at: - https://bazel.build/build/bzlmod#repository-names + NOTE: This function inspects the callstack to determine where in the runfiles the caller is located to determine which repository it came From 61955f2413e6a1c63a6458b0dfdb23e01ed081a0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 1 Aug 2026 19:49:48 -0700 Subject: [PATCH 135/179] docs(cc): clarify abi_tag is equivalent to SOABI and cite PEP 3149 (#3985) Clarify the documentation for `abi_tag` in C++ Python toolchains so that rule maintainers and users can easily map Bazel's toolchain attributes to standard Python distribution environment variables. Update docstrings in `python/private/py_cc_toolchain_info.bzl` and `python/private/py_cc_toolchain_rule.bzl` to explicitly note that `abi_tag` is the equivalent of the Python `sysconfig` variable `SOABI`, accompanied by a reference citation to PEP 3149. Also add `:::{versionadded} VERSION_NEXT_FEATURE` directives to all newly introduced toolchain fields and rule attributes. --- python/private/py_cc_toolchain_info.bzl | 19 ++++++++++++++++++- python/private/py_cc_toolchain_rule.bzl | 25 +++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl index 7f443d05a9..19dc5de482 100644 --- a/python/private/py_cc_toolchain_info.bzl +++ b/python/private/py_cc_toolchain_info.bzl @@ -21,11 +21,19 @@ PyCcToolchainInfo = provider( :type: str The runtime's ABI flags, i.e. `sys.abiflags` (e.g. 't' for free-threaded builds). + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, "abi_tag": """\ :type: str -The ABI tag for extension modules, e.g. 'cpython-311' or 'cpython-313t'. +The ABI tag for extension modules, equivalent to the `SOABI` sysconfig var +(see [PEP 3149](https://peps.python.org/pep-3149/)), e.g. 'cpython-311' or +'cpython-313t'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, "headers": """\ :type: struct @@ -107,12 +115,18 @@ If available, information about C libraries, struct with fields: The {pep}`PEP 508` `platform_machine` marker value for the target architecture, e.g. 'x86_64', 'aarch64'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, "platform_tag": """\ :type: str | None The PEP 3149 / PEP 425 platform tag for extension modules, e.g. 'x86_64-linux-gnu', 'darwin', or 'win_amd64'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, "python_version": """ :type: str @@ -124,6 +138,9 @@ The Python Major.Minor version. The {pep}`0508` `sys_platform` marker value for the target OS, e.g. 'linux', 'darwin', 'win32'. + +:::{versionadded} 2.2.0 +::: """, }, ) diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl index 76528654b1..a6cb29635c 100644 --- a/python/private/py_cc_toolchain_rule.bzl +++ b/python/private/py_cc_toolchain_rule.bzl @@ -144,10 +144,20 @@ The runtime's ABI flags, i.e. `sys.abiflags`. If not set, or set to ``, the ABI flags are automatically derived from `--//python/config_settings:py_freethreaded` (e.g., `'t'` when free-threaded is enabled, or `''` otherwise). + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, ), "abi_tag": attr.string( - doc = "The ABI tag for extension modules, e.g. 'cpython-311'", + doc = """\ +The ABI tag for extension modules, equivalent to the `SOABI` sysconfig var +(see [PEP 3149](https://peps.python.org/pep-3149/)), e.g. 'cpython-311' or +'cpython-313t'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", default = "", ), "headers": attr.label( @@ -171,7 +181,12 @@ attribute is available or not. providers = [[SentinelInfo], [CcInfo]], ), "libc": attr.string( - doc = "Target C library variant, e.g. 'glibc', 'musl'", + doc = """\ +Target C library variant, e.g. 'glibc', 'musl'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", default = "", ), "libs": attr.label( @@ -182,6 +197,9 @@ attribute is available or not. "platform_machine": attr.string( doc = """ Target architecture as a PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarch64', 'x86_32'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, default = "", ), @@ -192,6 +210,9 @@ Target architecture as a PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarc "sys_platform": attr.string( doc = """ Target OS as a PEP 508 `sys_platform` marker, e.g. 'linux', 'darwin', 'win32'. + +:::{versionadded} 2.2.0 +::: """, default = "", ), From 80867901ff4f472d5df97c2e74efdae377e34d6c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 2 Aug 2026 17:09:17 -0700 Subject: [PATCH 136/179] agents(monitor-ci-results): ignore superseded retried Buildkite jobs in CI monitor (#3991) When a Buildkite job fails and is subsequently retried, both the original failed job and the retried green job remain in `data/jobs.json`. This betrayal by superseded failed jobs causes false-positive failure reporting on builds that are actually green. To fix this, update the job aggregation logic in the CI monitor: * Skip jobs in `data/jobs.json` where `retried_in_job_uuid` or `retried_at` is set so that only active, non-superseded jobs are evaluated. * Add `bucket` to the `--json` output fields in `gh pr checks` to provide additional check state metadata. --- .../skills/monitor-ci-results/scripts/monitor_remote_ci.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py index 9b5970757e..57fde0ce8d 100755 --- a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py +++ b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py @@ -27,7 +27,7 @@ def get_pr_checks(pr_number): if not check_cli("gh"): print("❌ 'gh' CLI not installed.", file=sys.stderr) return [] - cmd = ["gh", "pr", "checks", str(pr_number), "--json", "name,link,state"] + cmd = ["gh", "pr", "checks", str(pr_number), "--json", "name,link,state,bucket"] try: res = subprocess.run(cmd, capture_output=True, text=True) out = res.stdout @@ -157,6 +157,8 @@ def main(): other = 0 for job in jobs: + if job.get("retried_in_job_uuid") or job.get("retried_at"): + continue jstate = job.get("state", "unknown") exit_status = job.get("exit_status") is_soft_failed = job.get("soft_failed") is True @@ -194,6 +196,8 @@ def main(): ) for job in jobs: + if job.get("retried_in_job_uuid") or job.get("retried_at"): + continue jname = job.get("name", "unknown_job") jstate = job.get("state", "unknown") jid = job.get("id", "") From 18846e31aa3b8d68d2069665596f625d1d8fde50 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 2 Aug 2026 22:22:17 -0700 Subject: [PATCH 137/179] agents: add concurrent multi-subagent pre-review audit to create-pr skill (#3992) To catch oversights early and prevent low-quality pull requests from reaching reviewers, the PR creation process needs an automated quality gate. Without pre-review validation, common formatting, documentation, and testing errors can easily escape detection and cause unnecessary review iterations. This change integrates an automated, concurrent multi-subagent pre-review quality audit directly into the PR creation workflow: * Adds `.agents/rules/docs.md` and updates `.agents/rules/python.md` to define explicit domain standards for documentation and Python code. * Introduces `.agents/skills/pre-review-audit/SKILL.md` along with 6 specialized domain audit prompt files to inspect diffs across distinct technical areas concurrently. * Updates `.agents/skills/create-pr/SKILL.md` so that `create-pr` automatically spawns concurrent subagents to execute the pre-review audit before a PR is opened, strictly wrapping PR body text at 72 columns max. --- .agents/rules/docs.md | 15 +++++++ .agents/skills/create-pr/SKILL.md | 18 ++++++--- .agents/skills/pre-review-audit/SKILL.md | 40 +++++++++++++++++++ .../pre-review-audit/review-agents-prompt.md | 10 +++++ .../review-contributing-prompt.md | 9 +++++ .../pre-review-audit/review-docs-prompt.md | 12 ++++++ .../review-pr-standards-prompt.md | 9 +++++ .../pre-review-audit/review-python-prompt.md | 9 +++++ .../review-starlark-prompt.md | 11 +++++ 9 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 .agents/rules/docs.md create mode 100644 .agents/skills/pre-review-audit/SKILL.md create mode 100644 .agents/skills/pre-review-audit/review-agents-prompt.md create mode 100644 .agents/skills/pre-review-audit/review-contributing-prompt.md create mode 100644 .agents/skills/pre-review-audit/review-docs-prompt.md create mode 100644 .agents/skills/pre-review-audit/review-pr-standards-prompt.md create mode 100644 .agents/skills/pre-review-audit/review-python-prompt.md create mode 100644 .agents/skills/pre-review-audit/review-starlark-prompt.md diff --git a/.agents/rules/docs.md b/.agents/rules/docs.md new file mode 100644 index 0000000000..99ce5608ac --- /dev/null +++ b/.agents/rules/docs.md @@ -0,0 +1,15 @@ +--- +trigger: glob +description: Documentation formatting, Sphinx MyST style rules, and documentation build correctness +globs: docs/*.md +--- + +# Documentation Rules + +* Act as an expert in tech writing, Sphinx, MyST, and markdown. +* Wrap lines at 80 columns. +* Use hyphens (`-`) in file names instead of underscores (`_`). +* In Sphinx MyST markup, outer directives must have more colons than inner directives. +* When adding `{versionadded}` or `{versionchanged}` sections, add them at the end of the documentation text. +* For unreleased features or attributes, use `VERSION_NEXT_FEATURE` in `{versionadded}` / `{versionchanged}` directives. +* **Documentation Build Correctness**: Ensure new `.bzl` files or user-facing APIs are properly registered in documentation build targets (e.g. `//docs:docs` or relevant Starlark API reference generation configs). diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md index 9c2f454000..3510b15d07 100644 --- a/.agents/skills/create-pr/SKILL.md +++ b/.agents/skills/create-pr/SKILL.md @@ -9,9 +9,14 @@ to handle PR creation or description drafting. ### Instructions -1. Launch a subagent using `invoke_subagent` with `TypeName: "self"` (or +1. **Pre-Review Audit Subagent**: Before drafting or creating a PR, launch a + subagent to run the `pre-review-audit` skill on local changes (`git diff`). + Verify all checks pass (e.g., `VERSION_NEXT_FEATURE` directives, no Bazel + copyright headers, line wrapping, Starlark formatting). Fix any issues + found before proceeding. +2. Launch a subagent using `invoke_subagent` with `TypeName: "self"` (or `agentapi new-conversation`). -2. Provide a prompt to the subagent directing it to: +3. Provide a prompt to the subagent directing it to: - Include `@/.agents/rules/pr.md` and read `CONTRIBUTING.md` (specifically the sections on **Commit messages and PR descriptions** and **Documenting changes**) before drafting. @@ -19,6 +24,9 @@ to handle PR creation or description drafting. - **PR Title**: Follow conventional commit style and title formatting. For agent rules, skills, and system updates, use `agents:` prefix. - **PR Body**: Include rationale, high-level summary, and structure. + **CRITICAL**: Strictly wrap all PR body text at 72 columns max + (GitHub uses the PR description as the commit message upon merge, + which reflows text at 72 columns). - **Formatting**: Follow repository style guidelines and structure. - Create a Markdown artifact (`pr_info.md`) containing the PR title, body, and link/metadata so the user can review and comment on it. @@ -29,12 +37,12 @@ to handle PR creation or description drafting. - **Targeting Upstream Repo**: When executing `gh pr create`, always target the upstream repository by passing `--repo bazel-contrib/rules_python` and `--head :`. -3. **Return Status**: Direct the subagent to communicate the PR number or draft +4. **Return Status**: Direct the subagent to communicate the PR number or draft status back using `send_message` (or `agentapi send-message`) with the parent conversation ID, or include it in its final completion response. -4. **Publish Artifact**: Upon receiving the subagent completion message, the +5. **Publish Artifact**: Upon receiving the subagent completion message, the main agent must publish `pr_info.md` to display the artifact directly in the primary user UI. -5. **Interactive Actions**: To present custom action choices to the user +6. **Interactive Actions**: To present custom action choices to the user (e.g., "Create PR", "Create Draft PR"), the main agent can use the `ask_question` tool with custom options. diff --git a/.agents/skills/pre-review-audit/SKILL.md b/.agents/skills/pre-review-audit/SKILL.md new file mode 100644 index 0000000000..1c0b16e273 --- /dev/null +++ b/.agents/skills/pre-review-audit/SKILL.md @@ -0,0 +1,40 @@ +--- +name: pre-review-audit +description: High-level pre-review audit of local changes when preparing to send a PR for review +trigger: model_decision +--- + +When preparing to send a Pull Request for review, invoke **separate, concurrent sub-agents** (`invoke_subagent`), where each sub-agent focuses exclusively on its assigned checklist category. + +**CRITICAL**: Do NOT use a single sub-agent to validate multiple or all dimensions at once. Each sub-agent must be launched with its own distinct prompt file from `.agents/skills/pre-review-audit/`: + +### Audit Checklist & Sub-Agent Prompts + +1. **Starlark / Bazel Sub-Agent** (`Role: "Starlark Code Auditor"`): + - Prompt file: `.agents/skills/pre-review-audit/review-starlark-prompt.md` + - Focus: Audits Starlark / Bazel changes (`*.bzl`, `BUILD`, `*.bazel` files) in `git diff` against `.agents/rules/bzl.md` and Starlark rules in `AGENTS.md`. + +2. **Python Code Sub-Agent** (`Role: "Python Code Auditor"`): + - Prompt file: `.agents/skills/pre-review-audit/review-python-prompt.md` + - Focus: Audits Python source and test changes in `git diff` against `.agents/rules/python.md` and Python and pytest conventions in `AGENTS.md`. + +3. **Documentation Sub-Agent** (`Role: "Documentation Auditor"`): + - Prompt file: `.agents/skills/pre-review-audit/review-docs-prompt.md` + - Focus: Audits documentation (`.md`) changes and docs build targets against `.agents/rules/docs.md` and `AGENTS.md`. + +4. **Contribution Guidelines Sub-Agent** (`Role: "Contributing Auditor"`): + - Prompt file: `.agents/skills/pre-review-audit/review-contributing-prompt.md` + - Focus: Audits changes, requirements updates, and directives against `CONTRIBUTING.md`. + +5. **Project Conventions Sub-Agent** (`Role: "Project Conventions Auditor"`): + - Prompt file: `.agents/skills/pre-review-audit/review-agents-prompt.md` + - Focus: Audits overall workspace compliance against `AGENTS.md`. + +6. **Pull Request Standards Sub-Agent** (`Role: "PR Standards Auditor"`): + - Prompt file: `.agents/skills/pre-review-audit/review-pr-standards-prompt.md` + - Focus: Audits PR titles and descriptions against Conventional Commits formatting and PR update rules in `CONTRIBUTING.md`. + +### Action Instructions +- Launch all sub-agents concurrently using `invoke_subagent`. +- Collect the reports from each sub-agent and summarize any violations clearly with suggested fixes for the user. +- If all domain audits pass, confirm that the PR is ready for review. diff --git a/.agents/skills/pre-review-audit/review-agents-prompt.md b/.agents/skills/pre-review-audit/review-agents-prompt.md new file mode 100644 index 0000000000..f81924224c --- /dev/null +++ b/.agents/skills/pre-review-audit/review-agents-prompt.md @@ -0,0 +1,10 @@ +You are a specialized Project Conventions (`AGENTS.md`) Auditor sub-agent. +Your sole task is to audit all local changes (`git diff`) and workspace state against `AGENTS.md`: + +1. Read and strictly enforce `AGENTS.md` and all `.agents/rules/*.md` files without exception. +2. Verify NO Bazel copyright headers (`# Copyright ... The Bazel Authors`) were added to new or existing files, unless explicitly instructed by the user. +3. Verify that tests were executed using `bazel test --config=fast-tests` and non-test build targets did not use `--config=fast-tests`. +4. Ensure public config settings in `python/config_settings/BUILD.bazel` were not modified unless explicitly instructed. +5. Check that all repo rules and macro conventions described in `AGENTS.md` are respected. + +Report any violations found clearly with actionable suggested fixes, or report that the changes pass project conventions audit. diff --git a/.agents/skills/pre-review-audit/review-contributing-prompt.md b/.agents/skills/pre-review-audit/review-contributing-prompt.md new file mode 100644 index 0000000000..10a719284a --- /dev/null +++ b/.agents/skills/pre-review-audit/review-contributing-prompt.md @@ -0,0 +1,9 @@ +You are a specialized Contribution Guidelines Auditor sub-agent. +Your sole task is to audit all local changes (`git diff`), commit messages, and PR metadata against `CONTRIBUTING.md`: + +1. Read and strictly enforce `CONTRIBUTING.md`, all `.agents/rules/*.md` files, and `AGENTS.md`. +2. Verify that `{versionadded}` and `{versionchanged}` directives use `VERSION_NEXT_FEATURE` for unreleased features. +3. If locked/resolved requirements files (`requirements.txt`, `pyproject.toml`, `requirements.in`) were modified, verify that the associated `requirements.update` target was executed to keep locked requirement files in sync. +4. Ensure style and conventions described in `CONTRIBUTING.md` are respected across the changes. + +Report any violations found clearly with actionable suggested fixes, or report that the changes pass contribution audit. diff --git a/.agents/skills/pre-review-audit/review-docs-prompt.md b/.agents/skills/pre-review-audit/review-docs-prompt.md new file mode 100644 index 0000000000..02867940ed --- /dev/null +++ b/.agents/skills/pre-review-audit/review-docs-prompt.md @@ -0,0 +1,12 @@ +You are a specialized Documentation & Sphinx/MyST Auditor sub-agent. +Your sole task is to audit all documentation (`.md`) changes and new `.bzl` APIs in `git diff` against the project's documentation rules: + +1. Read and strictly enforce `.agents/rules/docs.md`, `AGENTS.md`, and `CONTRIBUTING.md`. +2. Check that lines wrap at 80 columns. +3. Ensure markdown filenames use hyphens (`-`) rather than underscores (`_`). +4. Verify Sphinx MyST colon indentation hierarchy (outer directives must have more colons than inner directives). +5. Verify `{versionadded}` and `{versionchanged}` sections are placed at the end of the documentation text. +6. For unreleased features or attributes, ensure `{versionadded}` / `{versionchanged}` directives use `VERSION_NEXT_FEATURE` (not hardcoded version numbers). +7. Check documentation build correctness: ensure new `.bzl` files or public APIs are included in `//docs:docs` or relevant docs build targets. + +Report any violations found clearly with actionable suggested fixes, or report that the documentation changes pass audit. diff --git a/.agents/skills/pre-review-audit/review-pr-standards-prompt.md b/.agents/skills/pre-review-audit/review-pr-standards-prompt.md new file mode 100644 index 0000000000..6120b412af --- /dev/null +++ b/.agents/skills/pre-review-audit/review-pr-standards-prompt.md @@ -0,0 +1,9 @@ +You are a specialized PR Standards Auditor sub-agent. +Your sole task is to audit PR titles and PR descriptions against `CONTRIBUTING.md` and project rules: + +1. Read and strictly enforce `.agents/rules/pr.md`, `.agents/rules/news.md`, `AGENTS.md`, and `CONTRIBUTING.md`. +2. Check PR title formatting: must follow Conventional Commits format (e.g. `feat(cc): ...`, `docs(python): ...`). For agent rules/skills, use `agents:` prefix. +3. Ensure PR descriptions explain *why* a change is made and provide a high-level overview of *how*, following advice in `CONTRIBUTING.md`. +4. If a PR has already been created, enforce PR update rules: do NOT amend or rebase existing commits (create new commits and merges instead, to preserve code review comment threads). + +Report any violations found clearly with actionable suggested fixes, or report that the PR standards pass audit. diff --git a/.agents/skills/pre-review-audit/review-python-prompt.md b/.agents/skills/pre-review-audit/review-python-prompt.md new file mode 100644 index 0000000000..aa2d726228 --- /dev/null +++ b/.agents/skills/pre-review-audit/review-python-prompt.md @@ -0,0 +1,9 @@ +You are a specialized Python & pytest Auditor sub-agent. +Your sole task is to audit all Python source (`.py`) and test changes in `git diff` against the project's Python conventions: + +1. Read and strictly enforce `.agents/rules/python.md`, `AGENTS.md`, and `CONTRIBUTING.md`. +2. Check Python pytest conventions: when registering pytest fixtures from helper modules in test files, use `pytest_plugins = [""]`. +3. Name fixture functions with a `fixture_` prefix (e.g. `def fixture_foo():`) and pass the public fixture name using `@pytest.fixture(name="foo")`. +4. Verify that tests were executed using Bazel (`bazel test --config=fast-tests`) and passed. + +Report any violations found clearly with actionable suggested fixes, or report that the Python changes pass audit. diff --git a/.agents/skills/pre-review-audit/review-starlark-prompt.md b/.agents/skills/pre-review-audit/review-starlark-prompt.md new file mode 100644 index 0000000000..8421219a36 --- /dev/null +++ b/.agents/skills/pre-review-audit/review-starlark-prompt.md @@ -0,0 +1,11 @@ +You are a specialized Starlark & Bazel Code Auditor sub-agent. +Your sole task is to audit all Starlark (`.bzl`, `BUILD`, `*.bazel`) changes in `git diff` against the project's Starlark coding rules and conventions: + +1. Read and strictly enforce `.agents/rules/starlark.md`, `.agents/rules/bzl.md`, `AGENTS.md`, and `CONTRIBUTING.md`. +2. Verify iterative algorithms are used (no recursion, no `while` loops). +3. Ensure every `.bzl` file outside `tests/` has a corresponding `bzl_library` target in its `BUILD` file with proper dependencies. +4. Ensure loads from `/private/` in test files have `# buildifier: disable=bzl-visibility`. +5. Check multi-line rule/macro doc arguments: use triple-quoted strings (`"""`), and do NOT use trailing backslashes (`\`) on opening triple-quotes. +6. Verify analysis tests use `rules_testing`, not `bazel_skylib`. + +Report any violations found clearly with actionable suggested fixes, or report that the Starlark changes pass audit. From 16e167a5b269930f2f5000c4d9dc99b72f930f2b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 3 Aug 2026 06:09:45 -0700 Subject: [PATCH 138/179] fix(sphinxdocs): include stderr in Sphinx worker response output (#3994) When running Sphinx in persistent worker mode, warnings and error messages emitted to stderr during build execution were not included in the worker response output sent back to Bazel. This made it difficult to diagnose build warnings or diagnostic messages printed to stderr when the worker succeeded with exit code 0. To fix this, include both stdout and stderr in the worker response's output dictionary with clear `--- STDOUT ---` and `--- STDERR ---` separators when stderr is present, and document why stderr is included. Work towards #3977. --- sphinxdocs/sphinxdocs/private/sphinx_build.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sphinxdocs/sphinxdocs/private/sphinx_build.py b/sphinxdocs/sphinxdocs/private/sphinx_build.py index 790d4dff84..8b0eec8fd3 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx_build.py +++ b/sphinxdocs/sphinxdocs/private/sphinx_build.py @@ -267,9 +267,19 @@ def _process_request(self, request: "WorkRequest") -> "WorkResponse | None": # implicily bring along what the symlinks point to. shutil.copytree(worker_outdir, bazel_outdir, dirs_exist_ok=True) + # Include both stdout and stderr in the response output so that Sphinx + # warnings or diagnostic messages written to stderr are reported to the + # Bazel console even when the build succeeds. + stdout_output = stdout.getvalue() + stderr_output = stderr.getvalue() + if stderr_output: + output = f"--- STDOUT ---\n{stdout_output}\n--- STDERR ---\n{stderr_output}" + else: + output = stdout_output + response = { "requestId": request.get("requestId", 0), - "output": stdout.getvalue(), + "output": output, "exitCode": 0, } return response From 8b460f82a5379a0fc1a2fe9d93f8c6905e88a49d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 3 Aug 2026 16:33:27 -0700 Subject: [PATCH 139/179] agents(pr): prohibit per-file edit lists in PR descriptions (#3996) Update the pull request rules to explicitly prohibit per-file edit lists or individual file changelog items in PR descriptions and commit messages. Reinforce that PR descriptions must provide a high-level overview explaining why a change is made and how at a conceptual level, and mandate linking related issues. --- .agents/rules/pr.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/rules/pr.md b/.agents/rules/pr.md index ba8236d072..caacb07519 100644 --- a/.agents/rules/pr.md +++ b/.agents/rules/pr.md @@ -1,6 +1,6 @@ --- trigger: model_decision -description: Apply when drafting pull request descriptions. +description: rules to apply to pull request descriptions --- @CONTRIBUTING.md @@ -18,3 +18,7 @@ Before drafting any pull request description, strictly adhere to the rules in * Once a Pull Request is created, always make new commits or merge commits. * **NEVER** amend or rebase commits on an active PR branch to avoid breaking code review threads. +* **NEVER** include a list of per-file edits or changelog bullet points of + individual file modifications in PR descriptions or commit messages. +* High-level overview only: state *why* the change is made and *how* at a + conceptual level. Link related issues (e.g. `Work towards #`). From 874a6832c3bb20d623e91e1db516e9baa3c31e49 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 3 Aug 2026 19:56:21 -0700 Subject: [PATCH 140/179] agents: explain force-merging while requiring user consent (#3998) The merge-pr skill tells agents how to force merge pull requests using gh pr merge --admin, but also explicitly requires that agents obtain user consent before bypassing merge queues or required checks. --- .agents/skills/merge-pr/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.agents/skills/merge-pr/SKILL.md b/.agents/skills/merge-pr/SKILL.md index 73086759ed..30b8f38af4 100644 --- a/.agents/skills/merge-pr/SKILL.md +++ b/.agents/skills/merge-pr/SKILL.md @@ -6,6 +6,11 @@ description: Merge a pull request into main, monitoring the merge queue, retryin When the user asks to merge a pull request (e.g., "merge PR ", "merge this PR", or monitor its merge): 1. **Enqueue for Merge**: Run `gh pr merge --auto --squash` to enable auto-merge or add the pull request to the merge queue. + - **Force / Admin Merge**: **CRITICAL**: Passing `--admin` to bypass the + merge queue or required checks requires explicit user permission or + consent. Only pass `--admin` (e.g., `gh pr merge --admin + --squash`) if the user has explicitly requested or approved a force/admin + merge. Never invoke `--admin` autonomously. 2. **Invoke a Background Shepherd**: Launch a background subagent with the role `Merge PR Shepherd` to continuously watch the PR until it merges. 3. **Leverage Existing CI Skills**: - Have the subagent use the **`monitor-ci-results`** skill to watch for CI check failures and generate analysis reports. From c73f93199b7d99091b3cca8822b324f942080f89 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 3 Aug 2026 21:44:54 -0700 Subject: [PATCH 141/179] feat(cc): populate abi and platform settings on local py_cc_toolchain (#3990) feat(cc): populate abi and platform settings on local py_cc_toolchain The introduction of `py_extension` added `abi_flags`, `abi_tag`, `platform_machine`, and `sys_platform` to `py_cc_toolchain`. Local Python runtime toolchains did not populate these fields, causing `py_extension` builds to fail when using local toolchains. Extract `sys_platform`, `platform_machine`, and `abi_tag` (from `sysconfig` `SOABI`) in `get_local_runtime_info.py`, pass them through `local_runtime_repo.bzl`, and set them on `py_cc_toolchain` in `local_runtime_repo_setup.bzl`. --- python/private/get_local_runtime_info.py | 4 ++++ python/private/local_runtime_repo.bzl | 9 +++++++++ python/private/local_runtime_repo_setup.bzl | 12 +++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/python/private/get_local_runtime_info.py b/python/private/get_local_runtime_info.py index 787fad5635..340ef934f7 100644 --- a/python/private/get_local_runtime_info.py +++ b/python/private/get_local_runtime_info.py @@ -16,6 +16,7 @@ import glob import json import os +import platform import sys import sysconfig from typing import Any @@ -247,6 +248,7 @@ def _unique_basenames(inputs: dict[str, None]) -> list[str]: "abi_dynamic_libraries": _unique_basenames(abi_dynamic_libraries), "abi_interface_libraries": _unique_basenames(abi_interface_libraries), "abi_flags": abi_flags, + "abi_tag": config_vars.get("SOABI") or "", "shlib_suffix": ".dylib" if _IS_DARWIN else "", "additional_dlls": dlls, "defines": defines, @@ -265,6 +267,8 @@ def _get_base_executable() -> str: "include": sysconfig.get_path("include"), "implementation_name": sys.implementation.name, "base_executable": _get_base_executable(), + "sys_platform": sys.platform, + "platform_machine": platform.machine(), } data.update(_get_python_library_info(_get_base_executable())) print(json.dumps(data)) diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl index 37b7d2b130..48fc576bb7 100644 --- a/python/private/local_runtime_repo.bzl +++ b/python/private/local_runtime_repo.bzl @@ -35,6 +35,7 @@ define_local_runtime_toolchain_impl( minor = "{minor}", micro = "{micro}", abi_flags = "{abi_flags}", + abi_tag = "{abi_tag}", os = "{os}", implementation_name = "{implementation_name}", interpreter_path = "{interpreter_path}", @@ -44,6 +45,8 @@ define_local_runtime_toolchain_impl( abi3_interface_library = {abi3_interface_library}, abi3_libraries = {abi3_libraries}, additional_dlls = {additional_dlls}, + sys_platform = "{sys_platform}", + platform_machine = "{platform_machine}", ) """ @@ -53,6 +56,7 @@ def _expand_incompatible_template(): minor = "0", micro = "0", abi_flags = "", + abi_tag = "", os = "@platforms//:incompatible", implementation_name = "incompatible", interpreter_path = "/incompatible", @@ -62,6 +66,8 @@ def _expand_incompatible_template(): abi3_interface_library = "None", abi3_libraries = "[]", additional_dlls = "[]", + sys_platform = "", + platform_machine = "", ) def _norm_path(path): @@ -210,6 +216,7 @@ def _local_runtime_repo_impl(rctx): minor = info["minor"], micro = info["micro"], abi_flags = info["abi_flags"], + abi_tag = info["abi_tag"], os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)), implementation_name = info["implementation_name"], interpreter_path = _norm_path(interpreter_path), @@ -219,6 +226,8 @@ def _local_runtime_repo_impl(rctx): abi3_interface_library = repr(abi3_interface_library), abi3_libraries = repr(abi3_libraries), additional_dlls = repr(additional_dlls), + sys_platform = info["sys_platform"], + platform_machine = info["platform_machine"], ) logger.debug(lambda: "BUILD.bazel\n{}".format(build_bazel)) diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl index 5cb7bda200..78c8bd0093 100644 --- a/python/private/local_runtime_repo_setup.bzl +++ b/python/private/local_runtime_repo_setup.bzl @@ -37,7 +37,10 @@ def define_local_runtime_toolchain_impl( defines, abi3_interface_library, abi3_libraries, - additional_dlls): + additional_dlls, + sys_platform = "", + platform_machine = "", + abi_tag = ""): """Defines a toolchain implementation for a local Python runtime. Generates public targets: @@ -71,6 +74,9 @@ def define_local_runtime_toolchain_impl( e.g. ["lib/python3.dll"] or ["lib/python3.so"] additional_dlls: `list[str]` Path[s] to additional DLLs. e.g. ["lib/msvcrt123.dll"] + sys_platform: `str` The PEP 508 `sys_platform` marker, e.g. 'linux', 'darwin', 'win32'. + platform_machine: `str` The PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarch64'. + abi_tag: `str` The ABI tag for extension modules, e.g. 'cpython-311'. """ major_minor = "{}.{}".format(major, minor) major_minor_micro = "{}.{}".format(major_minor, micro) @@ -183,10 +189,14 @@ def define_local_runtime_toolchain_impl( py_cc_toolchain( name = "py_cc_toolchain", + abi_flags = abi_flags, + abi_tag = abi_tag, headers = ":python_headers", headers_abi3 = ":python_headers_abi3", libs = ":libpython", + platform_machine = platform_machine, python_version = major_minor_micro, + sys_platform = sys_platform, visibility = ["//visibility:public"], ) From 7eb4a6bb3c3afb7800680905b9a92d9f9d352014 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 4 Aug 2026 06:01:46 -0700 Subject: [PATCH 142/179] refactor(cc): simplify _current_py_cc_libs_impl (#3993) Commit 0d6016ffe5307e2e7c30a90319f14f8efa801d2b accidentally added complex filtering logic for static libraries and DLLs to `_current_py_cc_libs_impl` in `python/private/current_py_cc_libs.bzl`. That extra filtering logic was actually a hack to support another hack in `py_extension`, and shouldn't have been added. To simplify, update `_current_py_cc_libs_impl` to return `py_cc_toolchain.libs.providers_map.values()` directly. Since Windows was relying on that hack, move it into a helper rule in the py_extension implementation. --- python/private/cc/py_extension_macro.bzl | 23 ++++++++++------ python/private/cc/py_extension_rule.bzl | 24 +++++++++++++++++ python/private/current_py_cc_libs.bzl | 34 +----------------------- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/python/private/cc/py_extension_macro.bzl b/python/private/cc/py_extension_macro.bzl index 7845a09ea2..b57e9a438e 100644 --- a/python/private/cc/py_extension_macro.bzl +++ b/python/private/cc/py_extension_macro.bzl @@ -8,7 +8,7 @@ load("@rules_cc//cc:cc_library.bzl", "cc_library") load("@rules_cc//cc:cc_shared_library.bzl", "cc_shared_library") load("//python/private:common_labels.bzl", "labels") load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs") -load(":py_extension_rule.bzl", "py_extension_wrapper") +load(":py_extension_rule.bzl", "py_extension_libs", "py_extension_wrapper") _EMPTY_CANONICAL_TARGET = str(Label("//python/private/cc:empty")) @@ -18,9 +18,6 @@ _PY_CC_HEADERS_ALIAS_CANONICAL_TARGET = str(Label(_PY_CC_HEADERS_ALIAS_BASE_TARG _PY_CC_LIBS_ALIAS_BASE_TARGET = "//python/private/cc:current_py_cc_libs_private_alias" _PY_CC_LIBS_ALIAS_CANONICAL_TARGET = str(Label(_PY_CC_LIBS_ALIAS_BASE_TARGET)) -_PY_CC_LIBS_ACTUAL_BASE_TARGET = "//python/cc:current_py_cc_libs" -_PY_CC_LIBS_ACTUAL_CANONICAL_TARGET = str(Label(_PY_CC_LIBS_ACTUAL_BASE_TARGET)) - def py_extension( name, srcs = None, @@ -148,22 +145,32 @@ def py_extension( "//conditions:default": [], }) + win_libs_name = "_" + name + "_win_libs" + + # On Windows, create a private target using toolchain resolution to extract .lib files + # from CcInfo in py_cc_toolchain because system_provided=True in cc_import leaves DefaultInfo empty. + py_extension_libs( + name = win_libs_name, + tags = ["manual"], + visibility = ["//visibility:private"], + ) + # Windows-specific CPython linking requirements: # 1. Windows requires .lib files when linking, so they must be added to deps. # 2. CPython import libraries (python3xx.lib) are declared with system_provided = True # in cc_import, suppressing automatic propagation of the .lib file path to link.exe. - # We explicitly pass $(locations ...) to provide the path of the CPython import library to MSVC link.exe. - # 3. We pass current_py_cc_libs as an additional linker input to ensure the .lib file is available to the link action. + # We explicitly pass $(locations ...) from win_libs_name to provide the path to MSVC link.exe. + # 3. We pass win_libs_name as an additional linker input to ensure the .lib file is available to the link action. deps = deps + select({ labels.PLATFORMS_OS_WINDOWS: [_PY_CC_LIBS_ALIAS_CANONICAL_TARGET], "//conditions:default": [], }) user_link_flags = user_link_flags + select({ - labels.PLATFORMS_OS_WINDOWS: ["$(locations " + _PY_CC_LIBS_ACTUAL_BASE_TARGET + ")"], + labels.PLATFORMS_OS_WINDOWS: ["$(locations :" + win_libs_name + ")"], "//conditions:default": [], }) additional_linker_inputs = additional_linker_inputs + select({ - labels.PLATFORMS_OS_WINDOWS: [_PY_CC_LIBS_ACTUAL_CANONICAL_TARGET], + labels.PLATFORMS_OS_WINDOWS: [":" + win_libs_name], "//conditions:default": [], }) diff --git a/python/private/cc/py_extension_rule.bzl b/python/private/cc/py_extension_rule.bzl index 1c5ee11a06..2b3a8284b7 100644 --- a/python/private/cc/py_extension_rule.bzl +++ b/python/private/cc/py_extension_rule.bzl @@ -133,3 +133,27 @@ def _get_platform(ctx): ), ) return py_cc_toolchain.platform_tag + +def _py_extension_libs_impl(ctx): + py_toolchain = ctx.toolchains[PY_CC_TOOLCHAIN_TYPE] + py_cc_toolchain = py_toolchain.py_cc_toolchain + cc_info = py_cc_toolchain.libs.providers_map["CcInfo"] + files = [] + for input in cc_info.linking_context.linker_inputs.to_list(): + for lib in input.libraries: + if lib.interface_library: + files.append(lib.interface_library) + elif lib.static_library: + files.append(lib.static_library) + elif lib.dynamic_library: + files.append(lib.dynamic_library) + link_files = [f for f in files if not f.path.endswith(".dll")] + return [DefaultInfo(files = depset(link_files))] + +py_extension_libs = rule( + implementation = _py_extension_libs_impl, + toolchains = [PY_CC_TOOLCHAIN_TYPE], + doc = """\ +Private internal helper rule for extracting Windows C/C++ library files from toolchain. +""", +) diff --git a/python/private/current_py_cc_libs.bzl b/python/private/current_py_cc_libs.bzl index 58ab4b1bd8..ca68346bcb 100644 --- a/python/private/current_py_cc_libs.bzl +++ b/python/private/current_py_cc_libs.bzl @@ -18,39 +18,7 @@ load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") def _current_py_cc_libs_impl(ctx): py_cc_toolchain = ctx.toolchains["//python/cc:toolchain_type"].py_cc_toolchain - providers = [p for p in py_cc_toolchain.libs.providers_map.values() if not hasattr(p, "data_runfiles")] - default_runfiles = None - data_runfiles = None - files = [] - for p in py_cc_toolchain.libs.providers_map.values(): - if hasattr(p, "data_runfiles"): - default_runfiles = p.default_runfiles - data_runfiles = p.data_runfiles - - cc_infos = [p for p in py_cc_toolchain.libs.providers_map.values() if hasattr(p, "linking_context")] - if cc_infos: - cc_info = cc_infos[0] - for input in cc_info.linking_context.linker_inputs.to_list(): - for lib in input.libraries: - if lib.static_library: - files.append(lib.static_library) - if lib.interface_library: - files.append(lib.interface_library) - elif lib.dynamic_library: - files.append(lib.dynamic_library) - - # On Windows MSVC, user_link_flags passes $(locations @rules_python//python/cc:current_py_cc_libs) - # to link.exe. MSVC link.exe accepts import libraries (.lib) but fails with - # LNK1107 if passed raw DLL binaries (.dll). We filter out .dll files so - # DefaultInfo.files only contains linkable library files (.lib / .a). - link_files = [f for f in files if not f.path.endswith(".dll")] - - providers.append(DefaultInfo( - files = depset(link_files), - default_runfiles = default_runfiles, - data_runfiles = data_runfiles, - )) - return providers + return py_cc_toolchain.libs.providers_map.values() current_py_cc_libs = rule( implementation = _current_py_cc_libs_impl, From 0118a70aedbb749133758ca553761cd55e44fa3c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 4 Aug 2026 06:04:16 -0700 Subject: [PATCH 143/179] docs(cc): add py_extension to doc generation and update versionadded markup (#3989) Add py_extension to Sphinx Stardoc generation in docs/BUILD.bazel and resolve transitive bzl_library dependencies in python/private/cc/BUILD.bazel so API reference generation succeeds. Update py_extension docstrings in python/cc/py_extension.bzl and python/private/cc/py_extension_macro.bzl to use `:::{versionadded} VERSION_NEXT_FEATURE` per CONTRIBUTING.md. --- docs/BUILD.bazel | 1 + python/cc/py_extension.bzl | 3 +++ python/private/cc/BUILD.bazel | 3 ++- python/private/cc/py_extension_macro.bzl | 3 +++ python/private/cc/py_extension_rule.bzl | 2 -- tests/BUILD.bazel | 1 + 6 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index dd304c410a..baa1a1e3db 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -128,6 +128,7 @@ sphinx_stardocs( "//python/api:rule_builders", "//python/cc:py_cc_toolchain", "//python/cc:py_cc_toolchain_info", + "//python/cc:py_extension", "//python/entry_points:py_console_script_binary", "//python/extensions:config", "//python/extensions:python", diff --git a/python/cc/py_extension.bzl b/python/cc/py_extension.bzl index 2d5570b546..d81877a702 100644 --- a/python/cc/py_extension.bzl +++ b/python/cc/py_extension.bzl @@ -10,6 +10,9 @@ for information on writing C extension modules. :::{include} /_includes/experimental_api.md ::: + +:::{versionadded} VERSION_NEXT_FEATURE +::: """ load( diff --git a/python/private/cc/BUILD.bazel b/python/private/cc/BUILD.bazel index a89c47c58f..087d406932 100644 --- a/python/private/cc/BUILD.bazel +++ b/python/private/cc/BUILD.bazel @@ -43,6 +43,7 @@ bzl_library( srcs = ["py_extension_macro.bzl"], deps = [ ":py_extension_rule", + "//python/private:common_labels", "//python/private:util", "@rules_cc//cc:core_rules", ], @@ -55,11 +56,11 @@ bzl_library( "//python/private:attr_builders", "//python/private:attributes", "//python/private:builders", + "//python/private:common", "//python/private:py_info", "//python/private:reexports", "//python/private:rule_builders", "//python/private:toolchain_types", "@bazel_skylib//lib:dicts", - "@rules_cc//cc/common", ], ) diff --git a/python/private/cc/py_extension_macro.bzl b/python/private/cc/py_extension_macro.bzl index b57e9a438e..6b697ab503 100644 --- a/python/private/cc/py_extension_macro.bzl +++ b/python/private/cc/py_extension_macro.bzl @@ -50,6 +50,9 @@ def py_extension( - `module_name`: Pass `module_name = "custom_name"` to override the base module filename. + :::{versionadded} VERSION_NEXT_FEATURE + ::: + Args: name: {type}`str` Target name. srcs: {type}`list[Label | str] | None` C/C++ source files to compile diff --git a/python/private/cc/py_extension_rule.bzl b/python/private/cc/py_extension_rule.bzl index 2b3a8284b7..dc5a5e0c59 100644 --- a/python/private/cc/py_extension_rule.bzl +++ b/python/private/cc/py_extension_rule.bzl @@ -5,7 +5,6 @@ """ load("@bazel_skylib//lib:dicts.bzl", "dicts") -load("@rules_cc//cc/common:cc_shared_library_info.bzl", "CcSharedLibraryInfo") load("//python/private:attr_builders.bzl", "attrb") load("//python/private:attributes.bzl", "COMMON_ATTRS", "IMPORTS_ATTRS", "WINDOWS_CONSTRAINTS_ATTRS") load("//python/private:builders.bzl", "builders") @@ -78,7 +77,6 @@ PY_EXTENSION_WRAPPER_ATTRS = dicts.add( ), "src": lambda: attrb.Label( mandatory = True, - providers = [CcSharedLibraryInfo], doc = "The cc_shared_library target to wrap.", ), }, diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel index e7dbef65d8..927fc30566 100644 --- a/tests/BUILD.bazel +++ b/tests/BUILD.bazel @@ -22,6 +22,7 @@ build_test( "//python:py_test_bzl", "//python/cc:py_cc_toolchain_bzl", "//python/cc:py_cc_toolchain_info_bzl", + "//python/cc:py_extension", "//python/entry_points:py_console_script_binary_bzl", ], ) From 5f268ab0dae6fdc6fb4f8fd3a698863eeaacc8c5 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 4 Aug 2026 18:44:57 -0700 Subject: [PATCH 144/179] agents(monitor-ci-results): autonomous flake retries via subagent and forbid file lists in PRs (#4001) agents(monitor-ci-results): autonomous flake retries via subagent and forbid file lists in PRs Scopes process checks (`pgrep -f "monitor_remote_ci.py "`) specifically to the target PR number to prevent cross-PR monitoring conflicts when multiple agent conversations run concurrently. Automates recovery from transient infrastructure or network flakes without requiring manual human intervention. Updates `monitor-ci-results` workflow steps to require PR-scoped process lookups and introduces an autonomous retry step that spawns a dedicated retrier subagent via a prompt template (`retry-job-prompt.md`) to execute the `buildkite-retry-job` skill. --- .agents/skills/monitor-ci-results/SKILL.md | 15 +++++++++++++-- .../skills/monitor-ci-results/retry-job-prompt.md | 3 +++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/monitor-ci-results/retry-job-prompt.md diff --git a/.agents/skills/monitor-ci-results/SKILL.md b/.agents/skills/monitor-ci-results/SKILL.md index 3a1fab866b..e2475b55ad 100644 --- a/.agents/skills/monitor-ci-results/SKILL.md +++ b/.agents/skills/monitor-ci-results/SKILL.md @@ -12,8 +12,10 @@ or when monitoring CI after PR updates: > launch duplicate monitoring jobs for the same PR. 1. **Check Existing Process**: Check if a monitor script is already running for - the PR (e.g., `pgrep -f "monitor_remote_ci.py "`). If one is - already running, do not start another instance. + **this specific PR** using `pgrep -f "monitor_remote_ci.py "`. Do + NOT use a generic grep without ``, as other active agent + conversations may be monitoring different PRs concurrently. If one is + already running for this PR, do not start another instance. 2. **Launch Monitoring Script**: If no monitor process is active for ``, launch the script in the background: ```bash @@ -35,3 +37,12 @@ or when monitoring CI after PR updates: the monitoring script, immediately launch a separate subagent using the `invoke_subagent` tool with the role "CI Failure Analyzer" to run the `analyze-ci-failure` skill on the reported failure. +5. **Autonomous Flake Retry**: If the CI Failure Analyzer subagent confirms + that the failure is a transient infrastructure or network flake (e.g. disk + I/O error, 504 gateway, sandbox initialization failure), **immediately and + autonomously** launch a separate subagent using `invoke_subagent` with the + role "CI Job Retrier" and the prompt template in + `.agents/skills/monitor-ci-results/retry-job-prompt.md` to run the + `buildkite-retry-job` skill for ``. Do not execute the script + directly; use the subagent and skill orchestration instead. Continue + monitoring without pausing to ask the user. diff --git a/.agents/skills/monitor-ci-results/retry-job-prompt.md b/.agents/skills/monitor-ci-results/retry-job-prompt.md new file mode 100644 index 0000000000..4d2edad650 --- /dev/null +++ b/.agents/skills/monitor-ci-results/retry-job-prompt.md @@ -0,0 +1,3 @@ +Use the `buildkite-retry-job` skill to retry the failed Buildkite job(s) for PR {pr_number}. +Follow the instructions in `.agents/skills/buildkite-retry-job/SKILL.md` to retry the failing jobs using the skill. +Once the retry request completes, report back to the parent conversation. From 39b8c3c1da239b86fa23ccf70a2821cd55b60cee Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 4 Aug 2026 18:46:40 -0700 Subject: [PATCH 145/179] agents(rules): add manual tag rule and frontmatter to starlark.md (#4002) agents(rules): add manual tag rule and frontmatter to starlark.md Add YAML frontmatter triggers (`*.bzl,BUILD,BUILD.bazel,*.bazel`) to `.agents/rules/starlark.md` so Bazel rules load automatically, and document that internal macro helper targets must use `tags = ["manual"]`) to avoid being built during wildcard expansion. --- .agents/rules/starlark.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/rules/starlark.md b/.agents/rules/starlark.md index 430284bcd5..4517b06aec 100644 --- a/.agents/rules/starlark.md +++ b/.agents/rules/starlark.md @@ -1,3 +1,9 @@ +--- +trigger: glob +description: Starlark Language, Macro, and Testing Invariants +globs: "*.bzl,BUILD,BUILD.bazel,*.bazel" +--- + # Starlark Language & Macro Invariants ## Macro Target Canonicalization @@ -7,6 +13,12 @@ * Note that `python/private/common_labels.bzl` defines `labels`, a struct containing common canonicalized label strings used across the project. +## Manual Tag on Internal Macro Helper Targets +* When macros instantiate internal helper targets (such as private rule targets + for artifact extraction or linking support), always include `tags = ["manual"]`. +* **Why**: This prevents internal helper targets from being implicitly built when + wildcard target patterns (e.g., `//...`) are expanded. + ## Private Alias Pattern when Appending to User `deps` * When macros append internal helper targets to user-provided dependency lists (`deps`), use private alias targets (e.g. From e3291d3a17fdd3042bf58e15c3f3db55fa6fdd2f Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 4 Aug 2026 19:53:48 -0700 Subject: [PATCH 146/179] feat(python): add py_extension to features loadable_symbols (#4000) Add py_extension to features.loadable_symbols. This allows callers to use feature detection and optionally load it, if desired. --- python/features.bzl | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/python/features.bzl b/python/features.bzl index 9339d7889c..99050a7ca5 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -26,6 +26,10 @@ def _features_typedef(): A map of public API targets available in rules_python for feature detection purposes. + :::{seealso} + * {obj}`features.loadable_symbols` + ::: + :::{versionadded} 1.9.0 ::: :::: @@ -54,6 +58,10 @@ def _features_typedef(): A map of bzl paths to the list of public symbols they export. + :::{seealso} + * {obj}`features.targets` + ::: + :::{versionadded} 2.2.0 ::: :::: @@ -128,6 +136,10 @@ _TARGETS = { } _LOADABLE_SYMBOLS = { + "//python/cc:py_extension.bzl": [ + # keep sorted + "py_extension", + ], "//python:py_info.bzl": [ # keep sorted "PyInfo", From b84447fed5da40853fcf09d4b976b1647d5da44d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 4 Aug 2026 21:21:26 -0700 Subject: [PATCH 147/179] agents: update create-pr skill with interactive artifact requirements (#4004) Update the create-pr skill to specify interactive user-facing artifact requirements and explicit user decision options when proposing PRs. Previously, the create-pr skill instructions lacked explicit requirements for user-facing markdown artifacts with interactive feedback enabled, as well as defined options for user decision workflows. To address this, the SKILL.md documentation is updated to require user-facing artifacts with interactive feedback enabled and to outline the four decision choices for user review. --- .agents/skills/create-pr/SKILL.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md index 3510b15d07..88b687b39f 100644 --- a/.agents/skills/create-pr/SKILL.md +++ b/.agents/skills/create-pr/SKILL.md @@ -28,8 +28,14 @@ to handle PR creation or description drafting. (GitHub uses the PR description as the commit message upon merge, which reflows text at 72 columns). - **Formatting**: Follow repository style guidelines and structure. - - Create a Markdown artifact (`pr_info.md`) containing the PR title, body, - and link/metadata so the user can review and comment on it. + - Create a Markdown artifact (`pr_info.md`) meeting the following requirements: + - **User-facing**: Published so it is presented directly in the user interface. + - **Interactive feedback enabled**: Allows the user to select lines and leave inline comments on the draft. + - **User decision choices**: Ask the user if they want to: + 1. Create a regular PR + 2. Create a draft PR + 3. Provide feedback on the draft text + 4. Discard the draft text - **Propose vs. Create**: If the user requested to propose or draft a PR description, **do not** run `gh pr create`—just create the `pr_info.md` artifact for the user to review. Otherwise, execute `gh pr create` with From a2bdf6bebede105a7e4ea9aea5732c7866a17312 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 5 Aug 2026 03:10:24 -0700 Subject: [PATCH 148/179] docs(howto): add optional feature loading guide (#4003) Macro and rule authors need clear guidance on writing Bazel code that remains compatible across multiple rules_python releases as new APIs are introduced. Previously, using features.loadable_symbols, targets, and version lacked dedicated documentation, making it harder for developers to know how to use these feature-detection APIs. Add a how-to guide (docs/howto/optional-feature-loading.md) showing how to inspect //python:features.bzl and generate compatibility repo rules, BUILD files, and macro version checks. --- docs/howto/optional-feature-loading.md | 180 +++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/howto/optional-feature-loading.md diff --git a/docs/howto/optional-feature-loading.md b/docs/howto/optional-feature-loading.md new file mode 100644 index 0000000000..144c0f65cb --- /dev/null +++ b/docs/howto/optional-feature-loading.md @@ -0,0 +1,180 @@ +:::{default-domain} bzl +::: + +# How to optionally load symbols using `features.loadable_symbols` + +When writing Bazel rules, macros, or repository extensions that support +multiple versions of `rules_python`, you may want to detect whether a public +symbol (such as {obj}`py_extension` in `//python/cc:py_extension.bzl`) is +available before attempting to load or use it. + +Because Starlark `load()` statements are evaluated at parse time and must be at +the top level of a `.bzl` file, unconditionally loading a symbol that does not +exist in older versions of `rules_python` will cause a build error. + +The {bzl:obj}`features.loadable_symbols` dictionary in `//python:features.bzl` +allows you to programmatically inspect which symbols are exported by `.bzl` +files in the current `rules_python` version. + +## The `features.loadable_symbols` structure + +{bzl:obj}`features.loadable_symbols` is a `dict[str, list[str]]` mapping label +strings of `.bzl` files to the list of public symbols they export: + +```starlark +load("@rules_python//python:features.bzl", "features") + +# Example structure of features.loadable_symbols: +# { +# "//python/cc:py_extension.bzl": [ +# "py_extension", +# ], +# "//python:py_info.bzl": [ +# "PyInfo", +# ], +# } +``` + +## Using load() with optional symbols + +In repository rules or Bazel module extensions (`repository_ctx` or +`module_ctx`), you generate `.bzl` files dynamically. You can inspect +`features.loadable_symbols` to determine which `load()` statements to write into +a generated compatibility repository. + +Re-export the symbol under its standard name if available, or set it to `None` +if it is absent. By generating compatibility files and empty `BUILD.bazel` +files at the exact same relative package paths as `rules_python`, the only +difference in downstream `load()` statements is the repository name: + +```starlark +load("@rules_python//python:features.bzl", "features") + +def _rules_python_compat_impl(rctx): + for bzl, symbol_list in rctx.attr.symbols.items(): + loadable = features.loadable_symbols.get(bzl, []) + lines = [] + for symbol in symbol_list: + if symbol in loadable: + lines.append( + 'load("{}", _{} = "{}")'.format(bzl, symbol, symbol), + ) + lines.append("{} = _{}".format(symbol, symbol)) + else: + lines.append("{} = None".format(symbol)) + + package, _, filename = bzl.lstrip("/").partition(":") + path = package + "/" + filename if package else filename + build_path = package + "/BUILD.bazel" if package else "BUILD.bazel" + + rctx.file(path, content = "\n".join(lines) + "\n") + rctx.file(build_path, content = "") + +rules_python_compat = repository_rule( + implementation = _rules_python_compat_impl, + attrs = { + "symbols": attr.string_list_dict( + mandatory = True, + doc = "Map of bzl paths to lists of symbols to optionally load", + ), + }, +) +``` + +Instantiate the repository rule by providing a mapping of `.bzl` paths to their +symbols of interest: + +```starlark +rules_python_compat( + name = "rules_python_compat", + symbols = { + "//python/cc:py_extension.bzl": ["py_extension"], + }, +) +``` + +### Using the generated compatibility files + +Your macros and rules can load from `@rules_python_compat` using the same +file path as `@rules_python`, testing whether the symbol is `None` before +using it: + +```starlark +load("@rules_python_compat//python/cc:py_extension.bzl", "py_extension") + +def my_macro(name, **kwargs): + if py_extension != None: + py_extension( + name = name + "_ext", + **kwargs + ) + else: + # Fall back to default behavior for older rules_python versions + pass +``` + +## Handling optional targets + +In addition to symbol loading, you may need to check whether a specific Bazel +target exists in `rules_python` before referencing its label in dependencies, +toolchains, or attribute defaults. + +The {bzl:obj}`features.targets` dictionary in `//python:features.bzl` is a +`dict[str, bool]` mapping public API target labels to `True` when available. + +In a macro: + +```starlark +load("@rules_python//python:features.bzl", "features") + +def my_cc_extension_macro(name, deps = [], **kwargs): + if features.targets.get("//python/cc:current_py_cc_headers_abi3"): + deps = deps + ["@rules_python//python/cc:current_py_cc_headers_abi3"] + + # ... define target with deps +``` + +In a `BUILD` file: + +```starlark +load("@rules_python//python:features.bzl", "features") +load("@rules_python//python:py_library.bzl", "py_library") + +py_library( + name = "my_lib", + srcs = ["my_lib.py"], + deps = [ + "//my/app:base_lib", + ] + ( + ["@rules_python//python/cc:current_py_cc_headers_abi3"] + if features.targets.get("//python/cc:current_py_cc_headers_abi3") + else [] + ), +) +``` + +## Checking versions with `features.version` + +When a behavioral change or capability is not directly reflected by a public +target or loadable symbol, you can inspect {bzl:obj}`features.version` in +`//python:features.bzl`. + +{bzl:obj}`features.version` returns a semver-formatted version string (such as +`"1.0.0"`, `"2.0.0-rc2"`, or `""` for unreleased development builds): + +```starlark +load("@rules_python//python:features.bzl", "features") + +def _to_tuple(v): + return tuple([ + int(x) if x.isdigit() else x + for x in v.replace("-", ".").split(".") + ]) + +def has_foo(): + # If version is empty, it is an unreleased build from main which includes + # all features. + if not features.version: + return True + return _to_tuple(features.version) >= _to_tuple("0.38.0") +``` From 20773d6e46dae6e3ffea736dbbbcc251bd03dd63 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 5 Aug 2026 20:48:13 -0700 Subject: [PATCH 149/179] fix(sphinxdocs): materialize source symlinks to resolve relative cross-references (#3977) (#4005) In Sphinx with MyST, relative cross-references (`myst.xref_missing`) break when resolving relative paths across symlinked source files inside Bazel's `_sources` directory because Bazel symlinks resolve canonical paths that escape the source tree. To fix this, replaced input symlinking in `_relocate` with physical file materialization in `sphinx_build.py`. Implemented `DirectorySyncer` to concurrently synchronize files into a work-private `{srcdir}.worker-in.d` directory using SHA-based change detection for worker requests and full recursive copying for non-worker requests. Fixes #3977 --- sphinxdocs/sphinxdocs/private/BUILD.bazel | 6 + sphinxdocs/sphinxdocs/private/sphinx.bzl | 7 +- sphinxdocs/sphinxdocs/private/sphinx_build.py | 224 +++++++++++++++--- sphinxdocs/tests/sphinx_build/BUILD.bazel | 11 + .../sphinx_build/directory_syncer_test.py | 133 +++++++++++ sphinxdocs/tests/sphinx_docs/BUILD.bazel | 18 ++ sphinxdocs/tests/sphinx_docs/defs.bzl | 4 +- sphinxdocs/tests/sphinx_docs/index.md | 1 + .../sphinx_docs/sphinx_docs_output_test.py | 28 +++ .../sphinx_stardoc/sphinx_output_test.py | 6 +- sphinxdocs/tests/sphinx_stardoc/xrefs.md | 1 + 11 files changed, 407 insertions(+), 32 deletions(-) create mode 100644 sphinxdocs/tests/sphinx_build/BUILD.bazel create mode 100644 sphinxdocs/tests/sphinx_build/directory_syncer_test.py create mode 100644 sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py diff --git a/sphinxdocs/sphinxdocs/private/BUILD.bazel b/sphinxdocs/sphinxdocs/private/BUILD.bazel index fa5ded15f1..823f07fe73 100644 --- a/sphinxdocs/sphinxdocs/private/BUILD.bazel +++ b/sphinxdocs/sphinxdocs/private/BUILD.bazel @@ -129,6 +129,12 @@ py_binary( deps = [":proto_to_markdown_lib"], ) +py_library( + name = "sphinx_build_lib", + srcs = ["sphinx_build.py"], + visibility = ["//:__subpackages__"], +) + py_library( name = "proto_to_markdown_lib", srcs = ["proto_to_markdown.py"], diff --git a/sphinxdocs/sphinxdocs/private/sphinx.bzl b/sphinxdocs/sphinxdocs/private/sphinx.bzl index b7c051a154..c42b42ebc6 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx.bzl +++ b/sphinxdocs/sphinxdocs/private/sphinx.bzl @@ -360,12 +360,17 @@ def _sphinx_source_tree_impl(ctx): dest_path = paths.join(source_prefix, dest_path) if source_file.is_directory: dest_file = ctx.actions.declare_directory(dest_path) + progress_message = "Symlinking Sphinx source directory %{input} to %{output}" else: dest_file = ctx.actions.declare_file(dest_path) + progress_message = "Symlinking Sphinx source %{input} to %{output}" + + # NOTE: Sphinx/MyST will read through symlinks, which can break relative + # xref lookup. Files are copied during the action phase to prevent this. ctx.actions.symlink( output = dest_file, target_file = source_file, - progress_message = "Symlinking Sphinx source %{input} to %{output}", + progress_message = progress_message, ) sphinx_source_files.append(dest_file) return dest_file diff --git a/sphinxdocs/sphinxdocs/private/sphinx_build.py b/sphinxdocs/sphinxdocs/private/sphinx_build.py index 8b0eec8fd3..52a334d9b9 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx_build.py +++ b/sphinxdocs/sphinxdocs/private/sphinx_build.py @@ -1,10 +1,14 @@ +import concurrent.futures import contextlib import io import json import logging import os +import pathlib import shutil +import stat import sys +import threading import traceback import typing @@ -29,7 +33,171 @@ def __init__(self, message, exit_code): _REQUEST_INFO_CONFIG_NAME = "bazel_worker_request_info_path" +class DirectorySyncerError(Exception): + """Raised when one or more errors occur during directory synchronization.""" + + def __init__(self, errors: typing.List[BaseException]): + self.errors = errors + message = f"Encountered {len(errors)} error(s) during sync:\n" + "\n".join( + f" - {e}" for e in errors + ) + super().__init__(message) + + +class DirectorySyncer: + """Synchronizes a working destination directory from a source directory. + + Supports concurrent SHA-aware incremental updates (via sync()) for worker + mode and concurrent full directory copying (via copytree()) for non-worker + mode, ensuring physical file materialization to prevent relative + cross-reference resolution failures. + """ + + def __init__( + self, + srcdir: pathlib.Path, + destdir: pathlib.Path, + max_workers: typing.Optional[int] = None, + ): + self._srcdir = srcdir + self._destdir = destdir + self._max_workers = max_workers or min(32, (os.cpu_count() or 4) + 4) + self._current_shas: typing.Dict[str, str] = {} + self._lock = threading.Lock() + self._finished_cond = threading.Condition(self._lock) + self._remaining = 0 + self._errors: typing.List[BaseException] = [] + self._executor: typing.Optional[concurrent.futures.ThreadPoolExecutor] = None + + def _reset_state(self) -> None: + with self._lock: + self._errors.clear() + self._remaining = 0 + + def _wait_for_completion(self) -> None: + with self._lock: + while self._remaining > 0: + self._finished_cond.wait() + if self._errors: + raise DirectorySyncerError(list(self._errors)) + + def _submit_task(self, fn, *args) -> None: + with self._lock: + self._remaining += 1 + future = self._executor.submit(fn, *args) + future.add_done_callback(self._handle_task_done) + + def _handle_task_done(self, future: concurrent.futures.Future) -> None: + exc = future.exception() + if exc: + with self._lock: + self._errors.append(exc) + + def _task_finished(self) -> None: + with self._lock: + self._remaining -= 1 + if self._remaining == 0: + self._finished_cond.notify_all() + + @contextlib.contextmanager + def _create_executor(self): + with concurrent.futures.ThreadPoolExecutor( + max_workers=self._max_workers + ) as executor: + self._executor = executor + try: + yield + finally: + self._executor = None + + def copytree(self) -> None: + """Concurrently copies srcdir to destdir without SHA tracking.""" + self._reset_state() + shutil.rmtree(self._destdir, ignore_errors=True) + with self._create_executor(): + self._submit_task(self._copy_dir, self._srcdir, self._destdir) + self._wait_for_completion() + + def sync(self, entries: typing.Dict[str, str]) -> None: + """Synchronizes destdir to match entries {relative_path: sha} concurrently.""" + self._reset_state() + + to_remove = set(self._current_shas.keys()) - set(entries.keys()) + to_copy = { + path: sha + for path, sha in entries.items() + if self._current_shas.get(path) != sha + } + + if not to_remove and not to_copy: + self._current_shas = dict(entries) + return + + with self._create_executor(): + # 1. Submit stale path removals concurrently ASAP + for rel_path in to_remove: + dest_path = self._destdir / rel_path + self._submit_task(self._remove_path, dest_path) + + # 2. Submit created/updated item copies concurrently ASAP + for rel_path in to_copy: + src_path = self._srcdir / rel_path + dest_path = self._destdir / rel_path + if src_path.is_dir(): + self._submit_task(self._copy_dir, src_path, dest_path) + else: + self._submit_task(self._copy_file, src_path, dest_path) + + self._wait_for_completion() + + self._current_shas = dict(entries) + + def _remove_path(self, dest_path: pathlib.Path) -> None: + try: + if dest_path.is_dir() and not dest_path.is_symlink(): + shutil.rmtree(dest_path) + else: + dest_path.unlink(missing_ok=True) + except BaseException as e: + e.add_note(f"Failed removing path: dest_path={dest_path}") + raise + finally: + self._task_finished() + + def _copy_file(self, src: pathlib.Path, dest: pathlib.Path) -> None: + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IWUSR) + except BaseException as e: + e.add_note(f"Failed copying file: src={src}, dest={dest}") + raise + finally: + self._task_finished() + + def _copy_dir(self, src: pathlib.Path, dest: pathlib.Path) -> None: + """Recursively creates destination directory and submits tasks for its entries.""" + try: + dest.mkdir(parents=True, exist_ok=True) + with os.scandir(src) as scanner: + for entry in scanner: + c_dest = dest / entry.name + c_src = pathlib.Path(entry.path) + if entry.is_dir(): + self._submit_task(self._copy_dir, c_src, c_dest) + else: + self._submit_task(self._copy_file, c_src, c_dest) + except BaseException as e: + e.add_note(f"Failed copying directory: src={src}, dest={dest}") + raise + finally: + self._task_finished() + + class Worker: + """A Bazel persistent worker for Sphinx builds.""" + def __init__( self, instream: "typing.TextIO", outstream: "typing.TextIO", exec_root: str ): @@ -51,6 +219,7 @@ def __init__( # dict[str srcdir, dict[str path, str digest]] self._digests = {} + self._syncers: typing.Dict[pathlib.Path, DirectorySyncer] = {} # Internal output directories the worker gives to Sphinx that need # to be cleaned up upon exit. @@ -124,18 +293,25 @@ def _send_response(self, response: "WorkResponse") -> None: def _prepare_sphinx(self, request): sphinx_args = request["arguments"] - srcdir = sphinx_args[0] + srcdir = pathlib.Path(sphinx_args[0]) + destdir = pathlib.Path(f"{srcdir}.worker-in.d") incoming_digests = {} - current_digests = self._digests.setdefault(srcdir, {}) + current_digests = self._digests.setdefault(str(srcdir), {}) is_first_request = not current_digests changed_paths = [] request_info = {"exec_root": self._exec_root, "inputs": request["inputs"]} + srcdir_prefix = str(srcdir) + "/" for entry in request["inputs"]: path = entry["path"] + # In persistent worker mode, request["inputs"] includes action-level + # tools (e.g. sphinx-build, sphinx_build.py) and params files that + # live outside srcdir. Only synchronize documentation sources inside srcdir. + if not path.startswith(srcdir_prefix): + continue digest = entry["digest"] # Make the path srcdir-relative so Sphinx understands it. - path = path.removeprefix(srcdir + "/") + path = path.removeprefix(srcdir_prefix) incoming_digests[path] = digest if path not in current_digests: @@ -145,31 +321,7 @@ def _prepare_sphinx(self, request): logger.info("path %s changed", path) changed_paths.append(path) - # Remove any source files that were tracked in the previous request (`current_digests`) - # but are missing from the current `request["inputs"]` (`incoming_digests`). - # Across incremental branch switches or file removals, if these stale symlinks - # remain in `srcdir` on disk, Sphinx will discover broken/unreadable files during - # `find_files()` and abort with "WARNING: Ignored unreadable document" (fatal with -W). - for path in set(current_digests) - set(incoming_digests): - removed_path = os.path.join(srcdir, path) - if os.path.exists(removed_path) or os.path.islink(removed_path): - logger.info("removing stale source file %s", removed_path) - try: - if os.path.islink(removed_path): - try: - os.remove(removed_path) - except OSError: - os.rmdir(removed_path) - elif os.path.isdir(removed_path): - shutil.rmtree(removed_path) - else: - os.remove(removed_path) - except OSError as e: - logger.warning( - "failed to remove stale source %s: %s", removed_path, e - ) - - self._digests[srcdir] = incoming_digests + self._digests[str(srcdir)] = incoming_digests self._extension.changed_paths = changed_paths request_info["changed_sources"] = changed_paths @@ -184,13 +336,22 @@ def _prepare_sphinx(self, request): # failures. So on the first request start from a clean slate. if is_first_request: shutil.rmtree(worker_outdir, ignore_errors=True) + shutil.rmtree(destdir, ignore_errors=True) for arg in sphinx_args: if arg.startswith("--doctree-dir="): shutil.rmtree(arg.partition("=")[2], ignore_errors=True) self._worker_outdirs.add(worker_outdir) sphinx_args[1] = worker_outdir - request_info_path = os.path.join(srcdir, "_bazel_worker_request_info.json") + if srcdir not in self._syncers: + self._syncers[srcdir] = DirectorySyncer(srcdir, destdir) + syncer = self._syncers[srcdir] + syncer.sync(incoming_digests) + + sphinx_args[0] = str(destdir) + request_info_path = os.path.join( + sphinx_args[0], "_bazel_worker_request_info.json" + ) with open(request_info_path, "w") as fp: json.dump(request_info, fp) sphinx_args.append(f"--define={_REQUEST_INFO_CONFIG_NAME}={request_info_path}") @@ -341,6 +502,11 @@ def _non_worker_main(): args.extend(lines) else: args.append(arg) + if len(args) > 1: + srcdir = pathlib.Path(args[1]) + destdir = pathlib.Path(f"{srcdir}.worker-in.d") + DirectorySyncer(srcdir, destdir).copytree() + args[1] = str(destdir) sys.argv[:] = args return main() diff --git a/sphinxdocs/tests/sphinx_build/BUILD.bazel b/sphinxdocs/tests/sphinx_build/BUILD.bazel new file mode 100644 index 0000000000..b9e77220df --- /dev/null +++ b/sphinxdocs/tests/sphinx_build/BUILD.bazel @@ -0,0 +1,11 @@ +load("@rules_python//python:py_test.bzl", "py_test") + +py_test( + name = "directory_syncer_test", + srcs = ["directory_syncer_test.py"], + deps = [ + "//sphinxdocs/private:sphinx_build_lib", + "@dev_pip//absl_py", + "@dev_pip//sphinx", + ], +) diff --git a/sphinxdocs/tests/sphinx_build/directory_syncer_test.py b/sphinxdocs/tests/sphinx_build/directory_syncer_test.py new file mode 100644 index 0000000000..1365e2b3ce --- /dev/null +++ b/sphinxdocs/tests/sphinx_build/directory_syncer_test.py @@ -0,0 +1,133 @@ +import pathlib +import shutil +import stat +import tempfile + +from absl.testing import absltest +from sphinxdocs.private.sphinx_build import DirectorySyncer, DirectorySyncerError + + +class DirectorySyncerTest(absltest.TestCase): + def setUp(self): + super().setUp() + self.test_dir = pathlib.Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.test_dir, ignore_errors=True) + self.srcdir = self.test_dir / "src" + self.destdir = self.test_dir / "dest" + self.srcdir.mkdir() + + def _write_src(self, rel_path, content, mode=None): + path = self.srcdir / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + path.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + path.write_text(content) + if mode is not None: + path.chmod(mode) + return path + + def assert_dest_equals(self, rel_path, expected_content): + dest_file = self.destdir / rel_path + self.assertTrue(dest_file.exists(), f"Expected {dest_file} to exist") + self.assertEqual(expected_content, dest_file.read_text()) + + def assert_dest_not_exists(self, rel_path): + dest_file = self.destdir / rel_path + self.assertFalse(dest_file.exists(), f"Expected {dest_file} to not exist") + + def test_copytree(self): + self._write_src("file1.txt", "hello") + self._write_src("sub/file2.txt", "world") + + syncer = DirectorySyncer(self.srcdir, self.destdir) + syncer.copytree() + + self.assert_dest_equals("file1.txt", "hello") + self.assert_dest_equals("sub/file2.txt", "world") + + def test_sync_initial_and_incremental(self): + self._write_src("doc1.md", "v1") + self._write_src("doc2.md", "v1") + self._write_src("doc3.md", "v1") + + syncer = DirectorySyncer(self.srcdir, self.destdir) + # Initial sync + syncer.sync( + { + "doc1.md": "sha-doc1-v1", + "doc2.md": "sha-doc2-v1", + "doc3.md": "sha-doc3-v1", + } + ) + + self.assert_dest_equals("doc1.md", "v1") + self.assert_dest_equals("doc2.md", "v1") + self.assert_dest_equals("doc3.md", "v1") + + # Incremental sync: + # - doc1.md: unchanged SHA + # - doc2.md: updated SHA & content + # - doc3.md: removed from entries + # - doc4.md: newly created file + self._write_src("doc2.md", "v2") + self._write_src("doc4.md", "v1") + + syncer.sync( + { + "doc1.md": "sha-doc1-v1", + "doc2.md": "sha-doc2-v2", + "doc4.md": "sha-doc4-v1", + } + ) + + self.assert_dest_equals("doc1.md", "v1") + self.assert_dest_equals("doc2.md", "v2") + self.assert_dest_not_exists("doc3.md") + self.assert_dest_equals("doc4.md", "v1") + + def test_read_only_file_becomes_writable(self): + read_only_mode = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH + self._write_src("readonly.txt", "version 1", mode=read_only_mode) + syncer = DirectorySyncer(self.srcdir, self.destdir) + syncer.sync({"readonly.txt": "sha-v1"}) + + dest_file = self.destdir / "readonly.txt" + self.assert_dest_equals("readonly.txt", "version 1") + self.assertTrue( + dest_file.stat().st_mode & stat.S_IWUSR, + "Destination file should be writable", + ) + + # Ensure subsequent incremental updates can overwrite the file without permission error + self._write_src("readonly.txt", "version 2", mode=read_only_mode) + syncer.sync({"readonly.txt": "sha-v2"}) + self.assert_dest_equals("readonly.txt", "version 2") + + def test_sync_directory_artifact(self): + dir_artifact = self.srcdir / "tree_art" + dir_artifact.mkdir() + (dir_artifact / "page.md").write_text("content") + + syncer = DirectorySyncer(self.srcdir, self.destdir) + syncer.sync( + { + "tree_art": "sha-dir-art", + } + ) + + self.assert_dest_equals("tree_art/page.md", "content") + + def test_errors_bubble_up(self): + syncer = DirectorySyncer(self.srcdir, self.destdir) + with self.assertRaises(DirectorySyncerError) as cm: + syncer.sync( + { + "non_existent_1.txt": "sha1", + "non_existent_2.txt": "sha2", + } + ) + self.assertGreaterEqual(len(cm.exception.errors), 2) + + +if __name__ == "__main__": + absltest.main() diff --git a/sphinxdocs/tests/sphinx_docs/BUILD.bazel b/sphinxdocs/tests/sphinx_docs/BUILD.bazel index 33b98ec585..4bbaf90691 100644 --- a/sphinxdocs/tests/sphinx_docs/BUILD.bazel +++ b/sphinxdocs/tests/sphinx_docs/BUILD.bazel @@ -1,4 +1,5 @@ load("@bazel_skylib//rules:build_test.bzl", "build_test") +load("@rules_python//python:py_test.bzl", "py_test") load("//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") load(":defs.bzl", "gen_directory") @@ -21,10 +22,20 @@ sphinx_docs( ], config = "conf.py", formats = ["html"], + renamed_srcs = { + ":gen_binary_asset": "binary_asset.bin", + }, sphinx = ":sphinx-build", + strip_prefix = package_name() + "/", target_compatible_with = _TARGET_COMPATIBLE_WITH, ) +genrule( + name = "gen_binary_asset", + outs = ["binary_asset.bin"], + cmd = "printf '\\x00\\xff\\xfe\\xfd\\x80\\x90\\x00\\x01\\x02\\x03\\x04' > $@", +) + gen_directory( name = "generated_directory", ) @@ -42,3 +53,10 @@ build_test( name = "docs_build_test", targets = [":docs"], ) + +py_test( + name = "sphinx_docs_output_test", + srcs = ["sphinx_docs_output_test.py"], + data = [":docs"], + deps = ["@dev_pip//absl_py"], +) diff --git a/sphinxdocs/tests/sphinx_docs/defs.bzl b/sphinxdocs/tests/sphinx_docs/defs.bzl index 2e47ecc0f7..36fd1dba4f 100644 --- a/sphinxdocs/tests/sphinx_docs/defs.bzl +++ b/sphinxdocs/tests/sphinx_docs/defs.bzl @@ -6,7 +6,9 @@ def _gen_directory_impl(ctx): ctx.actions.run_shell( outputs = [out], command = """ -echo "# Hello" > {outdir}/index.md +printf '# Hello\\n' > {outdir}/index.md +printf '# Dir Page 1\\n\\n[Dir Page 2](dir_page2.md)\\n' > {outdir}/dir_page1.md +printf '# Dir Page 2\\n' > {outdir}/dir_page2.md """.format( outdir = out.path, ), diff --git a/sphinxdocs/tests/sphinx_docs/index.md b/sphinxdocs/tests/sphinx_docs/index.md index cdce641fa1..68a5fb38c3 100644 --- a/sphinxdocs/tests/sphinx_docs/index.md +++ b/sphinxdocs/tests/sphinx_docs/index.md @@ -3,6 +3,7 @@ :::{toctree} :glob: +generated_directory/dir_page1 ** genindex ::: diff --git a/sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py b/sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py new file mode 100644 index 0000000000..5d00817926 --- /dev/null +++ b/sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py @@ -0,0 +1,28 @@ +import importlib.resources +import os +from xml.etree import ElementTree + +import tests.sphinx_docs as sphinx_docs +from absl.testing import absltest + + +class SphinxDocsOutputTest(absltest.TestCase): + def test_directory_artifact_relative_xref(self): + page1_path = importlib.resources.files(sphinx_docs).joinpath( + "docs/_build/html/generated_directory/dir_page1.html" + ) + self.assertTrue(os.path.exists(str(page1_path)), f"Not found at {page1_path}") + with open(str(page1_path)) as f: + xml = f.read() + doc_elem = ElementTree.fromstring(xml) + actual = None + for elem in doc_elem.iter(): + if "href" in elem.attrib: + if "".join(elem.itertext()).strip() == "Dir Page 2": + actual = elem.attrib["href"] + break + self.assertEqual("dir_page2.html", actual) + + +if __name__ == "__main__": + absltest.main() diff --git a/sphinxdocs/tests/sphinx_stardoc/sphinx_output_test.py b/sphinxdocs/tests/sphinx_stardoc/sphinx_output_test.py index 650e0134d3..e6653b1656 100644 --- a/sphinxdocs/tests/sphinx_stardoc/sphinx_output_test.py +++ b/sphinxdocs/tests/sphinx_stardoc/sphinx_output_test.py @@ -13,7 +13,10 @@ def setUp(self): self._xmls = {} def assert_xref(self, doc, *, text, href): - match = self._doc_element(doc).find(f".//*[.='{text}']") + # Find an element with an 'href' attribute whose string content (including + # descendants like ) equals `text`. [@href] filters out ancestor + # elements like
  • or

    which also match [.='{text}']. + match = self._doc_element(doc).find(f".//*[@href][.='{text}']") if not match: self.fail(f"No element found with {text=}") actual = match.attrib.get("href", "") @@ -113,6 +116,7 @@ def _doc_element(self, doc): ("file_with_repo", "@testrepo//lang:rule.bzl", "rule.html"), ("package_absolute", "//lang", "target.html"), ("package_basename", "lang", "target.html"), + ("relative_doc_link", "Rule documentation", "rule.html"), # fmt: on ) def test_xrefs(self, text, href): diff --git a/sphinxdocs/tests/sphinx_stardoc/xrefs.md b/sphinxdocs/tests/sphinx_stardoc/xrefs.md index 9893c32023..e3a7d7b21e 100644 --- a/sphinxdocs/tests/sphinx_stardoc/xrefs.md +++ b/sphinxdocs/tests/sphinx_stardoc/xrefs.md @@ -7,6 +7,7 @@ Various tests of cross referencing support ## Short name +* [Rule documentation](rule.md) * function: {obj}`myfunc` * function arg: {obj}`myfunc.arg1` * rule: {obj}`my_rule` From 71a7e319c35296e11d3d80415c257cc2c7ec2507 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 6 Aug 2026 00:55:21 -0700 Subject: [PATCH 150/179] fix(sphinxdocs): fix path resolution for conf.py in separate directory (#4006) Sphinx documentation builds failed when conf.py was generated or located in a separate directory because _relocate used the relative short_path of config, setting sphinx_source_dir_path to the config subdirectory rather than the source tree root. Relocate ctx.file.config using paths.basename(ctx.file.config.path) so it is always placed at the root of the generated _sources directory. Work towards #3977. Fixes #3999 --- sphinxdocs/sphinxdocs/private/sphinx.bzl | 19 ++++++---- .../sphinx_docs_conf_in_other_dir/BUILD.bazel | 38 +++++++++++++++++++ .../sphinx_docs_conf_in_other_dir/index.md | 3 ++ .../sphinx_docs_conf_in_other_dir/src_conf.py | 4 ++ 4 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel create mode 100644 sphinxdocs/tests/sphinx_docs_conf_in_other_dir/index.md create mode 100644 sphinxdocs/tests/sphinx_docs_conf_in_other_dir/src_conf.py diff --git a/sphinxdocs/sphinxdocs/private/sphinx.bzl b/sphinxdocs/sphinxdocs/private/sphinx.bzl index c42b42ebc6..24c5e9f9d1 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx.bzl +++ b/sphinxdocs/sphinxdocs/private/sphinx.bzl @@ -100,7 +100,7 @@ def sphinx_docs( sphinx, config, formats, - strip_prefix = "", + strip_prefix = None, extra_opts = [], tools = [], allow_persistent_workers = True, @@ -133,10 +133,10 @@ def sphinx_docs( config: {type}`label` the Sphinx config file (`conf.py`) to use. formats: (list of str) the formats (`-b` flag) to generate documentation in. Each format will become an output group. - strip_prefix: {type}`str` A prefix to remove from the file paths of the - source files. e.g., given `//sphinxdocs/docs:foo.md`, stripping `docs/` makes - Sphinx see `foo.md` in its generated source directory. If not - specified, then {any}`native.package_name` is used. + strip_prefix: {type}`str | None` A prefix to remove from the file paths of the + source files. An empty string (`""`) means no prefix stripping; `None` + means {any}`native.package_name` is used. e.g., given `//docs:foo.md`, + stripping `docs/` makes Sphinx see `foo.md` in its generated source directory. extra_opts: {type}`list[str]` Additional options to pass onto Sphinx building. On each provided option, a location expansion is performed. See {any}`ctx.expand_location`. @@ -148,6 +148,7 @@ def sphinx_docs( This can improve incremental building of docs. **kwargs: {type}`dict` Common attributes to pass onto rules. """ + strip_prefix = strip_prefix if strip_prefix != None else native.package_name() add_tag(kwargs, "//sphinxdocs:sphinx_docs") common_kwargs = copy_propagating_kwargs(kwargs) @@ -355,7 +356,9 @@ def _sphinx_source_tree_impl(ctx): # Materialize a file under the `_sources` dir def _relocate(source_file, dest_path = None): if not dest_path: - dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix) + # Strip leading slash if strip_prefix lacks a trailing slash, + # preventing paths.join from treating it as an absolute root. + dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix).lstrip("/") dest_path = paths.join(source_prefix, dest_path) if source_file.is_directory: @@ -378,7 +381,9 @@ def _sphinx_source_tree_impl(ctx): # Though Sphinx has a -c flag, we move the config file into the sources # directory to make the config more intuitive because some configuration # options are relative to the config location, not the sources directory. - source_conf_file = _relocate(ctx.file.config) + # Sphinx requires the configuration file to be named conf.py: + # https://www.sphinx-doc.org/en/master/usage/configuration.html#module-conf + source_conf_file = _relocate(ctx.file.config, "conf.py") sphinx_source_dir_path = paths.dirname(source_conf_file.path) for src in ctx.attr.srcs: diff --git a/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel new file mode 100644 index 0000000000..eecbb90897 --- /dev/null +++ b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel @@ -0,0 +1,38 @@ +load("@bazel_skylib//rules:build_test.bzl", "build_test") +load("//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") + +_TARGET_COMPATIBLE_WITH = select({ + "@platforms//os:linux": [], + "@platforms//os:macos": [], + "//conditions:default": ["@platforms//:incompatible"], +}) + +genrule( + name = "gen_conf", + srcs = ["src_conf.py"], + outs = ["other_dir/conf.py"], + cmd = "cp $(location src_conf.py) $@", +) + +sphinx_docs( + name = "docs", + srcs = ["index.md"], + config = ":gen_conf", + formats = ["html"], + sphinx = ":sphinx-build", + target_compatible_with = _TARGET_COMPATIBLE_WITH, +) + +sphinx_build_binary( + name = "sphinx-build", + tags = ["manual"], + deps = [ + "@dev_pip//myst_parser", + "@dev_pip//sphinx", + ], +) + +build_test( + name = "docs_build_test", + targets = [":docs"], +) diff --git a/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/index.md b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/index.md new file mode 100644 index 0000000000..cd7d8b4188 --- /dev/null +++ b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/index.md @@ -0,0 +1,3 @@ +# Test Documentation + +This is a test document for reproducing conf.py in a separate directory. diff --git a/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/src_conf.py b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/src_conf.py new file mode 100644 index 0000000000..d4f2f45d31 --- /dev/null +++ b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/src_conf.py @@ -0,0 +1,4 @@ +project = "Repro Test" +copyright = "2026, Test" +author = "Test" +extensions = ["myst_parser"] From c2b3648f9274af4aa59f138b6ec4d63be5cda548 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 6 Aug 2026 19:26:23 -0700 Subject: [PATCH 151/179] refactor(toolchain): rename abi_tag to soabi to eliminate PEP ambiguity (#4008) Rename `abi_tag` to `soabi` in `PyCcToolchainInfo` and associated toolchain implementation and macro parameters. I found the `abi_tag` term a bit ambiguous with PEP 425 wheel ABI tags and PEP 3149 C extension filename SOABI tags. So rename it to `soabi` to directly reflect the SOABI sysconfig var that it is traditionally derived from. This also uncovered a bug where the value was _just_ the Python ABI-ness (`cpython-311`), while it should be the full ABI infix that includes the platform (`cpython-311-x86_64-linux-gnu`). This also helped simplify some of the Windows code paths. --- news/3973.added.md | 4 +- python/private/cc/py_extension_rule.bzl | 26 +--------- python/private/get_local_runtime_info.py | 2 +- python/private/local_runtime_repo.bzl | 6 +-- python/private/local_runtime_repo_setup.bzl | 6 +-- python/private/py_cc_toolchain_info.bzl | 20 ++++---- python/private/py_cc_toolchain_rule.bzl | 47 +++++++++---------- .../py_cc_toolchain/py_cc_toolchain_tests.bzl | 4 +- .../support/py_cc_toolchain_info_subject.bzl | 7 +++ 9 files changed, 53 insertions(+), 69 deletions(-) diff --git a/news/3973.added.md b/news/3973.added.md index ef3796afca..20d136179a 100644 --- a/news/3973.added.md +++ b/news/3973.added.md @@ -1,6 +1,6 @@ (cc) Added experimental {obj}`py_extension` macro for creating C/C++ Python extension modules ([#3283](https://github.com/bazel-contrib/rules_python/issues/3283)). -(cc) Added `abi_flags`, `abi_tag`, `libc`, `platform_machine`, `platform_tag`, -and `sys_platform` attributes and info fields to {obj}`py_cc_toolchain` / +(cc) Added `libc`, `platform_machine`, `platform_tag`, `soabi`, and +`sys_platform` attributes and info fields to {obj}`py_cc_toolchain` / {obj}`PyCcToolchainInfo`. diff --git a/python/private/cc/py_extension_rule.bzl b/python/private/cc/py_extension_rule.bzl index dc5a5e0c59..e167969d5b 100644 --- a/python/private/cc/py_extension_rule.bzl +++ b/python/private/cc/py_extension_rule.bzl @@ -26,11 +26,9 @@ def _py_extension_wrapper_impl(ctx): else: py_toolchain = ctx.toolchains[PY_CC_TOOLCHAIN_TYPE] py_cc_toolchain = py_toolchain.py_cc_toolchain - platform_tag = _get_platform(ctx) - output_filename = "{module_name}.{abi_tag}-{platform}.{ext}".format( + output_filename = "{module_name}.{soabi}.{ext}".format( module_name = module_name, - abi_tag = py_cc_toolchain.abi_tag, - platform = platform_tag, + soabi = py_cc_toolchain.soabi, ext = ext, ) @@ -112,26 +110,6 @@ def _get_extension(ctx): """ return "pyd" if is_windows_platform(ctx) else "so" -def _get_platform(ctx): - """Derives the PEP 3149 platform tag from the active Python C++ toolchain. - - Args: - ctx: The rule context. - - Returns: - The platform tag, e.g. "x86_64-linux-gnu" or "win_amd64" - """ - py_toolchain = ctx.toolchains[PY_CC_TOOLCHAIN_TYPE] - py_cc_toolchain = py_toolchain.py_cc_toolchain - if not py_cc_toolchain.platform_tag: - fail( - ("ERROR: Unable to resolve platform_tag from Python C++ toolchain for {self}. " + - "Please ensure the active py_cc_toolchain provides a non-empty platform_tag.").format( - self = ctx.label, - ), - ) - return py_cc_toolchain.platform_tag - def _py_extension_libs_impl(ctx): py_toolchain = ctx.toolchains[PY_CC_TOOLCHAIN_TYPE] py_cc_toolchain = py_toolchain.py_cc_toolchain diff --git a/python/private/get_local_runtime_info.py b/python/private/get_local_runtime_info.py index 340ef934f7..e0eab962d6 100644 --- a/python/private/get_local_runtime_info.py +++ b/python/private/get_local_runtime_info.py @@ -248,7 +248,7 @@ def _unique_basenames(inputs: dict[str, None]) -> list[str]: "abi_dynamic_libraries": _unique_basenames(abi_dynamic_libraries), "abi_interface_libraries": _unique_basenames(abi_interface_libraries), "abi_flags": abi_flags, - "abi_tag": config_vars.get("SOABI") or "", + "soabi": config_vars.get("SOABI") or "", "shlib_suffix": ".dylib" if _IS_DARWIN else "", "additional_dlls": dlls, "defines": defines, diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl index 48fc576bb7..f5597db613 100644 --- a/python/private/local_runtime_repo.bzl +++ b/python/private/local_runtime_repo.bzl @@ -35,7 +35,7 @@ define_local_runtime_toolchain_impl( minor = "{minor}", micro = "{micro}", abi_flags = "{abi_flags}", - abi_tag = "{abi_tag}", + soabi = "{soabi}", os = "{os}", implementation_name = "{implementation_name}", interpreter_path = "{interpreter_path}", @@ -56,7 +56,7 @@ def _expand_incompatible_template(): minor = "0", micro = "0", abi_flags = "", - abi_tag = "", + soabi = "", os = "@platforms//:incompatible", implementation_name = "incompatible", interpreter_path = "/incompatible", @@ -216,7 +216,7 @@ def _local_runtime_repo_impl(rctx): minor = info["minor"], micro = info["micro"], abi_flags = info["abi_flags"], - abi_tag = info["abi_tag"], + soabi = info["soabi"], os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)), implementation_name = info["implementation_name"], interpreter_path = _norm_path(interpreter_path), diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl index 78c8bd0093..62e46e4b98 100644 --- a/python/private/local_runtime_repo_setup.bzl +++ b/python/private/local_runtime_repo_setup.bzl @@ -40,7 +40,7 @@ def define_local_runtime_toolchain_impl( additional_dlls, sys_platform = "", platform_machine = "", - abi_tag = ""): + soabi = ""): """Defines a toolchain implementation for a local Python runtime. Generates public targets: @@ -76,7 +76,7 @@ def define_local_runtime_toolchain_impl( e.g. ["lib/msvcrt123.dll"] sys_platform: `str` The PEP 508 `sys_platform` marker, e.g. 'linux', 'darwin', 'win32'. platform_machine: `str` The PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarch64'. - abi_tag: `str` The ABI tag for extension modules, e.g. 'cpython-311'. + soabi: `str` The SOABI tag for extension modules, e.g. 'cpython-311-x86_64-linux-gnu'. """ major_minor = "{}.{}".format(major, minor) major_minor_micro = "{}.{}".format(major_minor, micro) @@ -190,7 +190,7 @@ def define_local_runtime_toolchain_impl( py_cc_toolchain( name = "py_cc_toolchain", abi_flags = abi_flags, - abi_tag = abi_tag, + soabi = soabi, headers = ":python_headers", headers_abi3 = ":python_headers_abi3", libs = ":libpython", diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl index 19dc5de482..208a9589ba 100644 --- a/python/private/py_cc_toolchain_info.bzl +++ b/python/private/py_cc_toolchain_info.bzl @@ -22,16 +22,6 @@ PyCcToolchainInfo = provider( The runtime's ABI flags, i.e. `sys.abiflags` (e.g. 't' for free-threaded builds). -:::{versionadded} VERSION_NEXT_FEATURE -::: -""", - "abi_tag": """\ -:type: str - -The ABI tag for extension modules, equivalent to the `SOABI` sysconfig var -(see [PEP 3149](https://peps.python.org/pep-3149/)), e.g. 'cpython-311' or -'cpython-313t'. - :::{versionadded} VERSION_NEXT_FEATURE ::: """, @@ -132,6 +122,16 @@ The PEP 3149 / PEP 425 platform tag for extension modules, e.g. :type: str The Python Major.Minor version. +""", + "soabi": """\ +:type: str + +The SOABI tag for extension modules (see +[PEP 3149](https://peps.python.org/pep-3149/)), e.g. +'cpython-311-x86_64-linux-gnu' or 'cp311'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, "sys_platform": """ :type: str diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl index a6cb29635c..02d4222fc8 100644 --- a/python/private/py_cc_toolchain_rule.bzl +++ b/python/private/py_cc_toolchain_rule.bzl @@ -88,19 +88,6 @@ def _py_cc_toolchain_impl(ctx): if ctx.attr._py_freethreaded_flag[BuildSettingInfo].value == FreeThreadedFlag.YES: abi_flags += "t" - abi_tag = ctx.attr.abi_tag - if not abi_tag: - # Derive default ABI tag: - # On POSIX: cpython-XX[t] (PEP 3149 / PEP 703) - # On Windows: cpXX[t] (PEP 3149 / PEP 703, CPython issue & commit): - # - https://peps.python.org/pep-3149/ - # - https://peps.python.org/pep-0703/ - # - https://github.com/python/cpython/issues/67169 - # - https://github.com/python/cpython/commit/03a144bb6ac3d7631a3bdb895e2a1f2d021fb08b - version_parts = ctx.attr.python_version.split(".") - prefix = "cp" if ctx.attr.sys_platform == "win32" else "cpython-" - abi_tag = "{}{}{}{}".format(prefix, version_parts[0], version_parts[1], abi_flags) - libc = ctx.attr.libc or LibcFlag.get_value(ctx) platform_tag = _get_platform_tag( @@ -109,9 +96,19 @@ def _py_cc_toolchain_impl(ctx): libc = libc, ) + soabi = ctx.attr.soabi + if not soabi: + # Derive default SOABI tag according to PEP 3149: + # On POSIX: cpython-XX[t]- + # On Windows: cpXX[t]- (CPython issue #67169) + version_parts = ctx.attr.python_version.split(".") + prefix = "cp" if ctx.attr.sys_platform == "win32" else "cpython-" + soabi = "{}{}{}{}".format(prefix, version_parts[0], version_parts[1], abi_flags) + if platform_tag: + soabi = "{}-{}".format(soabi, platform_tag) + py_cc_toolchain = PyCcToolchainInfo( abi_flags = abi_flags, - abi_tag = abi_tag, headers = struct( providers_map = { "CcInfo": ctx.attr.headers[CcInfo], @@ -123,6 +120,7 @@ def _py_cc_toolchain_impl(ctx): platform_machine = ctx.attr.platform_machine, platform_tag = platform_tag, python_version = ctx.attr.python_version, + soabi = soabi, sys_platform = ctx.attr.sys_platform, ) extra_kwargs = {} @@ -149,17 +147,6 @@ free-threaded is enabled, or `''` otherwise). ::: """, ), - "abi_tag": attr.string( - doc = """\ -The ABI tag for extension modules, equivalent to the `SOABI` sysconfig var -(see [PEP 3149](https://peps.python.org/pep-3149/)), e.g. 'cpython-311' or -'cpython-313t'. - -:::{versionadded} VERSION_NEXT_FEATURE -::: -""", - default = "", - ), "headers": attr.label( doc = ("Target that provides the Python headers. Typically this " + "is a cc_library target."), @@ -207,6 +194,16 @@ Target architecture as a PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarc doc = "The Major.minor Python version, e.g. 3.11", mandatory = True, ), + "soabi": attr.string( + doc = """\ +The SOABI tag for extension modules (see PEP 3149), e.g. +'cpython-311-x86_64-linux-gnu' or 'cp311'. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + default = "", + ), "sys_platform": attr.string( doc = """ Target OS as a PEP 508 `sys_platform` marker, e.g. 'linux', 'darwin', 'win32'. diff --git a/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl b/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl index 5b57810d0d..5b6f8e151d 100644 --- a/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl +++ b/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl @@ -119,10 +119,11 @@ def _test_py_cc_toolchain_impl(env, target): matching.str_matches("/libpython3."), ) - # ===== Verify PEP 508 platform markers ===== + # ===== Verify PEP 508 platform markers & SOABI ===== toolchain.sys_platform().equals("linux") toolchain.platform_machine().equals("x86_64") toolchain.platform_tag().equals("x86_64-linux-gnu") + toolchain.soabi().equals("cpython-3999-x86_64-linux-gnu") _tests.append(_test_py_cc_toolchain) @@ -148,6 +149,7 @@ def _test_custom_pep508_markers_impl(env, target): toolchain.sys_platform().equals("darwin") toolchain.platform_machine().equals("arm64") toolchain.platform_tag().equals("darwin") + toolchain.soabi().equals("cpython-311-darwin") _tests.append(_test_custom_pep508_markers) diff --git a/tests/support/py_cc_toolchain_info_subject.bzl b/tests/support/py_cc_toolchain_info_subject.bzl index fbf5a1a0ad..d6aa258a64 100644 --- a/tests/support/py_cc_toolchain_info_subject.bzl +++ b/tests/support/py_cc_toolchain_info_subject.bzl @@ -25,6 +25,7 @@ def _py_cc_toolchain_info_subject_new(info, *, meta): platform_machine = lambda *a, **k: _py_cc_toolchain_info_subject_platform_machine(self, *a, **k), platform_tag = lambda *a, **k: _py_cc_toolchain_info_subject_platform_tag(self, *a, **k), python_version = lambda *a, **k: _py_cc_toolchain_info_subject_python_version(self, *a, **k), + soabi = lambda *a, **k: _py_cc_toolchain_info_subject_soabi(self, *a, **k), sys_platform = lambda *a, **k: _py_cc_toolchain_info_subject_sys_platform(self, *a, **k), actual = info, ) @@ -82,6 +83,12 @@ def _py_cc_toolchain_info_subject_python_version(self): meta = self.meta.derive("python_version()"), ) +def _py_cc_toolchain_info_subject_soabi(self): + return subjects.str( + self.actual.soabi, + meta = self.meta.derive("soabi()"), + ) + def _py_cc_toolchain_info_subject_sys_platform(self): return subjects.str( self.actual.sys_platform, From 81b32605b2987697145c41b3fb59ca91a5b6e5a1 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 6 Aug 2026 20:24:50 -0700 Subject: [PATCH 152/179] build: add static type checking for runfiles with rules_pyrefly (#4009) Enable static type verification for the runfiles library using the rules_pyrefly ruleset. To ensure hermetic type safety across Bazel runfiles utilities, this configures rules_pyrefly's Bzlmod dev toolchain, tags the runfiles target with `tags = ["pyrefly"]`, and enables global aspect evaluation in .bazelrc via `--aspects=//tests/support/pyrefly:pyrefly.bzl%pyrefly_aspect`. To support WORKSPACE mode builds without breaking dependency resolution, a no-op stub repository for rules_pyrefly is defined under tests/modules/rules_pyrefly_stub and registered in internal_dev_deps.bzl. Under Bzlmod, the aspect performs static type checking on tagged targets, while under WORKSPACE mode, the stub aspect evaluates cleanly as a no-op. --- .agents/rules/no_gh_auth_token_in_cmdline.md | 11 ++++++++++ .bazelrc | 1 + .bazelrc.deleted_packages | 1 + MODULE.bazel | 14 +++++++++++++ internal_dev_deps.bzl | 7 +++++++ python/runfiles/BUILD.bazel | 1 + python/runfiles/runfiles.py | 2 +- tests/modules/rules_pyrefly_stub/WORKSPACE | 1 + .../rules_pyrefly_stub/pyrefly/BUILD.bazel | 3 +++ .../rules_pyrefly_stub/pyrefly/pyrefly.bzl | 21 +++++++++++++++++++ tests/support/pyrefly/BUILD.bazel | 8 +++++++ tests/support/pyrefly/pyrefly.bzl | 7 +++++++ 12 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 .agents/rules/no_gh_auth_token_in_cmdline.md create mode 100644 tests/modules/rules_pyrefly_stub/WORKSPACE create mode 100644 tests/modules/rules_pyrefly_stub/pyrefly/BUILD.bazel create mode 100644 tests/modules/rules_pyrefly_stub/pyrefly/pyrefly.bzl create mode 100644 tests/support/pyrefly/BUILD.bazel create mode 100644 tests/support/pyrefly/pyrefly.bzl diff --git a/.agents/rules/no_gh_auth_token_in_cmdline.md b/.agents/rules/no_gh_auth_token_in_cmdline.md new file mode 100644 index 0000000000..e8cb7794fb --- /dev/null +++ b/.agents/rules/no_gh_auth_token_in_cmdline.md @@ -0,0 +1,11 @@ +--- +trigger: always_on +--- + +# No Auth Token in Cmdline Rule + +* NEVER pass `gh auth token` or embed authentication tokens in command line + arguments or URLs. Doing so can leak credentials in process tables (`ps`), + shell history, or logs. +* Use configured SSH keys, standard `git push`, or `gh` commands natively + instead. diff --git a/.bazelrc b/.bazelrc index 044990fdb1..90f3bc8fdb 100644 --- a/.bazelrc +++ b/.bazelrc @@ -19,6 +19,7 @@ test --test_output=errors # Python targets as required. build --incompatible_default_to_explicit_init_py build --//python/config_settings:incompatible_default_to_explicit_init_py=True +build --aspects=//tests/support/pyrefly:pyrefly.bzl%pyrefly_aspect # Ensure ongoing compatibility with this flag. common --incompatible_disallow_struct_provider_syntax diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index 9c4745c178..da79f11058 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -53,3 +53,4 @@ common --deleted_packages=tests/modules/other/nspkg_single common --deleted_packages=tests/modules/other/simple_v1 common --deleted_packages=tests/modules/other/simple_v2 common --deleted_packages=tests/modules/other/with_external_data +common --deleted_packages=tests/modules/rules_pyrefly_stub/pyrefly diff --git a/MODULE.bazel b/MODULE.bazel index 1c2042152e..027277ab64 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -98,6 +98,20 @@ bazel_dep(name = "another_module", version = "0", dev_dependency = True) bazel_dep(name = "rules_go", version = "0.60.0", dev_dependency = True, repo_name = "io_bazel_rules_go") bazel_dep(name = "gazelle", version = "0.40.0", dev_dependency = True, repo_name = "bazel_gazelle") bazel_dep(name = "bazel_skylib_gazelle_plugin", version = "1.8.2", dev_dependency = True) +bazel_dep(name = "rules_pyrefly", version = "0.1.0", dev_dependency = True) + +pyrefly = use_extension( + "@rules_pyrefly//pyrefly:extensions.bzl", + "pyrefly", + dev_dependency = True, +) +pyrefly.toolchain(version = "1.2.0") +use_repo(pyrefly, "pyrefly_toolchains") + +register_toolchains( + "@pyrefly_toolchains//:all", + dev_dependency = True, +) internal_dev_deps = use_extension( "//python/private:internal_dev_deps.bzl", diff --git a/internal_dev_deps.bzl b/internal_dev_deps.bzl index 7ab4717236..6d4d7656b7 100644 --- a/internal_dev_deps.bzl +++ b/internal_dev_deps.bzl @@ -107,6 +107,13 @@ def rules_python_internal_deps(): ], ) + # Stub repository for rules_pyrefly in WORKSPACE mode so that load() + # statements for @rules_pyrefly resolve without requiring full Pyrefly. + local_repository( + name = "rules_pyrefly", + path = "tests/modules/rules_pyrefly_stub", + ) + # The below two deps are required for the integration test with bazel # gazelle. Maybe the test should be moved to the `gazelle` workspace? http_archive( diff --git a/python/runfiles/BUILD.bazel b/python/runfiles/BUILD.bazel index 73663472dc..4e119eddbe 100644 --- a/python/runfiles/BUILD.bazel +++ b/python/runfiles/BUILD.bazel @@ -40,6 +40,7 @@ py_library( # to the --experimental_python_import_all_repositories setting. "../..", ], + tags = ["pyrefly"], visibility = ["//visibility:public"], ) diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index 16afeea47c..6fe2e8f28b 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -323,7 +323,7 @@ def is_socket(self) -> bool: return self._as_path().is_socket() # override - def open( + def open( # pyrefly: ignore[bad-override] self, mode: str = "r", buffering: int = -1, diff --git a/tests/modules/rules_pyrefly_stub/WORKSPACE b/tests/modules/rules_pyrefly_stub/WORKSPACE new file mode 100644 index 0000000000..48a0b3083d --- /dev/null +++ b/tests/modules/rules_pyrefly_stub/WORKSPACE @@ -0,0 +1 @@ +workspace(name = "rules_pyrefly") diff --git a/tests/modules/rules_pyrefly_stub/pyrefly/BUILD.bazel b/tests/modules/rules_pyrefly_stub/pyrefly/BUILD.bazel new file mode 100644 index 0000000000..2f14f71b3b --- /dev/null +++ b/tests/modules/rules_pyrefly_stub/pyrefly/BUILD.bazel @@ -0,0 +1,3 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files(["pyrefly.bzl"]) diff --git a/tests/modules/rules_pyrefly_stub/pyrefly/pyrefly.bzl b/tests/modules/rules_pyrefly_stub/pyrefly/pyrefly.bzl new file mode 100644 index 0000000000..fc1a44ed31 --- /dev/null +++ b/tests/modules/rules_pyrefly_stub/pyrefly/pyrefly.bzl @@ -0,0 +1,21 @@ +"""Stub implementation of rules_pyrefly for WORKSPACE mode.""" + +# buildifier: disable=unused-variable +def _noop_aspect_impl(_target, _ctx): + return [] + +_noop_aspect = aspect( + implementation = _noop_aspect_impl, + doc = "No-op Pyrefly aspect stub for WORKSPACE mode.", +) + +def pyrefly(**_kwargs): + """Stub pyrefly aspect constructor. + + Args: + **_kwargs: Ignored keyword arguments. + + Returns: + A no-op aspect. + """ + return _noop_aspect diff --git a/tests/support/pyrefly/BUILD.bazel b/tests/support/pyrefly/BUILD.bazel new file mode 100644 index 0000000000..447af06f8f --- /dev/null +++ b/tests/support/pyrefly/BUILD.bazel @@ -0,0 +1,8 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") + +package(default_visibility = ["//:__subpackages__"]) + +bzl_library( + name = "pyrefly", + srcs = ["pyrefly.bzl"], +) diff --git a/tests/support/pyrefly/pyrefly.bzl b/tests/support/pyrefly/pyrefly.bzl new file mode 100644 index 0000000000..bcb791403f --- /dev/null +++ b/tests/support/pyrefly/pyrefly.bzl @@ -0,0 +1,7 @@ +"""Aspect definition for Pyrefly static type checking.""" + +load("@rules_pyrefly//pyrefly:pyrefly.bzl", "pyrefly") + +pyrefly_aspect = pyrefly( + opt_in_tags = ["pyrefly"], +) From 753795d388e9e24b2e72e26e1c7ae0e4855b4703 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 7 Aug 2026 00:54:27 -0700 Subject: [PATCH 153/179] chore(release): ignore .agents directory in version marker replacement (#4012) The release tool scans repository files for version placeholder markers (`VERSION_NEXT_FEATURE` / `VERSION_NEXT_PATCH`) to determine the next semantic version and replace them with the release version. Documentation and rule guidance within `.agents/` may reference these placeholder strings as examples or instructions. Previously, the `.agents/` directory was not excluded, allowing placeholder markers in agent configurations to falsely affect version calculations and undergo unwanted replacements during release. To fix this, add `.agents/` to the excluded directory patterns in the release utilities, and add tests verifying that placeholder markers in `.agents` are ignored during version determination and replacement. --- tests/tools/private/release/utils_test.py | 21 +++++++++++++++++++++ tools/private/release/utils.py | 1 + 2 files changed, 22 insertions(+) diff --git a/tests/tools/private/release/utils_test.py b/tests/tools/private/release/utils_test.py index f161308b60..d39b5e108e 100644 --- a/tests/tools/private/release/utils_test.py +++ b/tests/tools/private/release/utils_test.py @@ -264,6 +264,10 @@ def test_replace_version_next_excludes_bazel_dirs(release_tool_env): blabla ::: """ + agents_dir = release_tool_env.git_root / ".agents" + agents_dir.mkdir() + (agents_dir / "mock_file.md").write_text(mock_file_content) + bazel_dir = release_tool_env.git_root / "bazel-rules_python" bazel_dir.mkdir() (bazel_dir / "mock_file.bzl").write_text(mock_file_content) @@ -282,6 +286,9 @@ def test_replace_version_next_excludes_bazel_dirs(release_tool_env): utils.replace_version_next(version) # Assert + new_content = (agents_dir / "mock_file.md").read_text() + assert "VERSION_NEXT_FEATURE" in new_content + new_content = (bazel_dir / "mock_file.bzl").read_text() assert "VERSION_NEXT_FEATURE" in new_content @@ -290,3 +297,17 @@ def test_replace_version_next_excludes_bazel_dirs(release_tool_env): new_content = (tests_dir / "mock_file.bzl").read_text() assert "VERSION_NEXT_FEATURE" in new_content + + +def test_determine_next_version_ignores_agents_markers(mocker, release_tool_env): + mocker.patch( + "tools.private.release.git.Git.get_current_branch", return_value="main" + ) + mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3") + agents_dir = release_tool_env.git_root / ".agents" + agents_dir.mkdir() + (agents_dir / "mock_file.md").write_text(":::{versionadded} VERSION_NEXT_FEATURE") + + next_version = utils.determine_next_version() + + assert next_version == "1.2.4" diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index cdd1b7724d..8cbbe89a41 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -22,6 +22,7 @@ def semver_type(value): _EXCLUDE_PATTERNS = [ + "./.agents/*", "./.git/*", "./.github/*", "./.bazelci/*", From 1a0095889a891734dc9274b2efdd9482777432ca Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 7 Aug 2026 01:13:09 -0700 Subject: [PATCH 154/179] chore(release): indent multi-line bullet points in changelog (#4013) When assembling changelog entries from news files during release preparation, multi-line descriptions had continuation lines unindented. In Markdown, unindented continuation lines break list item hierarchy and cause improper formatting. Format news entries so continuation lines are indented with two spaces, preserving proper list item structure in the generated changelog. --- .../private/release/changelog_news_test.py | 58 +++++++++++++++++++ tools/private/release/changelog_news.py | 37 ++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/tests/tools/private/release/changelog_news_test.py b/tests/tools/private/release/changelog_news_test.py index 38bba1eecf..dd2bd6df79 100644 --- a/tests/tools/private/release/changelog_news_test.py +++ b/tests/tools/private/release/changelog_news_test.py @@ -495,3 +495,61 @@ def test_update_changelog_insertion_point_too_small(tmp_path): changelog_path=changelog_path, news_dir=news_dir, ) + + +def test_format_entry(): + # Multi-line without leading bullet + raw = "(foo) bla bla\nblabl bla\nblabla" + expected = "* (foo) bla bla\n blabl bla\n blabla" + assert changelog_news._format_entry(raw) == expected + + # Multi-line with leading bullet + raw_with_bullet = "* (foo) bla bla\nblabl bla\nblabla" + assert changelog_news._format_entry(raw_with_bullet) == expected + + # Multi-line already indented + raw_indented = "* (foo) bla bla\n blabl bla\n blabla" + assert changelog_news._format_entry(raw_indented) == expected + + # Empty string + assert changelog_news._format_entry("") == "" + + +def test_update_changelog_multiline_indentation(tmp_path): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +{#v2-0-0} +## [2.0.0] - 2026-04-09 + +[2.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0 +""" + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = tmp_path / "news" + news_dir.mkdir() + (news_dir / "1.fixed.md").write_text("(foo) bla bla\nblabl bla\nblabla") + (news_dir / "2.added.md").write_text("* (bar) feature one\nsecond line of feature") + + # Act + changelog_news.update_changelog( + "2.1.0", + "2026-06-17", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + expected_fixed = "### Fixed\n* (foo) bla bla\n blabl bla\n blabla" + expected_added = "### Added\n* (bar) feature one\n second line of feature" + + assert expected_fixed in new_content + assert expected_added in new_content diff --git a/tools/private/release/changelog_news.py b/tools/private/release/changelog_news.py index e09b082ade..6381aacb34 100644 --- a/tools/private/release/changelog_news.py +++ b/tools/private/release/changelog_news.py @@ -34,6 +34,35 @@ def _get_news_files(news_dir): return [p for p in news_path.iterdir() if is_news_file(p)] +def _format_entry(content): + """Formats news entry content as a markdown bullet list item. + + Continuation lines are indented with two spaces. + """ + lines = content.strip().splitlines() + if not lines: + return "" + + formatted_lines = [] + first_line = lines[0] + if not (first_line.startswith("* ") or first_line.startswith("- ")): + formatted_lines.append(f"* {first_line}") + else: + formatted_lines.append(first_line) + + for line in lines[1:]: + if not line: + formatted_lines.append("") + elif line.startswith("* ") or line.startswith("- "): + formatted_lines.append(line) + elif line.startswith(" "): + formatted_lines.append(line) + else: + formatted_lines.append(f" {line}") + + return "\n".join(formatted_lines) + + def _parse_new_files(news_files): """Parses news files and groups them by category.""" entries = {} @@ -48,13 +77,13 @@ def _parse_new_files(news_files): if not content: continue - # Format as list item if not already - if not (content.startswith("* ") or content.startswith("- ")): - content = f"* {content}" + formatted_content = _format_entry(content) + if not formatted_content: + continue if category not in entries: entries[category] = [] - entries[category].append(content) + entries[category].append(formatted_content) return entries From e84926a2a32732179e8d8b2f85af5dd1ae00a6be Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 7 Aug 2026 01:49:47 -0700 Subject: [PATCH 155/179] chore(release): fix duplicate argument in NamedTemporaryFile (#4014) In GitHub.update_issue_body, tempfile.NamedTemporaryFile was called with both a positional argument and a keyword argument for the mode, raising a TypeError for multiple values of 'mode'. Additionally, standard output was not flushed before writing fatal error messages to standard error upon release tool failures. Pass mode only as a keyword argument with UTF-8 encoding when writing temporary files for issue body updates, flush stdout prior to logging fatal exceptions, and add unit test coverage for update_issue_body. --- tests/tools/private/release/gh_test.py | 17 +++++++++++++++++ tools/private/release/gh.py | 2 +- tools/private/release/release.py | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/tools/private/release/gh_test.py b/tests/tools/private/release/gh_test.py index b8c346004b..7e19b2232c 100644 --- a/tests/tools/private/release/gh_test.py +++ b/tests/tools/private/release/gh_test.py @@ -77,3 +77,20 @@ def test_auto_patched_helpers_prevent_real_execution(auto_patch_cmd_helpers): gh_obj = GitHub("foo/bar") gh_obj._run_gh("issue", "list") auto_patch_cmd_helpers.run_gh.assert_called_with("issue", "list") + + +def test_update_issue_body(gh, auto_patch_cmd_helpers): + captured_body = {} + + def mock_run(*args, **kwargs): + for arg in args: + if isinstance(arg, str) and arg.startswith("--body-file="): + path = arg.split("=", 1)[1] + with open(path, encoding="utf-8") as f: + captured_body["content"] = f.read() + return None + + auto_patch_cmd_helpers.run_gh.side_effect = mock_run + gh.update_issue_body(123, "new body content") + auto_patch_cmd_helpers.run_gh.assert_called_once() + assert captured_body["content"] == "new body content" diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 9d157f31d6..17fcd59a69 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -307,7 +307,7 @@ def update_issue_body(self, issue_num: int, body: str) -> None: issue_num: The issue number. body: The new body content. """ - with tempfile.NamedTemporaryFile("w", delete=False, mode="w") as f: + with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8") as f: f.write(body) f.flush() temp_path = f.name diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 72c427a392..7f1896ecf4 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -64,6 +64,7 @@ def main(): # args.command is the run_from_args classmethod of the selected command exit_code = args.command(args) except Exception as e: + sys.stdout.flush() print(f"Fatal error: {e}", file=sys.stderr) if hasattr(e, "__notes__"): for note in e.__notes__: From d8bb5ebb35d8b7dd6e22792e38fe853df98e49d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:53:00 -0700 Subject: [PATCH 156/179] Prepare release v2.3.0 (#4011) Work towards #4010 Co-authored-by: Richard Levasseur --- CHANGELOG.md | 76 +++++++++++++++++++ .../python/config_settings/index.md | 2 +- news/3514.added.md | 1 - news/3825.added.md | 6 -- news/3858.fixed.md | 3 - news/3906.fixed.md | 3 - news/3919.fixed.md | 3 - news/3932.added.md | 3 - news/3934.fixed.md | 7 -- news/3935.fixed.md | 4 - news/3950.fixed.md | 3 - news/3952.fixed.md | 3 - news/3972.fixed.md | 11 --- news/3973.added.md | 6 -- news/3978.changed.md | 3 - news/3978.fixed.md | 5 -- python/cc/py_extension.bzl | 2 +- python/private/cc/py_extension_macro.bzl | 2 +- python/private/py_cc_toolchain_info.bzl | 8 +- python/private/py_cc_toolchain_rule.bzl | 8 +- python/private/pypi/extension.bzl | 6 +- python/private/pypi/whl_library.bzl | 4 +- python/private/python.bzl | 2 +- 23 files changed, 93 insertions(+), 78 deletions(-) delete mode 100644 news/3514.added.md delete mode 100644 news/3825.added.md delete mode 100644 news/3858.fixed.md delete mode 100644 news/3906.fixed.md delete mode 100644 news/3919.fixed.md delete mode 100644 news/3932.added.md delete mode 100644 news/3934.fixed.md delete mode 100644 news/3935.fixed.md delete mode 100644 news/3950.fixed.md delete mode 100644 news/3952.fixed.md delete mode 100644 news/3972.fixed.md delete mode 100644 news/3973.added.md delete mode 100644 news/3978.changed.md delete mode 100644 news/3978.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f6dc36ee73..ede22fc6db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,82 @@ Unreleased changes are tracked as individual files in the [news/](./news) directory, or view the [latest generated changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). +{#v2-3-0} +## [2.3.0] - 2026-08-07 + +[2.3.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.3.0 + +{#v2-3-0-changed} +### Changed +* (gazelle) **BREAKING** rules_python 1.5.0 or higher is now required. The Python + extension selects its standard library list on `is_python_3.14`, which earlier + versions do not define. + +{#v2-3-0-fixed} +### Fixed +* Fixed `py_binary_rule_builder()` / `py_test_rule_builder()` (from `python/api/executables.bzl`) + failing at analysis time with a visibility error when used to construct a custom rule from an + external module. +* (compile_pip_requirements) Add the explicit `data` attribute and forward it + directly to the generated `py_binary`, so files passed via `data` can be + referenced from `extra_args` using `$(location ...)`. +* (coverage) The warning about a missing bundled `coverage.py` wheel is no longer + emitted as we are now falling back to a pure python wheel + ([#3950](https://github.com/bazel-contrib/rules_python/issues/3950)). +* (gazelle) The Python extension now uses the correct standard library module list for + `python_version` 3.13 and 3.14; previously both fell back to the 3.11 list, so modules + added or removed since then (e.g. `compression.zstd`, `telnetlib`) were misclassified. The + fallback list for unrecognized versions is now the newest available one rather than 3.11 + ([#3978](https://github.com/bazel-contrib/rules_python/pull/3978)). +* (pypi) Allow `uv_lock` to be specified in `pip.parse` without requiring + `requirements_lock` (or other os-specific requirement file attributes) to be + set. +* (pypi) Requirement `--hash=:` pins and Simple API + `#=` URL fragments are now parsed for all hash algorithms + instead of silently dropping everything except `sha256`. Non-sha256 pins are + matched against the digests advertised by the index and downloads are verified + using the corresponding Subresource Integrity value, and the pins are kept in + the requirement line when falling back to `pip` + ([#3972](https://github.com/bazel-contrib/rules_python/issues/3972)). + As part of this, `whl_library` repos created by `pip.parse` now always pass + the digest via the `integrity` attribute (SRI format) instead of `sha256`, + and the lock file facts store digests as `:` values (the facts + version was bumped, so cached index information is refreshed once). +* (pypi) `pip.parse(uv_lock = ...)` no longer exposes uv workspace/root members + that resolve to no wheel or sdist (e.g. `source = { virtual = "." }` or editable + installs). Previously these source-less packages were added to the hub's + `all_requirements` / `all_whl_requirements` with an alias to a subpackage that + does not exist, breaking analysis for anything enumerating the full set such as + `modules_mapping(wheels = all_whl_requirements)` + ([#3934](https://github.com/bazel-contrib/rules_python/issues/3934)). +* (pypi) correctly parse the `index_url` for each wheel so that the source registry is forwarded to + the {obj}`whl_library`. This is so that the `purl` for `package_metadata` can be correctly + constructed. +* (pypi) fixed the URL normalization function to correctly handle local paths + enabling wheel sources files to point to an absolute path. Currently it supports + the `file://` for linux and windows like paths. We also support + envsubst for the said paths from now on. + +{#v2-3-0-added} +### Added +* (bzlmod) Added MODULE.bazel flag aliases for Starlark-defined flags: + `build_python_zip`, `incompatible_default_to_explicit_init_py`, + `python_path`, and `experimental_python_import_all_repositories`. +* (cc) Added experimental {obj}`py_extension` macro for creating C/C++ Python + extension modules + ([#3283](https://github.com/bazel-contrib/rules_python/issues/3283)). + (cc) Added `libc`, `platform_machine`, `platform_tag`, `soabi`, and + `sys_platform` attributes and info fields to {obj}`py_cc_toolchain` / + {obj}`PyCcToolchainInfo`. +* (pip,python) Added `pyproject_toml` attribute to {obj}`pip.default`, {obj}`pip.parse` and {obj}`python.defaults` to read the default Python version from the `requires-python` field of `pyproject.toml`. +* (py_test) Added an opt-in safeguard against `py_test` targets that silently + pass without running any tests. Set + {obj}`--@rules_python//python/config_settings:validate_test_main=enabled` to + fail the build when a test's main module only contains inert top-level + statements (definitions, imports, assignments) and never invokes a test + runner ([#3824](https://github.com/bazel-contrib/rules_python/issues/3824)). + + {#v2-2-0} ## [2.2.0] - 2026-06-30 diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md index 78f8937a4d..9ae0665da2 100644 --- a/docs/api/rules_python/python/config_settings/index.md +++ b/docs/api/rules_python/python/config_settings/index.md @@ -276,7 +276,7 @@ Enabling this requires the exec tools toolchain (with an exec interpreter) to be registered, which is the case for the default hermetic toolchains. ::: -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: :::: diff --git a/news/3514.added.md b/news/3514.added.md deleted file mode 100644 index bf2a119380..0000000000 --- a/news/3514.added.md +++ /dev/null @@ -1 +0,0 @@ -(pip,python) Added `pyproject_toml` attribute to {obj}`pip.default`, {obj}`pip.parse` and {obj}`python.defaults` to read the default Python version from the `requires-python` field of `pyproject.toml`. diff --git a/news/3825.added.md b/news/3825.added.md deleted file mode 100644 index 71b20aca91..0000000000 --- a/news/3825.added.md +++ /dev/null @@ -1,6 +0,0 @@ -(py_test) Added an opt-in safeguard against `py_test` targets that silently -pass without running any tests. Set -{obj}`--@rules_python//python/config_settings:validate_test_main=enabled` to -fail the build when a test's main module only contains inert top-level -statements (definitions, imports, assignments) and never invokes a test -runner ([#3824](https://github.com/bazel-contrib/rules_python/issues/3824)). diff --git a/news/3858.fixed.md b/news/3858.fixed.md deleted file mode 100644 index dd991e4654..0000000000 --- a/news/3858.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -(compile_pip_requirements) Add the explicit `data` attribute and forward it -directly to the generated `py_binary`, so files passed via `data` can be -referenced from `extra_args` using `$(location ...)`. diff --git a/news/3906.fixed.md b/news/3906.fixed.md deleted file mode 100644 index 64a16fd9c8..0000000000 --- a/news/3906.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -(pypi) correctly parse the `index_url` for each wheel so that the source registry is forwarded to -the {obj}`whl_library`. This is so that the `purl` for `package_metadata` can be correctly -constructed. diff --git a/news/3919.fixed.md b/news/3919.fixed.md deleted file mode 100644 index 0e45d3ce9c..0000000000 --- a/news/3919.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -Fixed `py_binary_rule_builder()` / `py_test_rule_builder()` (from `python/api/executables.bzl`) -failing at analysis time with a visibility error when used to construct a custom rule from an -external module. diff --git a/news/3932.added.md b/news/3932.added.md deleted file mode 100644 index 579d1b5f59..0000000000 --- a/news/3932.added.md +++ /dev/null @@ -1,3 +0,0 @@ -(bzlmod) Added MODULE.bazel flag aliases for Starlark-defined flags: -`build_python_zip`, `incompatible_default_to_explicit_init_py`, -`python_path`, and `experimental_python_import_all_repositories`. diff --git a/news/3934.fixed.md b/news/3934.fixed.md deleted file mode 100644 index a30f475770..0000000000 --- a/news/3934.fixed.md +++ /dev/null @@ -1,7 +0,0 @@ -(pypi) `pip.parse(uv_lock = ...)` no longer exposes uv workspace/root members -that resolve to no wheel or sdist (e.g. `source = { virtual = "." }` or editable -installs). Previously these source-less packages were added to the hub's -`all_requirements` / `all_whl_requirements` with an alias to a subpackage that -does not exist, breaking analysis for anything enumerating the full set such as -`modules_mapping(wheels = all_whl_requirements)` -([#3934](https://github.com/bazel-contrib/rules_python/issues/3934)). diff --git a/news/3935.fixed.md b/news/3935.fixed.md deleted file mode 100644 index 83cd0473e8..0000000000 --- a/news/3935.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -(pypi) fixed the URL normalization function to correctly handle local paths -enabling wheel sources files to point to an absolute path. Currently it supports -the `file://` for linux and windows like paths. We also support -envsubst for the said paths from now on. diff --git a/news/3950.fixed.md b/news/3950.fixed.md deleted file mode 100644 index 87e0bd91a6..0000000000 --- a/news/3950.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -(coverage) The warning about a missing bundled `coverage.py` wheel is no longer -emitted as we are now falling back to a pure python wheel -([#3950](https://github.com/bazel-contrib/rules_python/issues/3950)). diff --git a/news/3952.fixed.md b/news/3952.fixed.md deleted file mode 100644 index 70558dd986..0000000000 --- a/news/3952.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -(pypi) Allow `uv_lock` to be specified in `pip.parse` without requiring -`requirements_lock` (or other os-specific requirement file attributes) to be -set. diff --git a/news/3972.fixed.md b/news/3972.fixed.md deleted file mode 100644 index f96f803988..0000000000 --- a/news/3972.fixed.md +++ /dev/null @@ -1,11 +0,0 @@ -(pypi) Requirement `--hash=:` pins and Simple API -`#=` URL fragments are now parsed for all hash algorithms -instead of silently dropping everything except `sha256`. Non-sha256 pins are -matched against the digests advertised by the index and downloads are verified -using the corresponding Subresource Integrity value, and the pins are kept in -the requirement line when falling back to `pip` -([#3972](https://github.com/bazel-contrib/rules_python/issues/3972)). -As part of this, `whl_library` repos created by `pip.parse` now always pass -the digest via the `integrity` attribute (SRI format) instead of `sha256`, -and the lock file facts store digests as `:` values (the facts -version was bumped, so cached index information is refreshed once). diff --git a/news/3973.added.md b/news/3973.added.md deleted file mode 100644 index 20d136179a..0000000000 --- a/news/3973.added.md +++ /dev/null @@ -1,6 +0,0 @@ -(cc) Added experimental {obj}`py_extension` macro for creating C/C++ Python -extension modules -([#3283](https://github.com/bazel-contrib/rules_python/issues/3283)). -(cc) Added `libc`, `platform_machine`, `platform_tag`, `soabi`, and -`sys_platform` attributes and info fields to {obj}`py_cc_toolchain` / -{obj}`PyCcToolchainInfo`. diff --git a/news/3978.changed.md b/news/3978.changed.md deleted file mode 100644 index 8418abd0b3..0000000000 --- a/news/3978.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -(gazelle) **BREAKING** rules_python 1.5.0 or higher is now required. The Python -extension selects its standard library list on `is_python_3.14`, which earlier -versions do not define. diff --git a/news/3978.fixed.md b/news/3978.fixed.md deleted file mode 100644 index 2c5cbb5f5d..0000000000 --- a/news/3978.fixed.md +++ /dev/null @@ -1,5 +0,0 @@ -(gazelle) The Python extension now uses the correct standard library module list for -`python_version` 3.13 and 3.14; previously both fell back to the 3.11 list, so modules -added or removed since then (e.g. `compression.zstd`, `telnetlib`) were misclassified. The -fallback list for unrecognized versions is now the newest available one rather than 3.11 -([#3978](https://github.com/bazel-contrib/rules_python/pull/3978)). diff --git a/python/cc/py_extension.bzl b/python/cc/py_extension.bzl index d81877a702..8e90517789 100644 --- a/python/cc/py_extension.bzl +++ b/python/cc/py_extension.bzl @@ -11,7 +11,7 @@ for information on writing C extension modules. :::{include} /_includes/experimental_api.md ::: -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """ diff --git a/python/private/cc/py_extension_macro.bzl b/python/private/cc/py_extension_macro.bzl index 6b697ab503..b76685113a 100644 --- a/python/private/cc/py_extension_macro.bzl +++ b/python/private/cc/py_extension_macro.bzl @@ -50,7 +50,7 @@ def py_extension( - `module_name`: Pass `module_name = "custom_name"` to override the base module filename. - :::{versionadded} VERSION_NEXT_FEATURE + :::{versionadded} 2.3.0 ::: Args: diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl index 208a9589ba..a56ae7c960 100644 --- a/python/private/py_cc_toolchain_info.bzl +++ b/python/private/py_cc_toolchain_info.bzl @@ -22,7 +22,7 @@ PyCcToolchainInfo = provider( The runtime's ABI flags, i.e. `sys.abiflags` (e.g. 't' for free-threaded builds). -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, "headers": """\ @@ -106,7 +106,7 @@ If available, information about C libraries, struct with fields: The {pep}`PEP 508` `platform_machine` marker value for the target architecture, e.g. 'x86_64', 'aarch64'. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, "platform_tag": """\ @@ -115,7 +115,7 @@ value for the target architecture, e.g. 'x86_64', 'aarch64'. The PEP 3149 / PEP 425 platform tag for extension modules, e.g. 'x86_64-linux-gnu', 'darwin', or 'win_amd64'. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, "python_version": """ @@ -130,7 +130,7 @@ The SOABI tag for extension modules (see [PEP 3149](https://peps.python.org/pep-3149/)), e.g. 'cpython-311-x86_64-linux-gnu' or 'cp311'. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, "sys_platform": """ diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl index 02d4222fc8..405a8afe56 100644 --- a/python/private/py_cc_toolchain_rule.bzl +++ b/python/private/py_cc_toolchain_rule.bzl @@ -143,7 +143,7 @@ If not set, or set to ``, the ABI flags are automatically derived from `--//python/config_settings:py_freethreaded` (e.g., `'t'` when free-threaded is enabled, or `''` otherwise). -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, ), @@ -171,7 +171,7 @@ attribute is available or not. doc = """\ Target C library variant, e.g. 'glibc', 'musl'. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, default = "", @@ -185,7 +185,7 @@ Target C library variant, e.g. 'glibc', 'musl'. doc = """ Target architecture as a PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarch64', 'x86_32'. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, default = "", @@ -199,7 +199,7 @@ Target architecture as a PEP 508 `platform_machine` marker, e.g. 'x86_64', 'aarc The SOABI tag for extension modules (see PEP 3149), e.g. 'cpython-311-x86_64-linux-gnu' or 'cp311'. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, default = "", diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index caca92a5f7..ed1fa28664 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -751,7 +751,7 @@ The version must be specified as `==X.Y.Z` (exact version with full semver). This is designed to work with dependency management tools like Renovate. ::: -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, ), @@ -934,7 +934,7 @@ for this `pip.parse()` call, unless `python_version` is set explicitly. The version must be specified as `==X.Y.Z` (exact version with full semver). ::: -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, ), @@ -952,7 +952,7 @@ a corresponding `python.toolchain()` configured. The {obj}`pyproject_toml` attribute for getting the version from a project file. ::: -:::{versionchanged} VERSION_NEXT_FEATURE +:::{versionchanged} 2.3.0 No longer mandatory if the {obj}`pyproject_toml` attribute or {obj}`pip.default.pyproject_toml` is specified. ::: diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl index 640a6518a6..47b05468ef 100644 --- a/python/private/pypi/whl_library.bzl +++ b/python/private/pypi/whl_library.bzl @@ -587,7 +587,7 @@ The expected checksum of the downloaded whl in Subresource Integrity format (e.g. `sha256-...` or `sha512-...`). Only used when `urls` is passed. If `sha256` is also set, it takes precedence over this attribute. -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, ), @@ -673,7 +673,7 @@ The `whl_library` is marked as reproducible if using starlark to extract and par wheel contents without building an `sdist` first. ::: -:::{versionchanged} VERSION_NEXT_FEATURE +:::{versionchanged} 2.3.0 The whl-only pure Starlark operations have been refactored into {obj}`whl_archive` and the previously named {obj}`whl_library` repository became renamed to `pip_archive`. ::: diff --git a/python/private/python.bzl b/python/private/python.bzl index 0d7d2baf40..8932d00e1b 100644 --- a/python/private/python.bzl +++ b/python/private/python.bzl @@ -1117,7 +1117,7 @@ Label pointing to pyproject.toml file to read the default Python version from. When specified, reads the `requires-python` field from pyproject.toml. The version must be specified as `==X.Y.Z` (exact version with full semver). -:::{versionadded} VERSION_NEXT_FEATURE +:::{versionadded} 2.3.0 ::: """, ), From 8293c76f9235b2d67ff286e4a297084a3366b812 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 7 Aug 2026 16:27:40 -0700 Subject: [PATCH 157/179] chore(release): add release-prepared label to prepare PRs (#4015) Release preparation pull requests created by the release prepare tool did not have the `release-prepared` label applied upon creation. This prevented downstream automation and CI workflows that filter or trigger on this label from detecting newly prepared release PRs. To address this, define the `release-prepared` label constant in GitHub helpers, attach the label when creating prepare PRs, and update test assertions to verify label assignment. --- tests/tools/private/release/prepare_test.py | 4 ++++ tools/private/release/gh.py | 1 + tools/private/release/prepare.py | 2 ++ 3 files changed, 7 insertions(+) diff --git a/tests/tools/private/release/prepare_test.py b/tests/tools/private/release/prepare_test.py index 3a3e886f45..64275df96f 100644 --- a/tests/tools/private/release/prepare_test.py +++ b/tests/tools/private/release/prepare_test.py @@ -1,5 +1,6 @@ import argparse +from tools.private.release.gh import RELEASE_PREPARED_LABEL from tools.private.release.prepare import Prepare pytest_plugins = ["tests.tools.private.release.release_test_helper"] @@ -27,6 +28,7 @@ def test_prepare_success_existing_issue(mocker, release_tool_env, mock_git, mock assert 1002 in mock_gh.prs assert mock_gh.prs[1002]["title"] == "Prepare release v2.0.0" assert mock_gh.prs[1002]["body"] == "Work towards #1001" + assert mock_gh.prs[1002]["labels"] == [RELEASE_PREPARED_LABEL] mock_git.add_modified_and_deleted.assert_called_once() @@ -49,6 +51,7 @@ def test_prepare_success_create_issue(mocker, release_tool_env, mock_git, mock_g assert 1002 in mock_gh.prs assert mock_gh.prs[1002]["title"] == "Prepare release v2.0.0" assert mock_gh.prs[1002]["body"] == "Work towards #1001" + assert mock_gh.prs[1002]["labels"] == [RELEASE_PREPARED_LABEL] mock_git.add_modified_and_deleted.assert_called_once() @@ -154,6 +157,7 @@ def test_prepare_create_pr_when_none_associated( ) updated_body = mock_gh.get_issue_body(1001) assert "pr=#1002" in updated_body + assert mock_gh.prs[1002]["labels"] == [RELEASE_PREPARED_LABEL] def test_prepare_reuse_existing_pr(mocker, release_tool_env, mock_git, mock_gh): diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 17fcd59a69..c21041384e 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -13,6 +13,7 @@ # GitHub label types RELEASE_LABEL = "type: release" BACKPORT_LABEL = "type: backport-pr" +RELEASE_PREPARED_LABEL = "release-prepared" # GitHub reaction types # See: https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index b01f135ff8..00412a8600 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -6,6 +6,7 @@ from tools.private.release import changelog_news from tools.private.release.gh import ( + RELEASE_PREPARED_LABEL, GitHub, MultipleTrackingIssuesError, NoTrackingIssueError, @@ -177,6 +178,7 @@ def run(self) -> int: title=f"Prepare release v{version}", body=f"Work towards #{issue_num}", base="main", + labels=[RELEASE_PREPARED_LABEL], ) pr_num = pr_url.split("/")[-1] print(f"Created Pull Request: {pr_url} (PR #{pr_num})") From caa22bd9229cc9e4b246334bb1b2d0c064cedf2f Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 7 Aug 2026 20:12:45 -0700 Subject: [PATCH 158/179] refactor(tests): convert legacy py_extension in local_toolchains to official rule (#3988) The local_toolchains integration test workspace previously relied on a custom, partial implementation of py_extension that predated the official rule. Replace this legacy test implementation with the official @rules_python//python/cc:py_extension.bzl rule, simplifying the target definition to use sources directly. --- python/private/get_local_runtime_info.py | 4 +- python/private/local_runtime_repo_setup.bzl | 8 + .../integration/local_toolchains/BUILD.bazel | 21 +-- .../integration/local_toolchains/echo_test.py | 2 +- .../local_toolchains/py_extension.bzl | 154 ------------------ 5 files changed, 20 insertions(+), 169 deletions(-) delete mode 100644 tests/integration/local_toolchains/py_extension.bzl diff --git a/python/private/get_local_runtime_info.py b/python/private/get_local_runtime_info.py index e0eab962d6..a4f622700b 100644 --- a/python/private/get_local_runtime_info.py +++ b/python/private/get_local_runtime_info.py @@ -268,7 +268,9 @@ def _get_base_executable() -> str: "implementation_name": sys.implementation.name, "base_executable": _get_base_executable(), "sys_platform": sys.platform, - "platform_machine": platform.machine(), + # Normalize to lowercase: on Windows, platform.machine() returns uppercase + # "AMD64" / "ARM64", whereas PEP 508 and toolchains expect lowercase. + "platform_machine": platform.machine().lower(), } data.update(_get_python_library_info(_get_base_executable())) print(json.dumps(data)) diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl index 62e46e4b98..05ca6228e0 100644 --- a/python/private/local_runtime_repo_setup.bzl +++ b/python/private/local_runtime_repo_setup.bzl @@ -137,6 +137,10 @@ def define_local_runtime_toolchain_impl( hdrs = [":includes"], defines = defines, # NOTE: Users should define Py_LIMITED_API=3 srcs = abi3_libraries + additional_dlls, + deps = select({ + "@bazel_tools//src/conditions:windows": [":abi3_interface"], + "//conditions:default": [], + }), ) cc_library( @@ -144,6 +148,10 @@ def define_local_runtime_toolchain_impl( hdrs = [":includes"], defines = defines, srcs = libraries + additional_dlls, + deps = select({ + "@bazel_tools//src/conditions:windows": [":interface"], + "//conditions:default": [], + }), ) # runtime configuration diff --git a/tests/integration/local_toolchains/BUILD.bazel b/tests/integration/local_toolchains/BUILD.bazel index 20ee7bcfe6..ae99e7cf2f 100644 --- a/tests/integration/local_toolchains/BUILD.bazel +++ b/tests/integration/local_toolchains/BUILD.bazel @@ -13,9 +13,8 @@ # limitations under the License. load("@bazel_skylib//rules:common_settings.bzl", "string_flag") -load("@rules_cc//cc:cc_library.bzl", "cc_library") load("@rules_python//python:py_test.bzl", "py_test") -load(":py_extension.bzl", "py_extension") +load("@rules_python//python/cc:py_extension.bzl", "py_extension") py_test( name = "local_runtime_test", @@ -60,28 +59,24 @@ string_flag( ) # Build rules to generate a python extension. -cc_library( - name = "echo_ext_cc", - testonly = True, - srcs = ["echo_ext.cc"], - deps = [ - "@rules_python//python/cc:current_py_cc_headers", - ], - alwayslink = True, -) - py_extension( name = "echo_ext", testonly = True, + srcs = ["echo_ext.cc"], copts = select({ "@rules_cc//cc/compiler:msvc-cl": [], "//conditions:default": ["-fvisibility=hidden"], }), - deps = [":echo_ext_cc"], + imports = ["."], ) py_test( name = "echo_test", srcs = ["echo_test.py"], + # Make this test better respect pyenv/local Python DLLs on Windows + env_inherit = [ + "PYENV_VERSION", + "PATH", + ], deps = [":echo_ext"], ) diff --git a/tests/integration/local_toolchains/echo_test.py b/tests/integration/local_toolchains/echo_test.py index 17121e0f17..655205fe29 100644 --- a/tests/integration/local_toolchains/echo_test.py +++ b/tests/integration/local_toolchains/echo_test.py @@ -5,4 +5,4 @@ class ExtensionTest(unittest.TestCase): def test_echo_extension(self): - self.assertEqual(echo_ext.echo(42, "str"), tuple(42, "str")) + self.assertEqual(echo_ext.echo(42, "str"), (42, "str")) diff --git a/tests/integration/local_toolchains/py_extension.bzl b/tests/integration/local_toolchains/py_extension.bzl deleted file mode 100644 index 5d37fd7824..0000000000 --- a/tests/integration/local_toolchains/py_extension.bzl +++ /dev/null @@ -1,154 +0,0 @@ -# Copyright 2025 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. - -"""Macro to build a python C/C++ extension. - -There are variants of py_extension in many other projects, such as: -* https://github.com/protocolbuffers/protobuf/tree/main/python/py_extension.bzl -* https://github.com/google/riegeli/blob/master/python/riegeli/py_extension.bzl -* https://github.com/pybind/pybind11_bazel/blob/master/build_defs.bzl - -The issue for a generic verion is: -* https://github.com/bazel-contrib/rules_python/issues/824 -""" - -load("@bazel_skylib//rules:copy_file.bzl", "copy_file") -load("@rules_cc//cc:cc_binary.bzl", "cc_binary") -load("@rules_python//python:defs.bzl", "py_library") - -def py_extension( - *, - name, - deps = None, - linkopts = None, - imports = None, - visibility = None, - **kwargs): - """Creates a Python module implemented in C++. - - A Python extension has 2 essential parts: - 1. An internal shared object / pyd package for the extension, `name.pyd`/`name.so` - 2. The py_library target for the extension.` - - Python modules can depend on a py_extension. - - Args: - name: `str`. Name for this target. This is typically the module name. - deps: `list`. Required. C++ libraries to link into the module. - linkopts: `list`. Linking options for the shared library. - imports: `list`. Additional imports for the py_library rule. - visibility: `str`. Visibility for target. - **kwargs: Additional options for the cc_library rule. - """ - if not name: - fail("py_extension requires a name") - if not deps: - fail("py_extension requires a non-empty deps attribute") - if "linkshared" in kwargs: - fail("py_extension attribute linkshared not allowed") - - if not linkopts: - linkopts = [] - - testonly = kwargs.get("testonly") - tags = kwargs.pop("tags", []) - - cc_binary_so_name = name + ".so" - cc_binary_dll_name = name + ".dll" - cc_binary_pyd_name = name + ".pyd" - linker_script_name = name + ".lds" - linker_script_name_rule = name + "_lds" - shared_objects_name = name + "__shared_objects" - - # On Unix, restrict symbol visibility. - exported_symbol = "PyInit_" + name - - # Generate linker script used on non-macOS unix platforms. - native.genrule( - name = linker_script_name_rule, - outs = [linker_script_name], - cmd = "\n".join([ - "cat <<'EOF' >$@", - "{", - " global: " + exported_symbol + ";", - " local: *;", - "};", - "EOF", - ]), - ) - - for cc_binary_name in [cc_binary_dll_name, cc_binary_so_name]: - cur_linkopts = linkopts - cur_deps = deps - if cc_binary_name == cc_binary_so_name: - cur_linkopts = linkopts + select({ - "@platforms//os:macos": [ - # Avoid undefined symbol errors for CPython symbols that - # will be resolved at runtime. - "-undefined", - "dynamic_lookup", - # On macOS, the linker does not support version scripts. Use - # the `-exported_symbol` option instead to restrict symbol - # visibility. - "-Wl,-exported_symbol", - # On macOS, the symbol starts with an underscore. - "-Wl,_" + exported_symbol, - ], - # On non-macOS unix, use a version script to restrict symbol - # visibility. - "//conditions:default": [ - "-Wl,--version-script", - "-Wl,$(location :" + linker_script_name + ")", - ], - }) - cur_deps = cur_deps + select({ - "@platforms//os:macos": [], - "//conditions:default": [linker_script_name], - }) - - cc_binary( - name = cc_binary_name, - linkshared = True, - visibility = ["//visibility:private"], - deps = cur_deps, - tags = tags + ["manual"], - linkopts = cur_linkopts, - **kwargs - ) - - copy_file( - name = cc_binary_pyd_name + "__pyd_copy", - src = ":" + cc_binary_dll_name, - out = cc_binary_pyd_name, - visibility = visibility, - tags = ["manual"], - testonly = testonly, - ) - - native.filegroup( - name = shared_objects_name, - data = select({ - "@platforms//os:windows": [":" + cc_binary_pyd_name], - "//conditions:default": [":" + cc_binary_so_name], - }), - testonly = testonly, - ) - py_library( - name = name, - data = [":" + shared_objects_name], - imports = imports, - tags = tags, - testonly = testonly, - visibility = visibility, - ) From 0e6d24a83abc0410ab9329f3ec4099333c5a987f Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 8 Aug 2026 13:10:04 -0700 Subject: [PATCH 159/179] chore(release): support manual dispatch and /prepare-complete for complete prepare workflow (#4016) Previously, the release preparation completion workflow only triggered automatically when a pull request with the `release-prepared` label was closed as merged. If the automatic trigger was missed or failed, maintainers had no mechanism to trigger completion on demand or update the release tracking issue checklist state. Support manual dispatch and comment-driven triggers with bidirectional issue/PR resolution. The workflow can now be invoked directly via manual dispatch, reusable workflow calls, or the `/prepare-complete` comment command on tracking issues and PRs. The release CLI tool resolves tracking issues and PRs from each other to mark the task complete in the tracking issue. --- .../release_tracking_template.md | 1 + .github/workflows/on_comment.yaml | 19 +++ .../workflows/release_complete_prepare.yaml | 49 ++++++-- RELEASING.md | 10 +- tests/tools/private/release/BUILD.bazel | 11 ++ .../private/release/complete_prepare_test.py | 109 ++++++++++++++++++ tools/private/release/complete_prepare.py | 75 ++++++++---- 7 files changed, 241 insertions(+), 33 deletions(-) create mode 100644 tests/tools/private/release/complete_prepare_test.py diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md index 57a51a16ef..349de8c506 100644 --- a/.github/ISSUE_TEMPLATE/release_tracking_template.md +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -23,6 +23,7 @@ To manually control the release flow, see the [RELEASING.md: Manual Editing](htt Comment commands: - `/prepare`: Determines version, creates tracking issue and preparation PR. +- `/prepare-complete [PR]`: Marks preparation task as complete. - `/create-rc`: Tags and publishes a new release candidate (RC). - `/process-backports`: Cherry-picks pending backports. - `/add-backports `: Adds PRs to the backports and processes backports. diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml index 7e4645f3fe..e2f742ba8c 100644 --- a/.github/workflows/on_comment.yaml +++ b/.github/workflows/on_comment.yaml @@ -50,6 +50,13 @@ jobs: # Handle /create-rc comment if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then echo "command=create-rc" >> "$GITHUB_OUTPUT" + # Handle /prepare-complete comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare-complete([[:space:]]|$)'; then + echo "command=prepare-complete" >> "$GITHUB_OUTPUT" + pr_arg=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/prepare-complete([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/prepare-complete[[:space:]]*//' | tr -d '[:space:]#') + if [ -n "$pr_arg" ]; then + echo "pr_number=$pr_arg" >> "$GITHUB_OUTPUT" + fi # Handle /prepare comment elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then echo "command=prepare" >> "$GITHUB_OUTPUT" @@ -100,6 +107,9 @@ jobs: if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then echo "command=pr-backport" >> "$GITHUB_OUTPUT" echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare-complete([[:space:]]|$)'; then + echo "command=prepare-complete" >> "$GITHUB_OUTPUT" + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" else echo "command=none" >> "$GITHUB_OUTPUT" fi @@ -116,6 +126,15 @@ jobs: comment_id: "${{ github.event.comment.id }}" secrets: inherit + call_prepare_complete: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'prepare-complete' + uses: ./.github/workflows/release_complete_prepare.yaml + with: + pr: ${{ needs.parse_comment.outputs.pr_number }} + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + call_prepare: needs: parse_comment if: needs.parse_comment.outputs.command == 'prepare' diff --git a/.github/workflows/release_complete_prepare.yaml b/.github/workflows/release_complete_prepare.yaml index 6d8bc8fd03..a084b6f682 100644 --- a/.github/workflows/release_complete_prepare.yaml +++ b/.github/workflows/release_complete_prepare.yaml @@ -1,19 +1,41 @@ -name: "Release: Complete Prepare" +name: "Release: Prepare: Complete" on: pull_request: types: [closed] + workflow_dispatch: + inputs: + pr: + description: 'The merged preparation PR number (optional if issue provided)' + required: false + type: string + issue: + description: 'The Release Tracking Issue Number (optional if pr provided)' + required: false + type: string + workflow_call: + inputs: + pr: + description: 'The merged preparation PR number (optional if issue provided)' + required: false + type: string + issue: + description: 'The Release Tracking Issue Number (optional if pr provided)' + required: false + type: string permissions: contents: write issues: write jobs: - on_pr_merged: - # Run only if the release-prepared PR was merged + complete_prepare: + # Run if triggered manually or if the release-prepared PR was merged if: | - github.event.pull_request.merged == true && - contains(github.event.pull_request.labels.*.name, 'release-prepared') + github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_call' || + (github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release-prepared')) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -27,9 +49,20 @@ jobs: bazelisk-version: 1.20.0 - name: Mark Prepare Release Complete + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ inputs.pr || github.event.pull_request.number }} + ISSUE: ${{ inputs.issue }} run: | + ARGS=() + if [ -n "$PR_NUMBER" ]; then + PR_NUMBER="${PR_NUMBER#\#}" + ARGS+=("--pr=$PR_NUMBER") + fi + if [ -n "$ISSUE" ]; then + ISSUE="${ISSUE#\#}" + ARGS+=("--issue=$ISSUE") + fi # Run the complete-prepare subcommand in the release tool to cleanly update checklist metadata bazel run //tools/private/release -- \ - complete-prepare --pr ${{ github.event.pull_request.number }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + complete-prepare "${ARGS[@]}" diff --git a/RELEASING.md b/RELEASING.md index 2b3d5859c1..badf33fb7c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,8 +27,12 @@ Release Tracking Issue and automated workflows triggered by comments or issue ed This will automatically determine the next version, create a release tracking issue, and send a preparation PR. -2. **Approve and Merge**: Approve and merge the PR. Once merged, a release - branch will be created automatically. +2. **Approve and Merge**: Approve and merge the PR. Once merged, the + [Release: Prepare: Complete](https://github.com/bazel-contrib/rules_python/actions/workflows/release_complete_prepare.yaml) + workflow automatically updates the tracking issue checklist, and a release + branch will be created. If the automatic trigger is missed, comment + `/prepare-complete` on the tracking issue or on the PR, or run the workflow + manually with the PR number. 3. **Add Backports (if needed)**: If there are backports, add them following the [How to add backports](#how-to-add-backports) steps. @@ -253,6 +257,8 @@ You can manually edit the Release Tracking Issue to control the release flow. The checklist items use metadata suffix: `| key=value key2=value2`. * **Retry Prepare Release**: Reset the task to `- [ ] Prepare Release | status=awaiting-preparation`. +* **Complete Prepare Release**: Comment `/prepare-complete` on the tracking + issue (or on the preparation PR) to mark the preparation task as done. * **Force Task Done**: Check the box `- [x]` and add appropriate metadata (e.g. `status=done`). ## Secrets diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index b666cf8b54..dcef2ab53a 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -46,6 +46,17 @@ pytest_test( ], ) +pytest_test( + name = "complete_prepare_test", + srcs = ["complete_prepare_test.py"], + target_compatible_with = NOT_WINDOWS, + deps = [ + ":conftest", + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + pytest_test( name = "complete_sync_changelog_test", srcs = ["complete_sync_changelog_test.py"], diff --git a/tests/tools/private/release/complete_prepare_test.py b/tests/tools/private/release/complete_prepare_test.py new file mode 100644 index 0000000000..4f28cce66e --- /dev/null +++ b/tests/tools/private/release/complete_prepare_test.py @@ -0,0 +1,109 @@ +import argparse + +from tools.private.release.complete_prepare import CompletePrepare + +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +def test_complete_prepare_with_pr_success(mock_gh): + args = argparse.Namespace(pr=456, issue=None) + mock_gh.prs[456] = { + "state": "MERGED", + "body": "Prepare release for v2.0.0\n\nWork towards #123", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + issue_body = """ +## Checklist +- [ ] Prepare Release | status=pending pr=#456 +- [ ] Create Release branch +- [ ] Tag Final +""" + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": issue_body, + "labels": ["type: release"], + "number": 123, + "url": "https://github.com/bazel-contrib/rules_python/issues/123", + } + + result = CompletePrepare(args, mock_gh).run() + + assert result == 0 + updated_body = mock_gh.get_issue_body(123) + assert ( + "- [x] Prepare Release | status=done pr=#456 commit= abcdef12" in updated_body + ) + + +def test_complete_prepare_with_issue_success(mock_gh): + args = argparse.Namespace(pr=None, issue=123) + mock_gh.prs[456] = { + "state": "MERGED", + "body": "Prepare release for v2.0.0", + "mergeCommit": {"oid": "1234567890abcdef"}, + } + issue_body = """ +## Checklist +- [ ] Prepare Release | status=pending pr=#456 +- [ ] Create Release branch +- [ ] Tag Final +""" + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": issue_body, + "labels": ["type: release"], + "number": 123, + "url": "https://github.com/bazel-contrib/rules_python/issues/123", + } + + result = CompletePrepare(args, mock_gh).run() + + assert result == 0 + updated_body = mock_gh.get_issue_body(123) + assert ( + "- [x] Prepare Release | status=done pr=#456 commit= 12345678" in updated_body + ) + + +def test_complete_prepare_no_args(mock_gh): + args = argparse.Namespace(pr=None, issue=None) + result = CompletePrepare(args, mock_gh).run() + assert result == 1 + + +def test_complete_prepare_not_merged(mock_gh): + args = argparse.Namespace(pr=456, issue=123) + mock_gh.prs[456] = { + "state": "OPEN", + "body": "Prepare release for v2.0.0", + } + issue_body = """ +## Checklist +- [ ] Prepare Release | status=pending pr=#456 +""" + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": issue_body, + "labels": ["type: release"], + "number": 123, + } + + result = CompletePrepare(args, mock_gh).run() + assert result == 1 + + +def test_complete_prepare_issue_missing_pr_task(mock_gh): + args = argparse.Namespace(pr=None, issue=123) + issue_body = """ +## Checklist +- [ ] Create Release branch +""" + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": issue_body, + "labels": ["type: release"], + "number": 123, + } + + result = CompletePrepare(args, mock_gh).run() + assert result == 1 diff --git a/tools/private/release/complete_prepare.py b/tools/private/release/complete_prepare.py index 8a60cbf0f3..23adba9b86 100644 --- a/tools/private/release/complete_prepare.py +++ b/tools/private/release/complete_prepare.py @@ -3,7 +3,10 @@ import re from tools.private.release.gh import GitHub -from tools.private.release.release_issue import update_task_in_body +from tools.private.release.release_issue import ( + parse_checklist_state, + update_task_in_body, +) class CompletePrepare: @@ -16,46 +19,66 @@ def __init__(self, args, gh: GitHub): def run(self) -> int: """Executes the complete-prepare subcommand (Phase 2 PR merged).""" args = self.args - print(f"Completing preparation for PR #{args.pr}...") + pr_number = args.pr + issue_number = args.issue - pr_info = self.gh.get_pr_info(args.pr) - if not pr_info or pr_info.get("state") != "MERGED": - state = pr_info.get("state", "UNKNOWN") - print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") + if not pr_number and not issue_number: + print("Error: Either --pr or --issue must be provided.") return 1 - # Resolve issue number from PR body - pr_body = pr_info.get("body") or "" - match = re.search(r"Work towards #(\d+)", pr_body) - if not match: - match = re.search(r"#(\d+)", pr_body) - if not match: - print( - f"Error: Could not determine tracking issue number from PR" - f" #{args.pr} body: {pr_body}" - ) + if not pr_number and issue_number: + issue_body = self.gh.get_issue_body(issue_number) + state = parse_checklist_state(issue_body) + prep_task = state.get("prepare_release") + if not prep_task or not prep_task.pr: + print( + f"Error: Could not find PR reference for 'Prepare Release'" + f" in issue #{issue_number}." + ) + return 1 + pr_number = int(prep_task.pr.lstrip("#")) + + print(f"Completing preparation for PR #{pr_number}...") + + pr_info = self.gh.get_pr_info(pr_number) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") if pr_info else "NOT_FOUND" + print(f"Error: PR #{pr_number} is not merged yet (state: {state}).") return 1 - issue_num = int(match.group(1)) - print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") + if not issue_number: + # Resolve issue number from PR body + pr_body = pr_info.get("body") or "" + match = re.search(r"Work towards #(\d+)", pr_body) + if not match: + match = re.search(r"#(\d+)", pr_body) + if not match: + print( + f"Error: Could not determine tracking issue number from PR" + f" #{pr_number} body: {pr_body}" + ) + return 1 + + issue_number = int(match.group(1)) + print(f"Resolved tracking issue #{issue_number} from PR #{pr_number} body.") commit_sha = pr_info["mergeCommit"]["oid"] short_commit = commit_sha[:8] print( - f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue..." + f"PR #{pr_number} merged at commit {commit_sha}. Updating tracking issue..." ) # Update checklist: mark Prepare Release as done (checked) and set SUCCESS - body = self.gh.get_issue_body(issue_num) + body = self.gh.get_issue_body(issue_number) metadata = { "status": "done", - "pr": f"#{args.pr}", + "pr": f"#{pr_number}", "commit": short_commit, } updated_body = update_task_in_body( body, "Prepare Release", checked=True, metadata=metadata ) - self.gh.update_issue_body(issue_num, updated_body) + self.gh.update_issue_body(issue_number, updated_body) print("Prepare Release task marked complete successfully!") return 0 @@ -69,9 +92,15 @@ def add_parser(cls, subparsers): parser.add_argument( "--pr", type=int, - required=True, + required=False, help="The merged preparation PR number.", ) + parser.add_argument( + "--issue", + type=int, + required=False, + help="The release tracking issue number.", + ) parser.set_defaults(command=cls.run_from_args) @classmethod From f33feb60789748bb9ca90880b92eb5d43bde6505 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 8 Aug 2026 13:32:32 -0700 Subject: [PATCH 160/179] chore(release): invoke create release branch from prepare complete and support /create-release-branch (#4017) Triggering the create release branch workflow on issue edits fired unnecessarily on unrelated edits and failed to trigger when other GitHub Actions modified the issue due to token recursion limits. Maintainers also lacked a direct comment command to manually cut the release branch from a release tracking issue if needed. Invoke the create release branch workflow automatically upon completion of the prepare workflow, support manual and reusable workflow dispatch with a tracking issue input, add `/create-release-branch` comment command support, and introduce a shared helper for writing GitHub Actions step outputs across release tools. --- .../release_tracking_template.md | 1 + .github/workflows/on_comment.yaml | 11 +++++++ .../workflows/release_complete_prepare.yaml | 10 +++++++ .../release_create_release_branch.yaml | 23 ++++++++++----- RELEASING.md | 2 ++ .../private/release/complete_prepare_test.py | 29 +++++++++++++++++++ .../private/release/release_test_helper.py | 6 +++- tools/private/release/complete_prepare.py | 4 +++ tools/private/release/create_rc.py | 8 ++--- tools/private/release/promote.py | 5 ++-- tools/private/release/utils.py | 7 +++++ 11 files changed, 90 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md index 349de8c506..ab634e75f3 100644 --- a/.github/ISSUE_TEMPLATE/release_tracking_template.md +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -24,6 +24,7 @@ To manually control the release flow, see the [RELEASING.md: Manual Editing](htt Comment commands: - `/prepare`: Determines version, creates tracking issue and preparation PR. - `/prepare-complete [PR]`: Marks preparation task as complete. +- `/create-release-branch`: Cuts and pushes the release branch. - `/create-rc`: Tags and publishes a new release candidate (RC). - `/process-backports`: Cherry-picks pending backports. - `/add-backports `: Adds PRs to the backports and processes backports. diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml index e2f742ba8c..a4330a0d6f 100644 --- a/.github/workflows/on_comment.yaml +++ b/.github/workflows/on_comment.yaml @@ -57,6 +57,9 @@ jobs: if [ -n "$pr_arg" ]; then echo "pr_number=$pr_arg" >> "$GITHUB_OUTPUT" fi + # Handle /create-release-branch comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-release-branch([[:space:]]|$)'; then + echo "command=create-release-branch" >> "$GITHUB_OUTPUT" # Handle /prepare comment elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then echo "command=prepare" >> "$GITHUB_OUTPUT" @@ -135,6 +138,14 @@ jobs: issue: ${{ needs.parse_comment.outputs.issue_number }} secrets: inherit + call_create_release_branch: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'create-release-branch' + uses: ./.github/workflows/release_create_release_branch.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + call_prepare: needs: parse_comment if: needs.parse_comment.outputs.command == 'prepare' diff --git a/.github/workflows/release_complete_prepare.yaml b/.github/workflows/release_complete_prepare.yaml index a084b6f682..2496dd9cd2 100644 --- a/.github/workflows/release_complete_prepare.yaml +++ b/.github/workflows/release_complete_prepare.yaml @@ -37,6 +37,8 @@ jobs: (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release-prepared')) runs-on: ubuntu-latest + outputs: + issue: ${{ steps.complete_prepare.outputs.issue }} steps: - name: Checkout repository uses: actions/checkout@v7 @@ -49,6 +51,7 @@ jobs: bazelisk-version: 1.20.0 - name: Mark Prepare Release Complete + id: complete_prepare env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ inputs.pr || github.event.pull_request.number }} @@ -66,3 +69,10 @@ jobs: # Run the complete-prepare subcommand in the release tool to cleanly update checklist metadata bazel run //tools/private/release -- \ complete-prepare "${ARGS[@]}" + + call_create_release_branch: + needs: complete_prepare + uses: ./.github/workflows/release_create_release_branch.yaml + with: + issue: ${{ needs.complete_prepare.outputs.issue }} + secrets: inherit diff --git a/.github/workflows/release_create_release_branch.yaml b/.github/workflows/release_create_release_branch.yaml index 4cbabc7d12..d251b1e394 100644 --- a/.github/workflows/release_create_release_branch.yaml +++ b/.github/workflows/release_create_release_branch.yaml @@ -1,8 +1,18 @@ name: "Release: Create Release Branch" on: - issues: - types: [edited] + workflow_dispatch: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string + workflow_call: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string permissions: contents: write @@ -10,8 +20,6 @@ permissions: jobs: cut_branch: - # Run only if the issue has the type: release label - if: "contains(github.event.issue.labels.*.name, 'type: release')" runs-on: ubuntu-latest steps: - name: Checkout repository @@ -30,11 +38,11 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Attempt Branch Creation - run: | - bazel run //tools/private/release -- \ - create-release-branch --issue ${{ github.event.issue.number }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bazel run //tools/private/release -- \ + create-release-branch --issue "${{ inputs.issue }}" --remote origin # A no-op job that always runs to prevent "no jobs ran" failures # when the main job is skipped. @@ -44,3 +52,4 @@ jobs: - name: Echo Success run: echo "Success" + diff --git a/RELEASING.md b/RELEASING.md index badf33fb7c..f16b13e582 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -259,6 +259,8 @@ The checklist items use metadata suffix: `| key=value key2=value2`. * **Retry Prepare Release**: Reset the task to `- [ ] Prepare Release | status=awaiting-preparation`. * **Complete Prepare Release**: Comment `/prepare-complete` on the tracking issue (or on the preparation PR) to mark the preparation task as done. +* **Create Release Branch**: Comment `/create-release-branch` on the tracking + issue to cut and push the release branch. * **Force Task Done**: Check the box `- [x]` and add appropriate metadata (e.g. `status=done`). ## Secrets diff --git a/tests/tools/private/release/complete_prepare_test.py b/tests/tools/private/release/complete_prepare_test.py index 4f28cce66e..b710c0d0c9 100644 --- a/tests/tools/private/release/complete_prepare_test.py +++ b/tests/tools/private/release/complete_prepare_test.py @@ -35,6 +35,35 @@ def test_complete_prepare_with_pr_success(mock_gh): ) +def test_complete_prepare_writes_github_output(release_tool_env, mock_gh): + args = argparse.Namespace(pr=456, issue=None) + mock_gh.prs[456] = { + "state": "MERGED", + "body": "Prepare release for v2.0.0\n\nWork towards #123", + "mergeCommit": {"oid": "abcdef1234567890"}, + } + issue_body = """ +## Checklist +- [ ] Prepare Release | status=pending pr=#456 +- [ ] Create Release branch +- [ ] Tag Final +""" + mock_gh.issues[123] = { + "title": "Release 2.0.0", + "body": issue_body, + "labels": ["type: release"], + "number": 123, + "url": "https://github.com/bazel-contrib/rules_python/issues/123", + } + + result = CompletePrepare(args, mock_gh).run() + assert result == 0 + assert release_tool_env.github_output_file.exists() + assert ( + release_tool_env.github_output_file.read_text(encoding="utf-8") == "issue=123\n" + ) + + def test_complete_prepare_with_issue_success(mock_gh): args = argparse.Namespace(pr=None, issue=123) mock_gh.prs[456] = { diff --git a/tests/tools/private/release/release_test_helper.py b/tests/tools/private/release/release_test_helper.py index c738d834f9..0b1c4e0351 100644 --- a/tests/tools/private/release/release_test_helper.py +++ b/tests/tools/private/release/release_test_helper.py @@ -13,9 +13,11 @@ class ReleaseToolEnv: Attributes: git_root: The root path of the temporary Git repository workspace. + github_output_file: Path to the mocked GITHUB_OUTPUT file. """ git_root: Path + github_output_file: Path DEFAULT_RELEASE_TEMPLATE_CONTENT = ( @@ -55,4 +57,6 @@ def fixture_release_tool_env(tmp_path, monkeypatch): template_dir.mkdir(parents=True, exist_ok=True) template_file = template_dir / "release_tracking_template.md" template_file.write_text(DEFAULT_RELEASE_TEMPLATE_CONTENT, encoding="utf-8") - yield ReleaseToolEnv(git_root=tmp_path) + github_output_file = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output_file)) + yield ReleaseToolEnv(git_root=tmp_path, github_output_file=github_output_file) diff --git a/tools/private/release/complete_prepare.py b/tools/private/release/complete_prepare.py index 23adba9b86..e585a53e8c 100644 --- a/tools/private/release/complete_prepare.py +++ b/tools/private/release/complete_prepare.py @@ -7,6 +7,7 @@ parse_checklist_state, update_task_in_body, ) +from tools.private.release.utils import set_github_output class CompletePrepare: @@ -80,6 +81,9 @@ def run(self) -> int: ) self.gh.update_issue_body(issue_number, updated_body) print("Prepare Release task marked complete successfully!") + + set_github_output("issue", str(issue_number)) + return 0 @classmethod diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 2e61e7d80a..0987bb3e43 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -1,5 +1,6 @@ """Subcommand to tag and push the next release candidate.""" +import os import traceback from argparse import Namespace @@ -16,6 +17,7 @@ from tools.private.release.utils import ( REPO_URL, get_latest_rc_tag, + set_github_output, ) @@ -142,11 +144,7 @@ def _run_internal(self) -> int: self.git.tag(next_rc, target_ref) self.git.push(args.remote, next_rc) - import os - - if "GITHUB_OUTPUT" in os.environ: - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"tag_name={next_rc}\n") + set_github_output("tag_name", next_rc) # Check off the appropriate "Tag RC{N}" task in the checklist print(f"Checking off Tag RC{next_rc_num} task...") diff --git a/tools/private/release/promote.py b/tools/private/release/promote.py index 66d5b74324..560c531383 100644 --- a/tools/private/release/promote.py +++ b/tools/private/release/promote.py @@ -15,6 +15,7 @@ determine_next_version, get_latest_rc_tag, semver_type, + set_github_output, ) @@ -170,9 +171,7 @@ def run(self) -> int: self.git.tag(version, commit_sha) self.git.push(args.remote, version) - if github_output := os.environ.get("GITHUB_OUTPUT"): - with open(github_output, "a", encoding="utf-8") as f: - f.write(f"version={version}\n") + set_github_output("version", version) print(f"Updating tracking issue #{issue_num} checklist...") self.gh.update_issue_body(issue_num, updated_body) diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 8cbbe89a41..ab1e0acef3 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -184,3 +184,10 @@ def parse_pr_list(value: str) -> list[str]: return [] # Split by space and/or comma return [p for p in re.split(r"[\s,]+", value.strip()) if p] + + +def set_github_output(name: str, value: str) -> None: + """Sets a GitHub Actions output parameter if GITHUB_OUTPUT is set.""" + if github_output := os.environ.get("GITHUB_OUTPUT"): + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"{name}={value}\n") From 028812a1297d4df4b3c69449aa9e08f9b291171f Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 8 Aug 2026 13:54:33 -0700 Subject: [PATCH 161/179] agents: optimize rules and clarify create-pr subagent requirements (#4018) Agent rule and skill definitions contained redundant prose and missed actionable guardrails for argparse attribute access, workflow input handling, and interactive PR draft linking. Streamline python.md and github_actions_workflows.md rules for token efficiency, add explicit conventions for argparse attributes and workflow inputs, and strengthen create-pr skill delegation instructions while ensuring draft artifacts are linked before requesting confirmation. --- .agents/rules/github_actions_workflows.md | 6 +- .agents/rules/python.md | 13 +++-- .agents/skills/create-pr/SKILL.md | 67 +++++++++-------------- 3 files changed, 38 insertions(+), 48 deletions(-) diff --git a/.agents/rules/github_actions_workflows.md b/.agents/rules/github_actions_workflows.md index 29bf628a24..525d2a2699 100644 --- a/.agents/rules/github_actions_workflows.md +++ b/.agents/rules/github_actions_workflows.md @@ -6,5 +6,7 @@ globs: [".github/workflows/*.yml", ".github/workflows/*.yaml", ".github/*.yaml"] # GitHub Actions Workflows Rule -* When creating files in `.github/workflows/` (such as `.yml` or `.yaml` - files), always use the latest version of the referenced GitHub Actions. +* Use the latest version of referenced actions in `.github/workflows/`. +* Preserve `suppress-no-jobs-ran-error` fallback jobs in conditional workflows. +* Pass inputs (e.g. `${{ inputs.issue }}`) directly to commands without + redundant shell parameter stripping or conversions. diff --git a/.agents/rules/python.md b/.agents/rules/python.md index 523cc923cd..22221fe3a1 100644 --- a/.agents/rules/python.md +++ b/.agents/rules/python.md @@ -1,8 +1,11 @@ # Python Conventions ## pytest -* **Fixture Registration via `pytest_plugins`**: When registering pytest helper - modules in test files, use `pytest_plugins = [""]`. -* **Fixture Naming Conventions**: Name fixture functions with a `fixture_` prefix - (e.g. `def fixture_foo():`), and pass the public fixture name using the `name` - parameter in `@pytest.fixture(name="foo")`. +* Register helper fixtures using `pytest_plugins = [""]`. +* Name fixture functions with `fixture_` prefix and pass public name via + `@pytest.fixture(name="foo")`. + +## CLI & Arguments +* Use direct attribute access (e.g. `args.foo`) on `argparse.Namespace` with + well-defined shapes. Avoid defensive `getattr()`. + diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md index 88b687b39f..97abf1d467 100644 --- a/.agents/skills/create-pr/SKILL.md +++ b/.agents/skills/create-pr/SKILL.md @@ -4,51 +4,36 @@ description: Propose, draft, or create a pull request by delegating to a subagent --- -When creating a Pull Request for local changes or a branch, invoke a subagent -to handle PR creation or description drafting. +When proposing, drafting, or creating a Pull Request, you MUST ALWAYS delegate +to a subagent. NEVER create or draft PRs directly in the main conversation. ### Instructions -1. **Pre-Review Audit Subagent**: Before drafting or creating a PR, launch a - subagent to run the `pre-review-audit` skill on local changes (`git diff`). - Verify all checks pass (e.g., `VERSION_NEXT_FEATURE` directives, no Bazel - copyright headers, line wrapping, Starlark formatting). Fix any issues - found before proceeding. -2. Launch a subagent using `invoke_subagent` with `TypeName: "self"` (or - `agentapi new-conversation`). -3. Provide a prompt to the subagent directing it to: - - Include `@/.agents/rules/pr.md` and read `CONTRIBUTING.md` (specifically - the sections on **Commit messages and PR descriptions** and - **Documenting changes**) before drafting. - - Strictly adhere to `CONTRIBUTING.md` rules for: - - **PR Title**: Follow conventional commit style and title formatting. - For agent rules, skills, and system updates, use `agents:` prefix. - - **PR Body**: Include rationale, high-level summary, and structure. - **CRITICAL**: Strictly wrap all PR body text at 72 columns max - (GitHub uses the PR description as the commit message upon merge, - which reflows text at 72 columns). - - **Formatting**: Follow repository style guidelines and structure. - - Create a Markdown artifact (`pr_info.md`) meeting the following requirements: - - **User-facing**: Published so it is presented directly in the user interface. - - **Interactive feedback enabled**: Allows the user to select lines and leave inline comments on the draft. - - **User decision choices**: Ask the user if they want to: +1. **Pre-Review Audit**: Launch a subagent with `pre-review-audit` to verify + `git diff` conforms to all rules (line wrapping, copyright, conventions). +2. **Launch Subagent**: Use `invoke_subagent` (`TypeName: "self"`). +3. **Subagent Prompt Instructions**: + - Follow `CONTRIBUTING.md` and `@/.agents/rules/pr.md`. + - **PR Title**: Conventional commits format (`agents:` prefix for agent + rules/skills). + - **PR Body**: Explain *why* and conceptual *how*. Wrap strictly at 72 + columns max. Omit TAG/CONV. + - **Artifact Requirements**: Create `pr_info.md` with: + - **User-facing**: Published directly in the user interface. + - **Interactive feedback enabled**: Allows selecting lines and leaving + inline comments on the draft (`RequestFeedback: true`). + - **User decision choices**: Present choices: 1. Create a regular PR 2. Create a draft PR 3. Provide feedback on the draft text 4. Discard the draft text - - **Propose vs. Create**: If the user requested to propose or draft a PR - description, **do not** run `gh pr create`—just create the `pr_info.md` - artifact for the user to review. Otherwise, execute `gh pr create` with - the formatted title and body. - - **Targeting Upstream Repo**: When executing `gh pr create`, always target - the upstream repository by passing `--repo bazel-contrib/rules_python` and - `--head :`. -4. **Return Status**: Direct the subagent to communicate the PR number or draft - status back using `send_message` (or `agentapi send-message`) with the - parent conversation ID, or include it in its final completion response. -5. **Publish Artifact**: Upon receiving the subagent completion message, the - main agent must publish `pr_info.md` to display the artifact directly in - the primary user UI. -6. **Interactive Actions**: To present custom action choices to the user - (e.g., "Create PR", "Create Draft PR"), the main agent can use the - `ask_question` tool with custom options. + - **Propose vs. Create**: If proposing or drafting a PR, do NOT run + `gh pr create`—only create the `pr_info.md` artifact. Only execute + `gh pr create` when explicitly requested to create the PR. + - **Targeting Upstream Repo**: When creating, target upstream using + `--repo bazel-contrib/rules_python` and `--head :`. +4. **Return Status**: Direct subagent to report PR number/draft status to caller. +5. **Link Artifact Before Asking**: Upon subagent completion, output a + clickable markdown link to `pr_info.md` before prompting for confirmation. +6. **Interactive Actions**: When presenting choices via `ask_question`, always + include the clickable markdown link to `pr_info.md` in the `question` prompt. From a2da0a9c8c15d996d329bbcc20919ef20b47c0f3 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 8 Aug 2026 15:27:41 -0700 Subject: [PATCH 162/179] agents: document Windows machine normalization and SOABI support (#4019) Agents resolving platform tags on Windows may encounter uppercase strings from platform.machine(), leading to mismatch errors when matching PEP 508 tags. Additionally, agents lacked guidance on Windows CPython native support for SOABI ABI infix tags. Document the requirement to normalize platform.machine() to lowercase and clarify native SOABI platform tag support for Windows CPython. --- .agents/rules/windows.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.agents/rules/windows.md b/.agents/rules/windows.md index 5948092902..2eea9b2df5 100644 --- a/.agents/rules/windows.md +++ b/.agents/rules/windows.md @@ -18,4 +18,15 @@ ## Windows Platform Tag Derivation * Windows platform tags for Python C extensions evaluate to `win_amd64` (x86_64/amd64), `win_arm64` (aarch64/arm64), or `win32` (32-bit x86). +* **`platform.machine()` Normalization**: On Windows, + `platform.machine()` returns uppercase (`"AMD64"`, `"ARM64"`). Always + normalize with `.lower()` when deriving PEP 508 markers or resolving platform + tags. * **Citation**: [PEP 425 — Compatibility Tags for Built Distributions](https://peps.python.org/pep-0425/). + +## Windows CPython SOABI & ABI Infix Support +* CPython on Windows natively supports loading ABI-tagged `.pyd` files + containing platform tags (e.g., `foo.cp314-win_amd64.pyd`, + `foo.cp311-win_amd64.pyd`). +* SOABI on Windows includes both the ABI prefix and platform tag (e.g., + `cp311-win_amd64`). From 13e21f26c23b0c0bc209bde0d9e42cdecb3a1fd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:06:58 -0700 Subject: [PATCH 163/179] build(deps): bump cryptography from 49.0.0 to 50.0.0 in /tools/publish (#3995) Bumps [cryptography](https://github.com/pyca/cryptography) from 49.0.0 to 50.0.0.

    Changelog

    Sourced from cryptography's changelog.

    50.0.0 - 2026-07-31

    
    * **SECURITY ISSUE**:
    
    :func:`~cryptography.hazmat.primitives.serialization.pkcs7.pkcs7_decrypt_der`
    and its PEM and S/MIME variants no longer expose distinguishable errors
    or
    timing when unwrapping a ``RecipientInfo``'s ``encryptedKey``, which
    could
    act as a Bleichenbacher oracle for callers that decrypt untrusted
    messages.
    A random key is now substituted on failure, as described in :rfc:`3218`.
      Credit to **@X1AOxiang** for reporting the issue
    * Deprecated Diffie-Hellman key exchange over finite fields (FFDH).
      Everything FFDH is deprecated, including the types in
    ``cryptography.hazmat.primitives.asymmetric.dh`` and loading FFDH keys
    or
      parameters with the key loading APIs. Users should migrate to a more
      modern key exchange algorithm.
    * Added ``xof()`` class methods to
      :class:`~cryptography.hazmat.primitives.hashes.SHAKE128` and
    :class:`~cryptography.hazmat.primitives.hashes.SHAKE256` for
    constructing
      algorithm instances configured for use with
      :class:`~cryptography.hazmat.primitives.hashes.XOFHash`.
    * The :mod:`X.509 verification <cryptography.x509.verification>`
    APIs are now
      considered stable and are subject to our API stability policy.
    * Added the :doc:`/cobblestone` recipe, an implementation of the
      Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP
      chunked-encryption specification
    <https://c2sp.org/chunked-encryption>`_ for streaming
    authenticated
      encryption of large messages.
    * Parsing a Signed Certificate Timestamp list now rejects encodings that
    carry trailing bytes after the list or after an individual SCT, instead
    of
      silently ignoring them.
    * Added support for using :class:`~cryptography.x509.Name` as a field
    type in
      the :doc:`/hazmat/asn1/index` module.
    * Loading a public key or an EC private key now rejects DER where the
    ``subjectPublicKey`` (or EC ``publicKey``) ``BIT STRING`` declares a
    non-zero
      number of unused bits, instead of silently ignoring it.
    * Parsing a CRL entry's ``InvalidityDate`` extension now rejects a
    ``GeneralizedTime`` that carries fractional seconds or another non-DER
    form,
    matching the strict encoding already required for every other X.509 time
      field.
    * :func:`~cryptography.x509.ocsp.load_der_ocsp_request` and
    :func:`~cryptography.x509.ocsp.load_der_ocsp_response` now reject a
    request
    or response whose ``version`` field is not ``v1``, the only version
    defined
    by RFC 6960, matching the version validation already performed when
    loading
      certificates, CSRs and CRLs.
    * :class:`~cryptography.hazmat.primitives.hashes.XOFHash` is now
    supported
      when building against AWS-LC.
    * HMAC (and therefore PBKDF2-HMAC) with SHA-3 hashes is now supported
    when
      building against AWS-LC.
    * Diffie-Hellman (:doc:`/hazmat/primitives/asymmetric/dh`) is now
    supported
      when building against AWS-LC.
    </tr></table>
    

    ... (truncated)

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cryptography&package-manager=pip&previous-version=49.0.0&new-version=50.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tools/publish/requirements_linux.txt | 94 ++++++++++++------------ tools/publish/requirements_universal.txt | 94 ++++++++++++------------ 2 files changed, 94 insertions(+), 94 deletions(-) diff --git a/tools/publish/requirements_linux.txt b/tools/publish/requirements_linux.txt index 41b0755b12..b786f5d029 100644 --- a/tools/publish/requirements_linux.txt +++ b/tools/publish/requirements_linux.txt @@ -207,53 +207,53 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -cryptography==49.0.0 \ - --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ - --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ - --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ - --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ - --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ - --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ - --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ - --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ - --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ - --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ - --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ - --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ - --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ - --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ - --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ - --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ - --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ - --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ - --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ - --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ - --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ - --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ - --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ - --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ - --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ - --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ - --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ - --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ - --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ - --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ - --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ - --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ - --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ - --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ - --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ - --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ - --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ - --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ - --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ - --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ - --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ - --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ - --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ - --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ - --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ - --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via secretstorage docutils==0.23 \ --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \ diff --git a/tools/publish/requirements_universal.txt b/tools/publish/requirements_universal.txt index bd17c90a54..99d317f1a7 100644 --- a/tools/publish/requirements_universal.txt +++ b/tools/publish/requirements_universal.txt @@ -207,53 +207,53 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -cryptography==49.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \ - --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ - --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ - --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ - --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ - --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ - --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ - --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ - --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ - --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ - --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ - --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ - --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ - --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ - --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ - --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ - --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ - --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ - --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ - --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ - --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ - --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ - --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ - --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ - --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ - --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ - --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ - --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ - --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ - --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ - --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ - --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ - --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ - --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ - --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ - --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ - --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ - --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ - --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ - --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ - --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ - --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ - --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ - --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ - --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ - --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ - --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b +cryptography==50.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via secretstorage docutils==0.23 \ --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \ From 39a37b8eeb384bc19152f3e42d512108c18477e2 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 8 Aug 2026 18:29:07 -0700 Subject: [PATCH 164/179] agents: add merge queue CI monitoring and lockfile sync rules (#4021) CI monitoring previously missed runs executed on GitHub merge queue temporary branches (`gh-readonly-queue/...`), and integration lockfiles became out of sync when requirements were bumped. Update merge shepherd guidance to discover and monitor merge queue branch statuses, and add testing rules to synchronize Bazel lockfiles in integration test workspaces whenever dependencies or requirements files change. --- .agents/rules/testing.md | 5 +++++ .agents/skills/merge-pr/SKILL.md | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md index 0f369b3861..769197f968 100644 --- a/.agents/rules/testing.md +++ b/.agents/rules/testing.md @@ -13,6 +13,11 @@ * Changes to transitive module extension dependencies or `.bzl` files loaded by extensions update Bazel 9 lockfile hashes, requiring `bazel mod deps --lockfile_mode=update` in integration test workspaces. +* When requirements files (e.g., in `//tools/publish` or root pip parses) are + modified or bumped by Dependabot, update the integration lockfile by running + `bazel mod deps --lockfile_mode=update` in `tests/integration/bzlmod_lockfile` + and verify with + `bazel test //tests/integration:bzlmod_lockfile_test_bazel_9.1.0`. ## Documentation Flake Handling * When building `//docs:docs` fails with exit code 2, treat it as a known diff --git a/.agents/skills/merge-pr/SKILL.md b/.agents/skills/merge-pr/SKILL.md index 30b8f38af4..406590e49f 100644 --- a/.agents/skills/merge-pr/SKILL.md +++ b/.agents/skills/merge-pr/SKILL.md @@ -13,7 +13,15 @@ When the user asks to merge a pull request (e.g., "merge PR ", "merge th merge. Never invoke `--admin` autonomously. 2. **Invoke a Background Shepherd**: Launch a background subagent with the role `Merge PR Shepherd` to continuously watch the PR until it merges. 3. **Leverage Existing CI Skills**: - - Have the subagent use the **`monitor-ci-results`** skill to watch for CI check failures and generate analysis reports. - - Have the subagent use the **`buildkite-retry-job`** skill (`retry_buildkite_jobs.py `) to automatically retry any transient network flakes (e.g., HTTP 504 gateway timeouts, downloader errors). + - Have the subagent use the **`monitor-ci-results`** skill to watch for CI + check failures and generate analysis reports. + - Have the subagent use the **`buildkite-retry-job`** skill + (`retry_buildkite_jobs.py `) to automatically retry any + transient network flakes (e.g., HTTP 504 gateway timeouts, downloader + errors). + - When the PR is queued, actively discover the merge queue branch via + `gh api repos/:owner/:repo/branches --jq '.[].name | select(test("gh-readonly-queue/.*/pr--"))'` + and monitor commit statuses/Buildkite builds running on that temporary + branch. 4. **Queue Shepherding**: Periodically check `gh pr view --json state,autoMergeRequest`. While `state` is `"OPEN"`, ensure auto-merge is enabled / queued by running `gh pr merge --auto --squash`. If `autoMergeRequest` is null (e.g., ejected from the merge queue due to a CI flake in the temporary queue branch), re-enqueue it for merge by running `gh pr merge --auto --squash` once checks are retried or green. 5. **Completion Notification**: Once `state` becomes `"MERGED"`, send a high-priority message back to the parent conversation. From 88c24b677d1fe5525a0b252f5c945e9cf79d8daf Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 8 Aug 2026 19:33:59 -0700 Subject: [PATCH 165/179] agents(rules): document linking external objects in TypedDict docstrings (#4022) When defining TypedDict structures that represent external objects or schemas, agents and developers need clear provenance and context for the fields and their semantics. Update the agent Python rules to establish a convention requiring TypedDict docstrings to include a link to the external object's original definition. --- .agents/rules/python.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/rules/python.md b/.agents/rules/python.md index 22221fe3a1..200295e650 100644 --- a/.agents/rules/python.md +++ b/.agents/rules/python.md @@ -9,3 +9,7 @@ * Use direct attribute access (e.g. `args.foo`) on `argparse.Namespace` with well-defined shapes. Avoid defensive `getattr()`. +## TypedDict +* **External Objects**: When defining a `TypedDict` for an external object, + link to its definition in the docstring. + From 99766be442009d5f805824bbd6a183539082657b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 9 Aug 2026 00:59:16 -0700 Subject: [PATCH 166/179] agents: rename pre-review-audit to review-code and add news file checks (#4027) The pre-review audit skill lacked checks for news fragment entries, which allowed user-visible changes to slip through without proper changelog documentation. Rename the pre-review-audit skill to review-code to better reflect its scope, and add dedicated news entry file validation across the auditor sub-agent prompts. --- .agents/skills/create-pr/SKILL.md | 12 ++-- .agents/skills/pre-review-audit/SKILL.md | 40 ------------- .../review-contributing-prompt.md | 9 --- .../pre-review-audit/review-docs-prompt.md | 12 ---- .../review-pr-standards-prompt.md | 9 --- .../pre-review-audit/review-python-prompt.md | 9 --- .../review-starlark-prompt.md | 11 ---- .agents/skills/review-code/SKILL.md | 59 +++++++++++++++++++ .../review-agents-prompt.md | 22 ++++--- .../review-code/review-contributing-prompt.md | 37 ++++++++++++ .../skills/review-code/review-docs-prompt.md | 24 ++++++++ .../review-code/review-pr-standards-prompt.md | 17 ++++++ .../review-code/review-python-prompt.md | 15 +++++ .../review-code/review-starlark-prompt.md | 17 ++++++ 14 files changed, 192 insertions(+), 101 deletions(-) delete mode 100644 .agents/skills/pre-review-audit/SKILL.md delete mode 100644 .agents/skills/pre-review-audit/review-contributing-prompt.md delete mode 100644 .agents/skills/pre-review-audit/review-docs-prompt.md delete mode 100644 .agents/skills/pre-review-audit/review-pr-standards-prompt.md delete mode 100644 .agents/skills/pre-review-audit/review-python-prompt.md delete mode 100644 .agents/skills/pre-review-audit/review-starlark-prompt.md create mode 100644 .agents/skills/review-code/SKILL.md rename .agents/skills/{pre-review-audit => review-code}/review-agents-prompt.md (57%) create mode 100644 .agents/skills/review-code/review-contributing-prompt.md create mode 100644 .agents/skills/review-code/review-docs-prompt.md create mode 100644 .agents/skills/review-code/review-pr-standards-prompt.md create mode 100644 .agents/skills/review-code/review-python-prompt.md create mode 100644 .agents/skills/review-code/review-starlark-prompt.md diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md index 97abf1d467..1c73c66424 100644 --- a/.agents/skills/create-pr/SKILL.md +++ b/.agents/skills/create-pr/SKILL.md @@ -9,8 +9,10 @@ to a subagent. NEVER create or draft PRs directly in the main conversation. ### Instructions -1. **Pre-Review Audit**: Launch a subagent with `pre-review-audit` to verify - `git diff` conforms to all rules (line wrapping, copyright, conventions). +1. **Code Review Audit**: Launch a subagent with `review-code` to verify + `git diff` conforms to all rules (news entry in `news/`, line wrapping, + copyright, conventions, Starlark formatting). Fix any issues before + proceeding. 2. **Launch Subagent**: Use `invoke_subagent` (`TypeName: "self"`). 3. **Subagent Prompt Instructions**: - Follow `CONTRIBUTING.md` and `@/.agents/rules/pr.md`. @@ -32,8 +34,10 @@ to a subagent. NEVER create or draft PRs directly in the main conversation. `gh pr create` when explicitly requested to create the PR. - **Targeting Upstream Repo**: When creating, target upstream using `--repo bazel-contrib/rules_python` and `--head :`. -4. **Return Status**: Direct subagent to report PR number/draft status to caller. +4. **Return Status**: Direct subagent to report PR number/draft status to + caller. 5. **Link Artifact Before Asking**: Upon subagent completion, output a clickable markdown link to `pr_info.md` before prompting for confirmation. 6. **Interactive Actions**: When presenting choices via `ask_question`, always - include the clickable markdown link to `pr_info.md` in the `question` prompt. + include the clickable markdown link to `pr_info.md` in the `question` + prompt. diff --git a/.agents/skills/pre-review-audit/SKILL.md b/.agents/skills/pre-review-audit/SKILL.md deleted file mode 100644 index 1c0b16e273..0000000000 --- a/.agents/skills/pre-review-audit/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: pre-review-audit -description: High-level pre-review audit of local changes when preparing to send a PR for review -trigger: model_decision ---- - -When preparing to send a Pull Request for review, invoke **separate, concurrent sub-agents** (`invoke_subagent`), where each sub-agent focuses exclusively on its assigned checklist category. - -**CRITICAL**: Do NOT use a single sub-agent to validate multiple or all dimensions at once. Each sub-agent must be launched with its own distinct prompt file from `.agents/skills/pre-review-audit/`: - -### Audit Checklist & Sub-Agent Prompts - -1. **Starlark / Bazel Sub-Agent** (`Role: "Starlark Code Auditor"`): - - Prompt file: `.agents/skills/pre-review-audit/review-starlark-prompt.md` - - Focus: Audits Starlark / Bazel changes (`*.bzl`, `BUILD`, `*.bazel` files) in `git diff` against `.agents/rules/bzl.md` and Starlark rules in `AGENTS.md`. - -2. **Python Code Sub-Agent** (`Role: "Python Code Auditor"`): - - Prompt file: `.agents/skills/pre-review-audit/review-python-prompt.md` - - Focus: Audits Python source and test changes in `git diff` against `.agents/rules/python.md` and Python and pytest conventions in `AGENTS.md`. - -3. **Documentation Sub-Agent** (`Role: "Documentation Auditor"`): - - Prompt file: `.agents/skills/pre-review-audit/review-docs-prompt.md` - - Focus: Audits documentation (`.md`) changes and docs build targets against `.agents/rules/docs.md` and `AGENTS.md`. - -4. **Contribution Guidelines Sub-Agent** (`Role: "Contributing Auditor"`): - - Prompt file: `.agents/skills/pre-review-audit/review-contributing-prompt.md` - - Focus: Audits changes, requirements updates, and directives against `CONTRIBUTING.md`. - -5. **Project Conventions Sub-Agent** (`Role: "Project Conventions Auditor"`): - - Prompt file: `.agents/skills/pre-review-audit/review-agents-prompt.md` - - Focus: Audits overall workspace compliance against `AGENTS.md`. - -6. **Pull Request Standards Sub-Agent** (`Role: "PR Standards Auditor"`): - - Prompt file: `.agents/skills/pre-review-audit/review-pr-standards-prompt.md` - - Focus: Audits PR titles and descriptions against Conventional Commits formatting and PR update rules in `CONTRIBUTING.md`. - -### Action Instructions -- Launch all sub-agents concurrently using `invoke_subagent`. -- Collect the reports from each sub-agent and summarize any violations clearly with suggested fixes for the user. -- If all domain audits pass, confirm that the PR is ready for review. diff --git a/.agents/skills/pre-review-audit/review-contributing-prompt.md b/.agents/skills/pre-review-audit/review-contributing-prompt.md deleted file mode 100644 index 10a719284a..0000000000 --- a/.agents/skills/pre-review-audit/review-contributing-prompt.md +++ /dev/null @@ -1,9 +0,0 @@ -You are a specialized Contribution Guidelines Auditor sub-agent. -Your sole task is to audit all local changes (`git diff`), commit messages, and PR metadata against `CONTRIBUTING.md`: - -1. Read and strictly enforce `CONTRIBUTING.md`, all `.agents/rules/*.md` files, and `AGENTS.md`. -2. Verify that `{versionadded}` and `{versionchanged}` directives use `VERSION_NEXT_FEATURE` for unreleased features. -3. If locked/resolved requirements files (`requirements.txt`, `pyproject.toml`, `requirements.in`) were modified, verify that the associated `requirements.update` target was executed to keep locked requirement files in sync. -4. Ensure style and conventions described in `CONTRIBUTING.md` are respected across the changes. - -Report any violations found clearly with actionable suggested fixes, or report that the changes pass contribution audit. diff --git a/.agents/skills/pre-review-audit/review-docs-prompt.md b/.agents/skills/pre-review-audit/review-docs-prompt.md deleted file mode 100644 index 02867940ed..0000000000 --- a/.agents/skills/pre-review-audit/review-docs-prompt.md +++ /dev/null @@ -1,12 +0,0 @@ -You are a specialized Documentation & Sphinx/MyST Auditor sub-agent. -Your sole task is to audit all documentation (`.md`) changes and new `.bzl` APIs in `git diff` against the project's documentation rules: - -1. Read and strictly enforce `.agents/rules/docs.md`, `AGENTS.md`, and `CONTRIBUTING.md`. -2. Check that lines wrap at 80 columns. -3. Ensure markdown filenames use hyphens (`-`) rather than underscores (`_`). -4. Verify Sphinx MyST colon indentation hierarchy (outer directives must have more colons than inner directives). -5. Verify `{versionadded}` and `{versionchanged}` sections are placed at the end of the documentation text. -6. For unreleased features or attributes, ensure `{versionadded}` / `{versionchanged}` directives use `VERSION_NEXT_FEATURE` (not hardcoded version numbers). -7. Check documentation build correctness: ensure new `.bzl` files or public APIs are included in `//docs:docs` or relevant docs build targets. - -Report any violations found clearly with actionable suggested fixes, or report that the documentation changes pass audit. diff --git a/.agents/skills/pre-review-audit/review-pr-standards-prompt.md b/.agents/skills/pre-review-audit/review-pr-standards-prompt.md deleted file mode 100644 index 6120b412af..0000000000 --- a/.agents/skills/pre-review-audit/review-pr-standards-prompt.md +++ /dev/null @@ -1,9 +0,0 @@ -You are a specialized PR Standards Auditor sub-agent. -Your sole task is to audit PR titles and PR descriptions against `CONTRIBUTING.md` and project rules: - -1. Read and strictly enforce `.agents/rules/pr.md`, `.agents/rules/news.md`, `AGENTS.md`, and `CONTRIBUTING.md`. -2. Check PR title formatting: must follow Conventional Commits format (e.g. `feat(cc): ...`, `docs(python): ...`). For agent rules/skills, use `agents:` prefix. -3. Ensure PR descriptions explain *why* a change is made and provide a high-level overview of *how*, following advice in `CONTRIBUTING.md`. -4. If a PR has already been created, enforce PR update rules: do NOT amend or rebase existing commits (create new commits and merges instead, to preserve code review comment threads). - -Report any violations found clearly with actionable suggested fixes, or report that the PR standards pass audit. diff --git a/.agents/skills/pre-review-audit/review-python-prompt.md b/.agents/skills/pre-review-audit/review-python-prompt.md deleted file mode 100644 index aa2d726228..0000000000 --- a/.agents/skills/pre-review-audit/review-python-prompt.md +++ /dev/null @@ -1,9 +0,0 @@ -You are a specialized Python & pytest Auditor sub-agent. -Your sole task is to audit all Python source (`.py`) and test changes in `git diff` against the project's Python conventions: - -1. Read and strictly enforce `.agents/rules/python.md`, `AGENTS.md`, and `CONTRIBUTING.md`. -2. Check Python pytest conventions: when registering pytest fixtures from helper modules in test files, use `pytest_plugins = [""]`. -3. Name fixture functions with a `fixture_` prefix (e.g. `def fixture_foo():`) and pass the public fixture name using `@pytest.fixture(name="foo")`. -4. Verify that tests were executed using Bazel (`bazel test --config=fast-tests`) and passed. - -Report any violations found clearly with actionable suggested fixes, or report that the Python changes pass audit. diff --git a/.agents/skills/pre-review-audit/review-starlark-prompt.md b/.agents/skills/pre-review-audit/review-starlark-prompt.md deleted file mode 100644 index 8421219a36..0000000000 --- a/.agents/skills/pre-review-audit/review-starlark-prompt.md +++ /dev/null @@ -1,11 +0,0 @@ -You are a specialized Starlark & Bazel Code Auditor sub-agent. -Your sole task is to audit all Starlark (`.bzl`, `BUILD`, `*.bazel`) changes in `git diff` against the project's Starlark coding rules and conventions: - -1. Read and strictly enforce `.agents/rules/starlark.md`, `.agents/rules/bzl.md`, `AGENTS.md`, and `CONTRIBUTING.md`. -2. Verify iterative algorithms are used (no recursion, no `while` loops). -3. Ensure every `.bzl` file outside `tests/` has a corresponding `bzl_library` target in its `BUILD` file with proper dependencies. -4. Ensure loads from `/private/` in test files have `# buildifier: disable=bzl-visibility`. -5. Check multi-line rule/macro doc arguments: use triple-quoted strings (`"""`), and do NOT use trailing backslashes (`\`) on opening triple-quotes. -6. Verify analysis tests use `rules_testing`, not `bazel_skylib`. - -Report any violations found clearly with actionable suggested fixes, or report that the Starlark changes pass audit. diff --git a/.agents/skills/review-code/SKILL.md b/.agents/skills/review-code/SKILL.md new file mode 100644 index 0000000000..8646ff2451 --- /dev/null +++ b/.agents/skills/review-code/SKILL.md @@ -0,0 +1,59 @@ +--- +name: review-code +description: High-level code review audit of local changes when preparing to + send a PR for review +trigger: model_decision +--- + +When preparing to send a Pull Request for review, invoke **separate, concurrent +sub-agents** (`invoke_subagent`), where each sub-agent focuses exclusively on +its assigned checklist category. + +**CRITICAL**: Do NOT use a single sub-agent to validate multiple or all +dimensions at once. Each sub-agent must be launched with its own distinct prompt +file from `.agents/skills/review-code/`: + +### Audit Checklist & Sub-Agent Prompts + +1. **Starlark / Bazel Sub-Agent** (`Role: "Starlark Code Auditor"`): + - Prompt file: + `.agents/skills/review-code/review-starlark-prompt.md` + - Focus: Audits Starlark / Bazel changes (`*.bzl`, `BUILD`, `*.bazel` files) + in `git diff` against `.agents/rules/bzl.md` and Starlark rules in + `AGENTS.md`. + +2. **Python Code Sub-Agent** (`Role: "Python Code Auditor"`): + - Prompt file: + `.agents/skills/review-code/review-python-prompt.md` + - Focus: Audits Python source and test changes in `git diff` against + `.agents/rules/python.md` and Python and pytest conventions in `AGENTS.md`. + +3. **Documentation Sub-Agent** (`Role: "Documentation Auditor"`): + - Prompt file: + `.agents/skills/review-code/review-docs-prompt.md` + - Focus: Audits documentation (`.md`) changes and docs build targets against + `.agents/rules/docs.md`, `.agents/rules/news.md`, and `AGENTS.md`. + +4. **Contribution Guidelines Sub-Agent** (`Role: "Contributing Auditor"`): + - Prompt file: + `.agents/skills/review-code/review-contributing-prompt.md` + - Focus: Audits changes, requirements updates, directives, and news entry + files (`news/..md`) against `CONTRIBUTING.md` and + `.agents/rules/news.md`. + +5. **Project Conventions Sub-Agent** (`Role: "Project Conventions Auditor"`): + - Prompt file: + `.agents/skills/review-code/review-agents-prompt.md` + - Focus: Audits overall workspace compliance against `AGENTS.md`. + +6. **Pull Request Standards Sub-Agent** (`Role: "PR Standards Auditor"`): + - Prompt file: + `.agents/skills/review-code/review-pr-standards-prompt.md` + - Focus: Audits PR titles and descriptions against Conventional Commits + formatting and PR update rules in `CONTRIBUTING.md`. + +### Action Instructions +- Launch all sub-agents concurrently using `invoke_subagent`. +- Collect the reports from each sub-agent and report all violations and + suggested improvements clearly with suggested fixes for the user. +- If all domain audits pass, confirm that the PR is ready for review. diff --git a/.agents/skills/pre-review-audit/review-agents-prompt.md b/.agents/skills/review-code/review-agents-prompt.md similarity index 57% rename from .agents/skills/pre-review-audit/review-agents-prompt.md rename to .agents/skills/review-code/review-agents-prompt.md index f81924224c..45651fd0de 100644 --- a/.agents/skills/pre-review-audit/review-agents-prompt.md +++ b/.agents/skills/review-code/review-agents-prompt.md @@ -1,10 +1,18 @@ You are a specialized Project Conventions (`AGENTS.md`) Auditor sub-agent. -Your sole task is to audit all local changes (`git diff`) and workspace state against `AGENTS.md`: +Your sole task is to audit all local changes (`git diff`) and workspace state +against `AGENTS.md`: -1. Read and strictly enforce `AGENTS.md` and all `.agents/rules/*.md` files without exception. -2. Verify NO Bazel copyright headers (`# Copyright ... The Bazel Authors`) were added to new or existing files, unless explicitly instructed by the user. -3. Verify that tests were executed using `bazel test --config=fast-tests` and non-test build targets did not use `--config=fast-tests`. -4. Ensure public config settings in `python/config_settings/BUILD.bazel` were not modified unless explicitly instructed. -5. Check that all repo rules and macro conventions described in `AGENTS.md` are respected. +1. Read and strictly enforce `AGENTS.md` and all `.agents/rules/*.md` files + without exception. +2. Verify NO Bazel copyright headers (`# Copyright ... The Bazel Authors`) + were added to new or existing files, unless explicitly instructed by the + user. +3. Verify that tests were executed using `bazel test --config=fast-tests` and + non-test build targets did not use `--config=fast-tests`. +4. Ensure public config settings in `python/config_settings/BUILD.bazel` were + not modified unless explicitly instructed. +5. Check that all repo rules and macro conventions described in `AGENTS.md` + are respected. -Report any violations found clearly with actionable suggested fixes, or report that the changes pass project conventions audit. +Report any violations found clearly with actionable suggested fixes, or report +that the changes pass project conventions audit. diff --git a/.agents/skills/review-code/review-contributing-prompt.md b/.agents/skills/review-code/review-contributing-prompt.md new file mode 100644 index 0000000000..51fe58db00 --- /dev/null +++ b/.agents/skills/review-code/review-contributing-prompt.md @@ -0,0 +1,37 @@ +You are a specialized Contribution Guidelines Auditor sub-agent. +Your sole task is to audit all local changes (`git diff`), commit messages, +and PR metadata against `CONTRIBUTING.md` and project rules: + +1. Read and strictly enforce `CONTRIBUTING.md`, all `.agents/rules/*.md` files + (especially `.agents/rules/news.md`), and `AGENTS.md`. +2. **News Entry File Audit**: + - Check if the PR introduces user-visible features (`feat:`), bug fixes + (`fix:`), behavioral changes (`changed:`), breaking changes, or removals + (`removed:`). If so, verify that a news fragment file is added under + `news/..md`. + - Verify `CHANGELOG.md` is NOT modified directly for unreleased changes. + - Verify news filename format: `news/..md` where `` is the + PR or issue number (or placeholder ID) and `` is strictly one of + `added`, `changed`, `fixed`, or `removed`. + - Verify news entry content rules: + - Brief, human-friendly description without leading bullet points (`*` + or `-`). + - Subsystem prefix in parentheses when applicable (e.g. `(gazelle) ...`, + `(cc) ...`). + - Use Sphinx MyST cross-reference syntax `{obj}\`\`` for rules, + macros, targets, providers, attributes, and args. + - Append GitHub issue cross-references at the end in markdown link format: + `([#1234](https://github.com/bazel-contrib/rules_python/issues/1234))`. + - Lines wrapped at 80 columns. +3. Verify that `{versionadded}` and `{versionchanged}` directives use + `VERSION_NEXT_FEATURE` for unreleased features and are placed at the end of + the documentation text. +4. If locked/resolved requirements files (`requirements.txt`, `pyproject.toml`, + `requirements.in`) were modified, verify that the associated + `requirements.update` target was executed to keep locked requirement files + in sync. +5. Ensure style and conventions described in `CONTRIBUTING.md` are respected + across all changes. + +Report any violations found clearly with actionable suggested fixes, or report +that the changes pass contribution audit. diff --git a/.agents/skills/review-code/review-docs-prompt.md b/.agents/skills/review-code/review-docs-prompt.md new file mode 100644 index 0000000000..eaeb91232d --- /dev/null +++ b/.agents/skills/review-code/review-docs-prompt.md @@ -0,0 +1,24 @@ +You are a specialized Documentation & Sphinx/MyST Auditor sub-agent. +Your sole task is to audit all documentation (`.md`) changes and new `.bzl` APIs +in `git diff` against the project's documentation rules: + +1. Read and strictly enforce `.agents/rules/docs.md`, `.agents/rules/news.md`, + `AGENTS.md`, and `CONTRIBUTING.md`. +2. Check that lines wrap at 80 columns. +3. Ensure markdown filenames use hyphens (`-`) rather than underscores (`_`) + (except news entry files under `news/`, which follow `..md`). +4. Verify Sphinx MyST colon indentation hierarchy (outer directives must have + more colons than inner directives). +5. Verify `{versionadded}` and `{versionchanged}` sections are placed at the + end of the documentation text. +6. For unreleased features or attributes, ensure `{versionadded}` / + `{versionchanged}` directives use `VERSION_NEXT_FEATURE` (not hardcoded + version numbers). +7. Check documentation build correctness: ensure new `.bzl` files or public + APIs are included in `//docs:docs` or relevant docs build targets. +8. For any added or modified news entries in `news/`, verify they adhere to + `.agents/rules/news.md` and `CONTRIBUTING.md` (proper `..md` + name, no leading bullets, subsystem prefix, `{obj}` refs, issue links). + +Report any violations found clearly with actionable suggested fixes, or report +that the documentation changes pass audit. diff --git a/.agents/skills/review-code/review-pr-standards-prompt.md b/.agents/skills/review-code/review-pr-standards-prompt.md new file mode 100644 index 0000000000..a4de4296a1 --- /dev/null +++ b/.agents/skills/review-code/review-pr-standards-prompt.md @@ -0,0 +1,17 @@ +You are a specialized PR Standards Auditor sub-agent. +Your sole task is to audit PR titles and PR descriptions against +`CONTRIBUTING.md` and project rules: + +1. Read and strictly enforce `.agents/rules/pr.md`, `.agents/rules/news.md`, + `AGENTS.md`, and `CONTRIBUTING.md`. +2. Check PR title formatting: must follow Conventional Commits format (e.g. + `feat(cc): ...`, `docs(python): ...`). For agent rules/skills, use `agents:` + prefix. +3. Ensure PR descriptions explain *why* a change is made and provide a + high-level overview of *how*, following advice in `CONTRIBUTING.md`. +4. If a PR has already been created, enforce PR update rules: do NOT amend or + rebase existing commits (create new commits and merges instead, to preserve + code review comment threads). + +Report any violations found clearly with actionable suggested fixes, or report +that the PR standards pass audit. diff --git a/.agents/skills/review-code/review-python-prompt.md b/.agents/skills/review-code/review-python-prompt.md new file mode 100644 index 0000000000..8e477ab3a7 --- /dev/null +++ b/.agents/skills/review-code/review-python-prompt.md @@ -0,0 +1,15 @@ +You are a specialized Python & pytest Auditor sub-agent. +Your sole task is to audit all Python source (`.py`) and test changes in +`git diff` against the project's Python conventions: + +1. Read and strictly enforce `.agents/rules/python.md`, `AGENTS.md`, and + `CONTRIBUTING.md`. +2. Check Python pytest conventions: when registering pytest fixtures from helper + modules in test files, use `pytest_plugins = [""]`. +3. Name fixture functions with a `fixture_` prefix (e.g. `def fixture_foo():`) + and pass the public fixture name using `@pytest.fixture(name="foo")`. +4. Verify that tests were executed using Bazel + (`bazel test --config=fast-tests`) and passed. + +Report any violations found clearly with actionable suggested fixes, or report +that the Python changes pass audit. diff --git a/.agents/skills/review-code/review-starlark-prompt.md b/.agents/skills/review-code/review-starlark-prompt.md new file mode 100644 index 0000000000..8bd8cdbe21 --- /dev/null +++ b/.agents/skills/review-code/review-starlark-prompt.md @@ -0,0 +1,17 @@ +You are a specialized Starlark & Bazel Code Auditor sub-agent. +Your sole task is to audit all Starlark (`.bzl`, `BUILD`, `*.bazel`) changes +in `git diff` against the project's Starlark coding rules and conventions: + +1. Read and strictly enforce `.agents/rules/starlark.md`, + `.agents/rules/bzl.md`, `AGENTS.md`, and `CONTRIBUTING.md`. +2. Verify iterative algorithms are used (no recursion, no `while` loops). +3. Ensure every `.bzl` file outside `tests/` has a corresponding `bzl_library` + target in its `BUILD` file with proper dependencies. +4. Ensure loads from `/private/` in test files have + `# buildifier: disable=bzl-visibility`. +5. Check multi-line rule/macro doc arguments: use triple-quoted strings + (`"""`), and do NOT use trailing backslashes (`\`) on opening triple-quotes. +6. Verify analysis tests use `rules_testing`, not `bazel_skylib`. + +Report any violations found clearly with actionable suggested fixes, or report +that the Starlark changes pass audit. From 05795bedfeda5a36efbc487ff23fb247a57294b7 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 9 Aug 2026 16:45:37 -0700 Subject: [PATCH 167/179] feat(runfiles): add CreateOrRaise API (#4028) Currently, Runfiles.Create returns None when runfiles environment variables ($RUNFILES_MANIFEST_FILE and $RUNFILES_DIR) are unset or empty, requiring callers to write repetitive None checks and handle missing runfiles manually. This adds Runfiles.CreateOrRaise and module-level CreateOrRaise, which return the initialized Runfiles instance or raise a RuntimeError if runfiles cannot be found. --- news/add_runfiles_raise_api.added.md | 2 + python/runfiles/README.md | 12 +++++- python/runfiles/runfiles.py | 49 ++++++++++++++++++++++++ tests/runfiles/runfiles_test.py | 56 ++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 news/add_runfiles_raise_api.added.md diff --git a/news/add_runfiles_raise_api.added.md b/news/add_runfiles_raise_api.added.md new file mode 100644 index 0000000000..5b2ff574e6 --- /dev/null +++ b/news/add_runfiles_raise_api.added.md @@ -0,0 +1,2 @@ +(runfiles) Added {obj}`Runfiles.CreateOrRaise` to return a `Runfiles` instance +or raise an error if runfiles cannot be found. diff --git a/python/runfiles/README.md b/python/runfiles/README.md index b5315a48f5..4ed51a3b12 100644 --- a/python/runfiles/README.md +++ b/python/runfiles/README.md @@ -61,7 +61,17 @@ with open(r.Rlocation("my_workspace/path/to/my/data.txt"), "r") as f: Here `my_workspace` is the name you specified via `module(name = "...")` in your `MODULE.bazel` file (with `--enable_bzlmod`, default as of Bazel 7) or `workspace(name = "...")` in `WORKSPACE` (with `--noenable_bzlmod`). -The code above creates a manifest- or directory-based implementation based on the environment variables in `os.environ`. See `Runfiles.Create()` for more info. +The code above creates a manifest- or directory-based implementation based on +the environment variables in `os.environ`. See `Runfiles.Create()` for more +info. + +Alternatively, `Runfiles.CreateOrRaise()` can be used to raise an error +instead of returning `None` if runfiles cannot be found: + +```python +r = Runfiles.CreateOrRaise() +``` + If you want to explicitly create a manifest- or directory-based implementation, you can do so as follows: diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index 6fe2e8f28b..02fceb3020 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -728,6 +728,46 @@ def Create(env: Optional[Dict[str, str]] = None) -> Optional["Runfiles"]: return None + # TODO: Update return type to Self when 3.11 is the min version + # https://peps.python.org/pep-0673/ + @staticmethod + def CreateOrRaise(env: Optional[Dict[str, str]] = None) -> "Runfiles": + """Returns a new `Runfiles` instance, or raises an error. + + The returned object is either: + - manifest-based, meaning it looks up runfile paths from a manifest + file, or + - directory-based, meaning it looks up runfile paths under a given + directory path + + If `env` contains "RUNFILES_MANIFEST_FILE" with non-empty value, this + method returns a manifest-based implementation. The object eagerly + reads and caches the whole manifest file upon instantiation; this may + be relevant for performance consideration. + + Otherwise, if `env` contains "RUNFILES_DIR" with non-empty value + (checked in this priority order), this method returns a directory-based + implementation. + + If neither cases apply, this method raises a `RuntimeError`. + + Args: + env: {string: string}; optional; the map of environment variables. If + None, this function uses the environment variable map of this + process. + Raises: + RuntimeError: if runfiles cannot be found. + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + """ + runfiles = Runfiles.Create(env=env) + if runfiles is None: + raise RuntimeError( + "Cannot create Runfiles: $RUNFILES_MANIFEST_FILE and $RUNFILES_DIR are both unset or empty" + ) + return runfiles + # Support legacy imports by defining a private symbol. _Runfiles = Runfiles @@ -743,3 +783,12 @@ def CreateDirectoryBased(runfiles_dir_path: str) -> Runfiles: def Create(env: Optional[Dict[str, str]] = None) -> Optional[Runfiles]: return Runfiles.Create(env) + + +def CreateOrRaise(env: Optional[Dict[str, str]] = None) -> Runfiles: + """Refer to `Runfiles.CreateOrRaise`. + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + """ + return Runfiles.CreateOrRaise(env) diff --git a/tests/runfiles/runfiles_test.py b/tests/runfiles/runfiles_test.py index 47c964631f..38a89ede7e 100644 --- a/tests/runfiles/runfiles_test.py +++ b/tests/runfiles/runfiles_test.py @@ -195,6 +195,62 @@ def testFailsToCreateAnyRunfilesBecauseEnvvarsAreNotDefined(self) -> None: self.assertIsNone(runfiles.Create({"TEST_SRCDIR": "always ignored"})) self.assertIsNone(runfiles.Create({"FOO": "bar"})) + def testCreatesManifestBasedRunfilesWithCreateOrRaise(self) -> None: + with _MockFile(contents=["a/b c/d"]) as mf: + r = runfiles.CreateOrRaise( + { + "RUNFILES_MANIFEST_FILE": mf.Path(), + "RUNFILES_DIR": "ignored when RUNFILES_MANIFEST_FILE has a value", + "TEST_SRCDIR": "always ignored", + } + ) + self.assertEqual(r.Rlocation("a/b"), "c/d") + self.assertIsNone(r.Rlocation("foo")) + + r_class = runfiles.Runfiles.CreateOrRaise( + { + "RUNFILES_MANIFEST_FILE": mf.Path(), + } + ) + self.assertEqual(r_class.Rlocation("a/b"), "c/d") + + def testCreatesDirectoryBasedRunfilesWithCreateOrRaise(self) -> None: + r = runfiles.CreateOrRaise( + { + "RUNFILES_DIR": "runfiles/dir", + "TEST_SRCDIR": "always ignored", + } + ) + self.assertEqual(r.Rlocation("a/b"), "runfiles/dir/a/b") + self.assertEqual(r.Rlocation("foo"), "runfiles/dir/foo") + + r_class = runfiles.Runfiles.CreateOrRaise( + { + "RUNFILES_DIR": "runfiles/dir", + } + ) + self.assertEqual(r_class.Rlocation("a/b"), "runfiles/dir/a/b") + + def testFailsToCreateManifestBasedBecauseManifestDoesNotExistWithCreateOrRaise( + self, + ) -> None: + def _Run(): + runfiles.CreateOrRaise({"RUNFILES_MANIFEST_FILE": "non-existing path"}) + + self.assertRaisesRegex(IOError, "non-existing path", _Run) + + def testFailsToCreateAnyRunfilesWithCreateOrRaise(self) -> None: + with self.assertRaises(RuntimeError): + runfiles.CreateOrRaise({"TEST_SRCDIR": "always ignored"}) + with self.assertRaises(RuntimeError): + runfiles.CreateOrRaise({"FOO": "bar"}) + with self.assertRaises(RuntimeError): + runfiles.CreateOrRaise({}) + with self.assertRaises(RuntimeError): + runfiles.Runfiles.CreateOrRaise({"TEST_SRCDIR": "always ignored"}) + with self.assertRaises(RuntimeError): + runfiles.Runfiles.CreateOrRaise({}) + def testManifestBasedRlocation(self) -> None: with _MockFile( contents=[ From 8f63d1e18f953c761e778cfd7d7896e81b25d78b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 9 Aug 2026 22:38:50 -0700 Subject: [PATCH 168/179] build(dev): bump dev requirements python version to 3.10 (#4024) Bump the minimum Python version target for dev requirements and lockfiles from Python 3.9 to Python 3.10. Python 3.9 compatibility constraints in dev dependencies are no longer needed and trigger dependency audit and Dependabot alerts. Updating the baseline resolves these warnings and keeps development tooling aligned with supported Python runtimes. --- dev/BUILD.bazel | 9 +- dev/requirements.txt | 90 ++------- dev/uv.lock | 435 +++++++------------------------------------ 3 files changed, 84 insertions(+), 450 deletions(-) diff --git a/dev/BUILD.bazel b/dev/BUILD.bazel index 91b2a9f566..22fe5bb918 100644 --- a/dev/BUILD.bazel +++ b/dev/BUILD.bazel @@ -12,9 +12,10 @@ lock( "--universal", "--upgrade", ], - # NOTE @aignas 2025-08-17: here we select the lowest actively supported version so that the - # requirements file is generated to be compatible with Python version 3.9 or greater. - python_version = "3.9", + # NOTE @aignas 2025-08-17: here we select the lowest actively supported + # version so that the requirements file is generated to be compatible with + # Python version 3.10 or greater. + python_version = "3.10", visibility = ["//:__subpackages__"], ) @@ -23,6 +24,6 @@ lock( name = "uv_lock", srcs = ["pyproject.toml"], out = "uv.lock", - python_version = "3.9", + python_version = "3.10", visibility = ["//:__subpackages__"], ) diff --git a/dev/requirements.txt b/dev/requirements.txt index 943fce2dcf..6efb1b8bdb 100644 --- a/dev/requirements.txt +++ b/dev/requirements.txt @@ -2,19 +2,11 @@ # bazel run //dev:requirements.update --index-url https://pypi.org/simple -absl-py==2.3.1 ; python_full_version < '3.10' \ - --hash=sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9 \ - --hash=sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d - # via rules-python-dev (dev/pyproject.toml) -absl-py==2.5.0 ; python_full_version >= '3.10' \ +absl-py==2.5.0 \ --hash=sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f \ --hash=sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba # via rules-python-dev (dev/pyproject.toml) -alabaster==0.7.16 ; python_full_version < '3.10' \ - --hash=sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65 \ - --hash=sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92 - # via sphinx -alabaster==1.0.0 ; python_full_version >= '3.10' \ +alabaster==1.0.0 \ --hash=sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e \ --hash=sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b # via sphinx @@ -157,23 +149,11 @@ idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests -imagesize==1.5.0 ; python_full_version < '3.10' \ - --hash=sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899 \ - --hash=sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f - # via sphinx -imagesize==2.0.0 ; python_full_version >= '3.10' \ +imagesize==2.0.0 \ --hash=sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96 \ --hash=sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3 # via sphinx -importlib-metadata==8.7.1 ; python_full_version < '3.10' \ - --hash=sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb \ - --hash=sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151 - # via sphinx -iniconfig==2.1.0 ; python_full_version < '3.10' \ - --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ - --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 - # via pytest -iniconfig==2.3.0 ; python_full_version >= '3.10' \ +iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 # via pytest @@ -293,11 +273,7 @@ markupsafe==3.0.3 \ # via # rules-python-dev (dev/pyproject.toml) # jinja2 -mdit-py-plugins==0.4.2 ; python_full_version < '3.10' \ - --hash=sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636 \ - --hash=sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5 - # via myst-parser -mdit-py-plugins==0.6.1 ; python_full_version >= '3.10' \ +mdit-py-plugins==0.6.1 \ --hash=sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d \ --hash=sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0 # via myst-parser @@ -305,11 +281,7 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -myst-parser==3.0.1 ; python_full_version < '3.10' \ - --hash=sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1 \ - --hash=sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87 - # via rules-python-dev (dev/pyproject.toml) -myst-parser==4.0.1 ; python_full_version == '3.10.*' \ +myst-parser==4.0.1 ; python_full_version < '3.11' \ --hash=sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4 \ --hash=sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d # via rules-python-dev (dev/pyproject.toml) @@ -317,9 +289,9 @@ myst-parser==5.1.0 ; python_full_version >= '3.11' \ --hash=sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a \ --hash=sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02 # via rules-python-dev (dev/pyproject.toml) -packaging==26.2 \ - --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ - --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c # via # pytest # readthedocs-sphinx-ext @@ -332,11 +304,7 @@ pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 # via pytest -pyelftools==0.32 ; python_full_version < '3.10' \ - --hash=sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738 \ - --hash=sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5 - # via rules-python-dev (dev/pyproject.toml) -pyelftools==0.33 ; python_full_version >= '3.10' \ +pyelftools==0.33 \ --hash=sha256:660d82dcbeb8e83d1702bd97f223f761625da06111c0cc988eac6b8ab0c1b61f \ --hash=sha256:f215ad5f47d3f1373a21496a6c9e0707c622840d0622f23ff7ce08678b020036 # via rules-python-dev (dev/pyproject.toml) @@ -346,14 +314,7 @@ pygments==2.20.0 \ # via # pytest # sphinx -pytest==8.4.2 ; python_full_version < '3.10' \ - --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \ - --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79 - # via - # rules-python-dev (dev/pyproject.toml) - # pytest-bazel - # pytest-mock -pytest==9.1.1 ; python_full_version >= '3.10' \ +pytest==9.1.1 \ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c # via @@ -446,13 +407,7 @@ readthedocs-sphinx-ext==2.2.5 \ --hash=sha256:ee5fd5b99db9f0c180b2396cbce528aa36671951b9526bb0272dbfce5517bd27 \ --hash=sha256:f8c56184ea011c972dd45a90122568587cc85b0127bc9cf064d17c68bc809daa # via rules-python-dev (dev/pyproject.toml) -requests==2.32.5 ; python_full_version < '3.10' \ - --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ - --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf - # via - # readthedocs-sphinx-ext - # sphinx -requests==2.34.2 ; python_full_version >= '3.10' \ +requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via @@ -466,16 +421,7 @@ snowballstemmer==3.1.1 \ --hash=sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752 \ --hash=sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260 # via sphinx -sphinx==7.4.7 ; python_full_version < '3.10' \ - --hash=sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe \ - --hash=sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239 - # via - # rules-python-dev (dev/pyproject.toml) - # myst-parser - # sphinx-reredirects - # sphinx-rtd-theme - # sphinxcontrib-jquery -sphinx==8.1.3 ; python_full_version == '3.10.*' \ +sphinx==8.1.3 ; python_full_version < '3.11' \ --hash=sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2 \ --hash=sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927 # via @@ -606,15 +552,7 @@ typing-extensions==4.16.0 \ # astroid # exceptiongroup # sphinx-autodoc2 -urllib3==2.6.3 ; python_full_version < '3.10' \ - --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ - --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 - # via requests -urllib3==2.7.0 ; python_full_version >= '3.10' \ +urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 # via requests -zipp==3.23.1 ; python_full_version < '3.10' \ - --hash=sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc \ - --hash=sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110 - # via importlib-metadata diff --git a/dev/uv.lock b/dev/uv.lock index 842e831dec..b524eb1f61 100644 --- a/dev/uv.lock +++ b/dev/uv.lock @@ -1,66 +1,25 @@ version = 1 revision = 3 -requires-python = ">=3.9" +requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", + "python_full_version >= '3.12'", "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version < '3.10'", -] - -[[package]] -name = "absl-py" -version = "2.3.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, + "python_full_version < '3.11'", ] [[package]] name = "absl-py" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, ] -[[package]] -name = "alabaster" -version = "0.7.16" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, -] - [[package]] name = "alabaster" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, @@ -207,22 +166,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, - { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, - { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, - { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, - { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, - { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, - { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -240,8 +183,7 @@ name = "docutils" version = "0.21.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version < '3.10'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } wheels = [ @@ -253,9 +195,7 @@ name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", + "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } @@ -284,69 +224,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] -[[package]] -name = "imagesize" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/59/4b0dd64676aa6fb4986a755790cb6fc558559cf0084effad516820208ec3/imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f", size = 1281127, upload-time = "2026-03-03T01:59:54.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/b1/a0662b03103c66cf77101a187f396ea91167cd9b7d5d3a2e465ad2c7ee9b/imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899", size = 5763, upload-time = "2026-03-03T01:59:52.343Z" }, -] - [[package]] name = "imagesize" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -381,8 +271,7 @@ name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version < '3.10'", + "python_full_version < '3.11'", ] dependencies = [ { name = "mdurl", marker = "python_full_version < '3.11'" }, @@ -397,9 +286,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", + "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -493,47 +380,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, - { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, - { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, - { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, - { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, ] [[package]] name = "mdit-py-plugins" version = "0.6.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } @@ -550,40 +404,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "myst-parser" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" }, -] - [[package]] name = "myst-parser" version = "4.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "jinja2", marker = "python_full_version == '3.10.*'" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "pyyaml", marker = "python_full_version == '3.10.*'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, + { name = "pyyaml", marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ @@ -595,16 +429,14 @@ name = "myst-parser" version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", + "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] dependencies = [ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jinja2", marker = "python_full_version >= '3.11'" }, { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, { name = "pyyaml", marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -659,46 +491,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -710,8 +514,7 @@ name = "pytest-bazel" version = "0.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c2/90/4726a39728fb5a5f77a773c5e9d7bfd3a0120aa2029963a88fd06e2c5fb2/pytest_bazel-0.1.6-py3-none-any.whl", hash = "sha256:a29e80e1d67c3db801bdd4d0b6b742f2bfb48cd6841caa33401458e5c4e29c21", size = 10126, upload-time = "2025-10-31T08:41:17.165Z" }, @@ -722,8 +525,7 @@ name = "pytest-mock" version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ @@ -792,15 +594,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] @@ -810,48 +603,22 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "packaging" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e8/ce/38130d8dec600bf5413eb89a3413dd38f204c7c728c4947e12ff8cb793b7/readthedocs-sphinx-ext-2.2.5.tar.gz", hash = "sha256:ee5fd5b99db9f0c180b2396cbce528aa36671951b9526bb0272dbfce5517bd27", size = 12303, upload-time = "2023-12-19T10:00:49.573Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/64/71/c89e7709a0d4f93af1848e9855112299a820b470d84f917b4dd5998bdd07/readthedocs_sphinx_ext-2.2.5-py2.py3-none-any.whl", hash = "sha256:f8c56184ea011c972dd45a90122568587cc85b0127bc9cf064d17c68bc809daa", size = 11332, upload-time = "2023-12-19T10:00:43.972Z" }, ] -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version < '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - [[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -872,22 +639,18 @@ name = "rules-python-dev" version = "0.0.0" source = { virtual = "." } dependencies = [ - { name = "absl-py", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "absl-py", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "absl-py" }, { name = "macholib" }, { name = "markupsafe" }, - { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pefile" }, { name = "pyelftools" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, { name = "pytest-bazel" }, { name = "pytest-mock" }, { name = "readthedocs-sphinx-ext" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autodoc2" }, @@ -925,63 +688,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] -[[package]] -name = "sphinx" -version = "7.4.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "babel", marker = "python_full_version < '3.10'" }, - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, -] - [[package]] name = "sphinx" version = "8.1.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "babel", marker = "python_full_version == '3.10.*'" }, - { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "jinja2", marker = "python_full_version == '3.10.*'" }, - { name = "packaging", marker = "python_full_version == '3.10.*'" }, - { name = "pygments", marker = "python_full_version == '3.10.*'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -996,15 +727,15 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, { name = "babel", marker = "python_full_version == '3.11.*'" }, { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, { name = "jinja2", marker = "python_full_version == '3.11.*'" }, { name = "packaging", marker = "python_full_version == '3.11.*'" }, { name = "pygments", marker = "python_full_version == '3.11.*'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, @@ -1024,20 +755,18 @@ name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", + "python_full_version >= '3.12'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "alabaster", marker = "python_full_version >= '3.12'" }, { name = "babel", marker = "python_full_version >= '3.12'" }, { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, { name = "jinja2", marker = "python_full_version >= '3.12'" }, { name = "packaging", marker = "python_full_version >= '3.12'" }, { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, @@ -1071,12 +800,10 @@ name = "sphinx-reredirects" version = "0.1.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version < '3.10'", + "python_full_version < '3.11'", ] dependencies = [ - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/6b/bcca2785de4071f604a722444d4d7ba8a9d40de3c14ad52fce93e6d92694/sphinx_reredirects-0.1.6.tar.gz", hash = "sha256:c491cba545f67be9697508727818d8626626366245ae64456fe29f37e9bbea64", size = 7080, upload-time = "2025-03-22T10:52:30.271Z" } wheels = [ @@ -1088,9 +815,7 @@ name = "sphinx-reredirects" version = "1.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", + "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -1109,8 +834,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-jquery" }, @@ -1152,8 +876,7 @@ name = "sphinxcontrib-jquery" version = "4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] @@ -1252,39 +975,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, -] From 0d9eb231eb9d4c8f68fdb61aefa282049f28cc80 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 9 Aug 2026 23:00:36 -0700 Subject: [PATCH 169/179] agents(rules): document git push -u guardrail and delegating function docstrings (#4030) Using `git push -u` or `--set-upstream` disrupts triangle branch tracking by repointing the local tracking branch away from upstream. Additionally, delegating helper functions need consistent docstring references to their underlying implementations. Update workspace and Python agent rules to prohibit setting upstream on push and establish a docstring reference convention for delegating functions. --- .agents/rules/python.md | 5 +++++ .agents/rules/workspace.md | 2 ++ 2 files changed, 7 insertions(+) diff --git a/.agents/rules/python.md b/.agents/rules/python.md index 200295e650..d683152814 100644 --- a/.agents/rules/python.md +++ b/.agents/rules/python.md @@ -13,3 +13,8 @@ * **External Objects**: When defining a `TypedDict` for an external object, link to its definition in the docstring. +## Delegating Functions +* Module-level functions delegating to class methods should have a docstring + referring to the class method (e.g. `"""Refer to \`Class.method\`."""`). + + diff --git a/.agents/rules/workspace.md b/.agents/rules/workspace.md index b819925f83..bc64d4779c 100644 --- a/.agents/rules/workspace.md +++ b/.agents/rules/workspace.md @@ -16,3 +16,5 @@ basis: ```bash .agents/scripts/setup_triangle_branch.sh ``` +* Never use `git push -u` or `--set-upstream` (it breaks upstream tracking). + From cba8cc96586a126e5fec35400bad8ee45c0ec779 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 9 Aug 2026 23:21:14 -0700 Subject: [PATCH 170/179] agents: document dev python version bump and merge queue fields (#4031) Updating dev tooling Python versions requires updating multiple lock configurations and regenerating lockfiles, but this process was not documented in agent instructions. In addition, the merge-pr skill needed to query additional GitHub PR state fields to reliably inspect merge queue status and mergeability. Add developer guidance for bumping dev tooling Python versions and running lockfile update commands, and expand the gh pr view fields queried during merge queue shepherding. --- .agents/skills/merge-pr/SKILL.md | 8 +++++++- AGENTS.md | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.agents/skills/merge-pr/SKILL.md b/.agents/skills/merge-pr/SKILL.md index 406590e49f..ae3e2e3ad5 100644 --- a/.agents/skills/merge-pr/SKILL.md +++ b/.agents/skills/merge-pr/SKILL.md @@ -23,5 +23,11 @@ When the user asks to merge a pull request (e.g., "merge PR ", "merge th `gh api repos/:owner/:repo/branches --jq '.[].name | select(test("gh-readonly-queue/.*/pr--"))'` and monitor commit statuses/Buildkite builds running on that temporary branch. -4. **Queue Shepherding**: Periodically check `gh pr view --json state,autoMergeRequest`. While `state` is `"OPEN"`, ensure auto-merge is enabled / queued by running `gh pr merge --auto --squash`. If `autoMergeRequest` is null (e.g., ejected from the merge queue due to a CI flake in the temporary queue branch), re-enqueue it for merge by running `gh pr merge --auto --squash` once checks are retried or green. +4. **Queue Shepherding**: Periodically check `gh pr view --json + state,autoMergeRequest,mergeStateStatus,mergeable`. While `state` is + `"OPEN"`, ensure auto-merge is enabled / queued by running `gh pr merge + --auto --squash`. If `autoMergeRequest` is null (e.g., ejected + from the merge queue due to a CI flake in the temporary queue branch), + re-enqueue it for merge by running `gh pr merge --auto --squash` + once checks are retried or green. 5. **Completion Notification**: Once `state` becomes `"MERGED"`, send a high-priority message back to the parent conversation. diff --git a/AGENTS.md b/AGENTS.md index d66e18ca11..07b523e410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,14 @@ When modifying locked/resolved requirements files: the requirements.txt file. That will update the locked/resolved requirements.txt file. +When updating the minimum Python version for dev/docs tooling (e.g. following +Dependabot alerts): + * Update `python_version` in both `lock(name = "requirements", ...)` and + `lock(name = "uv_lock", ...)` within `dev/BUILD.bazel`. + * Regenerate lockfiles by running: + `bazel run //dev:requirements.update && bazel run //dev:uv_lock.update` + * Verify docs build via `bazel build //docs:docs`. + ## rules_python idiosyncrasies When building `//docs:docs`, ignore an error about exit code 2; this is a flake, From c54830f7cbec12549e30ffa55dd2760cc8d92e9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:50:53 +0900 Subject: [PATCH 171/179] build(deps): bump packaging from 26.2 to 26.3 in /tools/publish (#4032) Bumps [packaging](https://github.com/pypa/packaging) from 26.2 to 26.3.
    Release notes

    Sourced from packaging's releases.

    26.3

    What's Changed

    Features

    • Add a public VersionRange API and SpecifierSet.to_range(), representing the versions a specifier set accepts as an interval set that supports intersection, union, difference, complement, set relations, membership tests, and filtering. VersionRange.to_specifier_set() converts a range back to a SpecifierSet where a PEP 440 form exists. (#1267, #1270, #1298)
    • PEP 808: accept Metadata-Version: 2.6. (#1194)
    • Add a limit argument to parse_tag() for compressed tag sets. (#1220)
    • Add a prefer_sdist_predicate argument to Pylock.select() to prefer source distributions over wheels for selected packages. (#1334)
    • Add pure_python_tags() to generate the pure-Python tags for a Python version without touching the running platform. (#1346)
    • Add SpecifierSet.is_subset(), SpecifierSet.is_superset(), and SpecifierSet.is_disjoint(), which compare the versions two specifier sets accept. (#1313)

    Behavior adaptations

    • Drop support for Python 3.8; packaging now requires Python 3.9 or later. (#1157)
    • Prefer native linux_* platform tags over manylinux and musllinux tags on Linux. (#160)

    Fixes for versions and specifiers

    • Raise InvalidVersion instead of TypeError when Version is given a non-string. (#1319)
    • Raise InvalidVersion for non-string pre-release letters passed to Version.from_parts. (#1241)
    • Fix an AttributeError when hashing internally trimmed versions. (#1242)
    • Fix SpecifierSet.is_unsatisfiable for post-release boundary intersections. (#1257)

    Fixes for requirements and markers

    • Make Requirement.__hash__ consistent with __eq__ for trailing-zero-equivalent specifiers (e.g. foo==1.0.0 and foo==1.0.0.0), so equal requirements hash equal and deduplicate in sets and dicts. (#1232)
    • Normalize requested extra names before comparing or hashing requirements. (#644)
    • Preserve a Requirement's specifier prereleases override across a pickle round trip. (#1204)
    • Raise InvalidRequirement instead of InvalidSpecifier when a requirement contains an invalid specifier. (#1332)
    • Clarify the error for post-release prefix wildcards like ==1.0.post1.*. (#1299)
    • Preserve quoting semantics when serializing marker values, so round-tripped markers parse back to the same marker. (#1213)
    • Keep the parentheses of a nested group when serializing markers. (#1316)
    • Normalize extra and dependency_groups values in nested markers at parse time. (#1246, #1310)
    • Raise UndefinedComparison when a set-valued variable like extras is used outside the membership form. (#1265)
    • Raise UndefinedEnvironmentName (a KeyError subclass) for missing environment keys during marker evaluation. (#1276)
    • Wrap malformed string literal errors in InvalidMarker / InvalidRequirement instead of leaking a low-level error. (#1249)
    • Reject requirements and markers with a trailing line break. (#1345)

    Fixes for metadata and licenses

    • Collect all from_email validation errors into one ExceptionGroup instead of raising the first. (#1268)
    • Accept the UTF-8 charset case-insensitively in email payloads. (#1330)
    • Reject malformed Description-Content-Type values. (#1329)
    • Don't rewrite user values that contain {field} placeholders in error messages. (#1327)
    • Route multipart email payloads to unparsed instead of asserting. (#1247)
    • Make InvalidMetadata and CyclicDependencyGroup picklable. (#1328)
    • Fold every line boundary str.splitlines recognizes when writing a header with RFC822Message. (#1356)

    ... (truncated)

    Changelog

    Sourced from packaging's changelog.

    26.3 - 2026-08-03

    
    Features:
    
    • Add a public :class:~packaging.ranges.VersionRange API and
      :meth:SpecifierSet.to_range() &lt;packaging.specifiers.SpecifierSet.to_range&gt;,
      representing the versions a specifier set accepts as an interval set that
      supports intersection, union, difference, complement, set relations,
      membership tests, and filtering.
      :meth:~packaging.ranges.VersionRange.to_specifier_set converts a range back
      to a :class:~packaging.specifiers.SpecifierSet where a PEP 440 form exists.
      (:pull:1267, :pull:1270, :pull:1298)
    • PEP 808: accept Metadata-Version: 2.6. (:pull:1194)
    • Add a limit argument to parse_tag() for compressed tag sets.
      (:issue:1220)
    • Add a prefer_sdist_predicate argument to Pylock.select() to prefer
      source distributions over wheels for selected packages. (:pull:1334)
    • Add :func:~packaging.tags.pure_python_tags to generate the pure-Python
      tags for a Python version without touching the running platform.
      (:pull:1346)
    • Add :meth:SpecifierSet.is_subset() &lt;packaging.specifiers.SpecifierSet.is_subset&gt;, :meth:~packaging.specifiers.SpecifierSet.is_superset,
      and :meth:~packaging.specifiers.SpecifierSet.is_disjoint, which compare the
      versions two specifier sets accept. (:pull:1313)

    Behavior adaptations:

    • Drop support for Python 3.8; packaging now requires Python 3.9 or later.
      (:pull:1157)
    • Prefer native linux_* platform tags over manylinux and musllinux
      tags on Linux. (:issue:160)

    Fixes for versions and specifiers:

    • Raise InvalidVersion instead of TypeError when Version is given a
      non-string. (:pull:1319)
    • Raise InvalidVersion for non-string pre-release letters passed to
      Version.from_parts. (:pull:1241)
    • Fix an AttributeError when hashing internally trimmed versions.
      (:pull:1242)
    • Fix SpecifierSet.is_unsatisfiable for post-release boundary
      intersections. (:pull:1257)

    Fixes for requirements and markers:

    • Make Requirement.__hash__ consistent with __eq__ for
      trailing-zero-equivalent specifiers (e.g. foo==1.0.0 and
      foo==1.0.0.0), so equal requirements hash equal and deduplicate in
      sets and dicts. (:pull:1232)
      </tr></table>
  • ... (truncated)

    Commits
    • 929fd4b Bump for release
    • f300ebf chore(deps): bump the pre-commit group with 5 updates (#1357)
    • f91d975 ci(downstream): bump hatchling to 1.31.0 and fix its pytest rootdir (#1361)
    • b1a7124 chore(deps): bump the github-actions group with 7 updates (#1358)
    • 2d873eb fix(metadata): fold every line boundary when writing headers (#1356)
    • 413d006 docs: changelog for 26.3 (#1343)
    • 4eb0753 docs(metadata): explain selective field validation (#1342)
    • 77e9ed4 feat(tags): add pure Python tag generator (#1346)
    • 7cea5e8 ci: drop 3.13t on Windows (3.13.14t may fail to build, run takes 9 minutes) (...
    • 45a8b34 docs: add missing versionadded/versionchanged directives (#1344)
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=packaging&package-manager=pip&previous-version=26.2&new-version=26.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tools/publish/requirements_darwin.txt | 6 +++--- tools/publish/requirements_linux.txt | 6 +++--- tools/publish/requirements_universal.txt | 6 +++--- tools/publish/requirements_windows.txt | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tools/publish/requirements_darwin.txt b/tools/publish/requirements_darwin.txt index 5022924d80..93f9d51eee 100644 --- a/tools/publish/requirements_darwin.txt +++ b/tools/publish/requirements_darwin.txt @@ -180,9 +180,9 @@ nh3==0.3.6 \ --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -packaging==26.2 \ - --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ - --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c # via twine pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ diff --git a/tools/publish/requirements_linux.txt b/tools/publish/requirements_linux.txt index b786f5d029..571e732422 100644 --- a/tools/publish/requirements_linux.txt +++ b/tools/publish/requirements_linux.txt @@ -336,9 +336,9 @@ nh3==0.3.6 \ --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -packaging==26.2 \ - --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ - --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c # via twine pycparser==3.0 \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ diff --git a/tools/publish/requirements_universal.txt b/tools/publish/requirements_universal.txt index 99d317f1a7..8e5367cfee 100644 --- a/tools/publish/requirements_universal.txt +++ b/tools/publish/requirements_universal.txt @@ -336,9 +336,9 @@ nh3==0.3.6 \ --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -packaging==26.2 \ - --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ - --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c # via twine pycparser==3.0 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ diff --git a/tools/publish/requirements_windows.txt b/tools/publish/requirements_windows.txt index b235d95a52..a1e3faa22f 100644 --- a/tools/publish/requirements_windows.txt +++ b/tools/publish/requirements_windows.txt @@ -180,9 +180,9 @@ nh3==0.3.6 \ --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -packaging==26.2 \ - --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ - --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c # via twine pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ From 233913fbec1d6646cf1fe702cb0fec562bc8572e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:28:24 -0700 Subject: [PATCH 172/179] build(deps): bump cffi from 2.1.0 to 2.1.1 in /tools/publish (#4033) Bumps [cffi](https://github.com/python-cffi/cffi) from 2.1.0 to 2.1.1.
    Release notes

    Sourced from cffi's releases.

    v2.1.1

    What's Changed

    • Minimize internal Python API usage for interpreter and thread state sampling where possible. Avoids breaking ABI change in Python >= 3.15.0b4 (python-cffi/cffi#269).

    Full Changelog: https://github.com/python-cffi/cffi/compare/v2.1.0...v2.1.1

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cffi&package-manager=pip&previous-version=2.1.0&new-version=2.1.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tools/publish/requirements_linux.txt | 202 +++++++++++------------ tools/publish/requirements_universal.txt | 202 +++++++++++------------ 2 files changed, 202 insertions(+), 202 deletions(-) diff --git a/tools/publish/requirements_linux.txt b/tools/publish/requirements_linux.txt index 571e732422..00d46f9ff0 100644 --- a/tools/publish/requirements_linux.txt +++ b/tools/publish/requirements_linux.txt @@ -10,107 +10,107 @@ certifi==2026.7.22 \ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests -cffi==2.1.0 \ - --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ - --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ - --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ - --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ - --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ - --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ - --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ - --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ - --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ - --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ - --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ - --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ - --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ - --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ - --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ - --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ - --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ - --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ - --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ - --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ - --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ - --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ - --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ - --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ - --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ - --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ - --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ - --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ - --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ - --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ - --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ - --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ - --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ - --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ - --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ - --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ - --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ - --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ - --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ - --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ - --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ - --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ - --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ - --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ - --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ - --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ - --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ - --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ - --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ - --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ - --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ - --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ - --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ - --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ - --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ - --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ - --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ - --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ - --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ - --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ - --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ - --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ - --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ - --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ - --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ - --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ - --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ - --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ - --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ - --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ - --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ - --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ - --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ - --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ - --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ - --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ - --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ - --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ - --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ - --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ - --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ - --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ - --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ - --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ - --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ - --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ - --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ - --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ - --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ - --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ - --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ - --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ - --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ - --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ - --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ - --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ - --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ - --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ - --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ - --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 # via cryptography charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ diff --git a/tools/publish/requirements_universal.txt b/tools/publish/requirements_universal.txt index 8e5367cfee..5446aff052 100644 --- a/tools/publish/requirements_universal.txt +++ b/tools/publish/requirements_universal.txt @@ -10,107 +10,107 @@ certifi==2026.7.22 \ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests -cffi==2.1.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' \ - --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ - --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ - --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ - --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ - --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ - --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ - --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ - --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ - --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ - --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ - --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ - --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ - --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ - --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ - --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ - --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ - --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ - --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ - --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ - --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ - --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ - --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ - --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ - --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ - --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ - --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ - --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ - --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ - --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ - --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ - --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ - --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ - --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ - --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ - --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ - --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ - --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ - --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ - --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ - --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ - --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ - --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ - --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ - --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ - --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ - --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ - --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ - --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ - --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ - --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ - --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ - --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ - --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ - --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ - --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ - --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ - --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ - --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ - --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ - --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ - --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ - --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ - --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ - --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ - --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ - --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ - --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ - --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ - --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ - --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ - --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ - --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ - --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ - --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ - --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ - --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ - --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ - --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ - --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ - --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ - --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ - --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ - --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ - --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ - --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ - --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ - --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ - --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ - --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ - --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ - --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ - --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ - --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ - --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ - --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ - --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ - --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ - --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ - --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ - --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f +cffi==2.1.1 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 # via cryptography charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ From b401c751370993171ee6122e1711b1c198405f80 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 10 Aug 2026 23:29:28 -0700 Subject: [PATCH 173/179] build(pyrefly): enable Pyrefly static type checking across Python targets (#4020) Enables Pyrefly static type checking across Python source and test targets in rules_python and sphinxdocs. Previously, static type analysis was not uniformly applied across all Python targets, leaving potential type inconsistencies and unbound variable edge cases uncaught during builds and CI runs. This change configures the global Pyrefly aspect to evaluate Python targets by default in Bzlmod mode, adds type annotations and narrowing assertions on runfiles resolution, and annotates dynamic imports (such as generated protobuf stubs and compiled C extensions) with explicit type ignore comments. Targets utilizing dynamically generated bootstrap wrappers retain opt-out tags with documented rationales. --- .agents/rules/python.md | 10 + .../scripts/analyze_ci_failure.py | 105 ++++++-- .bazelrc | 4 +- docs/howto/debuggers.md | 4 +- examples/wheel/main.py | 6 +- examples/wheel/private/directory_writer.py | 3 +- examples/wheel/wheel_test.py | 17 +- python/bin/repl_stub.py | 5 +- python/private/py_console_script_gen.py | 9 +- python/private/py_test_main_validator.py | 41 +-- .../dependency_resolver.py | 24 +- .../private/pypi/whl_installer/arguments.py | 6 +- python/private/repl_template.py | 5 +- python/runfiles/BUILD.bazel | 1 - python/runfiles/runfiles.py | 105 ++++---- sphinxdocs/.bazelrc | 4 + sphinxdocs/MODULE.bazel | 17 +- sphinxdocs/integration_tests/runner.py | 8 +- .../sphinxdocs/private/proto_to_markdown.py | 21 +- sphinxdocs/sphinxdocs/private/sphinx_build.py | 104 +++++--- sphinxdocs/sphinxdocs/src/sphinx_bzl/bzl.py | 238 +++++++++++------- .../tests/proto_to_markdown/BUILD.bazel | 2 +- .../proto_to_markdown_test.py | 8 +- sphinxdocs/tests/sphinx_build/BUILD.bazel | 4 +- sphinxdocs/tests/sphinx_docs/BUILD.bazel | 6 +- .../sphinx_docs_conf_in_other_dir/BUILD.bazel | 4 +- sphinxdocs/tests/sphinx_stardoc/BUILD.bazel | 8 +- sphinxdocs/tests/support/pyrefly/BUILD.bazel | 8 + sphinxdocs/tests/support/pyrefly/pyrefly.bzl | 5 + .../bazel_tools_importable_test.py | 6 +- tests/bootstrap_impls/bin.py | 2 +- tests/bootstrap_impls/sys_path_order_test.py | 2 +- tests/build_data/build_data_test.py | 4 +- tests/build_data/print_build_data.py | 2 +- .../abi3_headers_linkage_test.py | 2 + .../cc/py_extension/py_extension_pkg_test.py | 6 +- tests/cc/py_extension/py_extension_test.py | 6 +- .../py_console_script_gen_test.py | 2 +- tests/integration/runner.py | 8 +- tests/integration/uv_lock_pypi_server.py | 2 +- .../multi_pypi/pypi_alpha/pypi_alpha_test.py | 4 +- tests/multi_pypi/pypi_beta/pypi_beta_test.py | 4 +- tests/news/news_test.py | 1 + tests/py_zipapp/BUILD.bazel | 6 + tests/repl/BUILD.bazel | 2 + tests/repl/repl_test.py | 15 +- tests/runfiles/pathlib_test.py | 4 +- tests/runfiles/runfiles_test.py | 16 +- .../toolchain_runs_test.py | 9 +- tests/support/pyrefly/pyrefly.bzl | 4 +- .../pytest_test/pytest_bootstrap_template.py | 2 +- tests/toolchains/python_toolchain_test.py | 2 + tests/tools/private/release/BUILD.bazel | 2 + tests/tools/private/release/git_test.py | 2 +- tests/uv/lock/lock_run_test.py | 1 + .../shared_lib_loading_test.py | 26 +- tools/private/release/mock_gh.py | 1 + tools/private/release/process_backports.py | 2 +- tools/private/release/promote.py | 11 +- tools/private/release/release_issue.py | 3 +- tools/private/update_deps/args.py | 6 +- .../update_deps/update_coverage_deps.py | 4 +- tools/private/update_deps/update_pip_deps.py | 2 +- tools/wheelmaker.py | 29 ++- 64 files changed, 625 insertions(+), 357 deletions(-) mode change 100644 => 100755 .agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py create mode 100644 sphinxdocs/tests/support/pyrefly/BUILD.bazel create mode 100644 sphinxdocs/tests/support/pyrefly/pyrefly.bzl diff --git a/.agents/rules/python.md b/.agents/rules/python.md index d683152814..c149130fe2 100644 --- a/.agents/rules/python.md +++ b/.agents/rules/python.md @@ -13,6 +13,16 @@ * **External Objects**: When defining a `TypedDict` for an external object, link to its definition in the docstring. +## Type Checking & Annotations +* **In-file disables vs target skipping**: Prefer `# pyrefly: ignore[]` + (e.g. `[missing-import]`) over `tags = ["no-pyrefly"]`. +* **No blanket ignores**: NEVER use bare `# type: ignore` or literal + `# type: ignore[...]`. Use error-specific ignores instead. +* **Type assertions**: When adding assertions for type narrowing, add an + end-of-line comment: `assert foo is not None # type assert`. +* **Consent for `Any`**: Require user consent before changing type annotations + to `Any`. + ## Delegating Functions * Module-level functions delegating to class methods should have a docstring referring to the class method (e.g. `"""Refer to \`Class.method\`."""`). diff --git a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py old mode 100644 new mode 100755 index 661a427cc7..5708f6d1c2 --- a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py +++ b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py @@ -8,7 +8,29 @@ import urllib.request -def fetch_log(build_id, job_id, output_path): +def fetch_log(job_name, build_id, job_id, output_path): + if ( + "readthedocs" in job_name.lower() + or "readthedocs" in build_id.lower() + or "readthedocs" in job_id.lower() + ): + rtd_match = re.search(r"(\d+)", build_id) or re.search(r"(\d+)", job_id) + if rtd_match: + rtd_id = rtd_match.group(1) + rtd_url = f"https://app.readthedocs.org/api/v2/build/{rtd_id}.txt" + print(f"📥 Downloading ReadTheDocs failure log from {rtd_url}...") + req = urllib.request.Request(rtd_url, headers={"User-Agent": "ci-analyzer"}) + try: + with urllib.request.urlopen(req) as resp: + content = resp.read() + with open(output_path, "wb") as f: + f.write(content) + return True + except Exception as e: + print( + f"⚠️ Failed to download RTD log from {rtd_url}: {e}", file=sys.stderr + ) + if build_id.startswith("http"): log_url = build_id elif job_id.startswith("http"): @@ -17,9 +39,10 @@ def fetch_log(build_id, job_id, output_path): log_url = f"https://buildkite.com/organizations/bazel/pipelines/rules-python-python/builds/{build_id}/jobs/{job_id}/download.txt" # Check if this is a GitHub Actions job - gh_match = re.search(r"github\.com/.*/job/(\d+)", log_url) or re.search( - r"^(\d+)$", job_id - ) + gh_match = re.search(r"github\.com/.*/job/(\d+)", log_url) + if not gh_match and "github" in job_name.lower() and re.match(r"^\d+$", job_id): + gh_match = re.match(r"^(\d+)$", job_id) + if gh_match: gh_job_id = gh_match.group(1) print(f"📥 Fetching GitHub Action log for job {gh_job_id} using gh CLI...") @@ -53,6 +76,9 @@ def fetch_log(build_id, job_id, output_path): return False +ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + def parse_log(log_path): if not os.path.exists(log_path): return [f"Log file not found at {log_path}"] @@ -62,28 +88,36 @@ def parse_log(log_path): errors = [] for line in lines: + clean_line = ANSI_ESCAPE.sub("", line).strip() + # Clean buildkite timestamp prefix: _bk;t=... + clean_line = re.sub(r"^_bk;t=\d+\s*", "", clean_line) if any( - keyword in line + keyword.lower() in clean_line.lower() for keyword in [ - "ERROR:", - "FAILED:", - "Critical Path", - "Traceback", - "Exception", - "FileNotFoundError", + "error:", + "failed:", + "critical path", + "traceback", + "exception", + "filenotfounderror", "no such package", "no such target", "exit code", "exit-code", + "status 125", "fatal:", "fatal", "##[error]", - "Would reformat:", + "would reformat:", "would be reformatted", "error]", + "error waiting for container", + "error during connect:", + "user command error:", ] ): - errors.append(line.strip()) + if clean_line: + errors.append(clean_line) return errors[:30] @@ -95,8 +129,44 @@ def create_plan(job_name, log_path, errors): else "No obvious keyword error lines matched. Please inspect the raw log file." ) + is_flake = False + flake_reason = "" + if any( + "fatal: destination path '.' already exists and is not an empty directory." in e + for e in errors + ): + is_flake = True + flake_reason = "ReadTheDocs workspace checkout race / dirty container environment where target directory is not empty (`fatal: destination path '.' already exists`). This is an infrastructure flake, not a codebase failure." + elif any("exit code 2" in e.lower() for e in errors) and ( + "docs" in job_name.lower() or "readthedocs" in job_name.lower() + ): + is_flake = True + flake_reason = "Known docs build flake with exit code 2." + elif any( + "error waiting for container" in e.lower() + or "status 125" in e.lower() + or "error during connect:" in e.lower() + or "docker-buildkite-plugin command hook exited with status 125" in e.lower() + for e in errors + ): + is_flake = True + flake_reason = "Buildkite agent / Docker runner infrastructure failure (dockerd disconnection / grpc context canceled / exit status 125). This is an infrastructure flake, not a codebase bug." + + classification = ( + "⚡ **Classification**: **Infrastructure / Flake Issue** (Not a codebase bug)" + if is_flake + else "🔍 **Classification**: **Code / Configuration Issue**" + ) + fix_advice = ( + f"Retry the failed job (`buildkite-retry-job`). {flake_reason}" + if is_flake + else "Resolve the root cause in the relevant source / build files." + ) + plan = f"""# 🚨 CI Failure Analysis Report: {job_name} +{classification} + ## 📁 CI Log Path `{log_path}` @@ -106,10 +176,9 @@ def create_plan(job_name, log_path, errors): ``` ## 🛠️ Suggested Plan to Fix -1. **Inspect Log**: Review the exact log snippets above or read the full raw log file at `{log_path}`. -2. **Reproduce Locally**: Run `./replicate_ci "{job_name}"` or the matching `bazel build/test` command locally. -3. **Apply Fix**: Resolve the root cause in the relevant `BUILD.bazel` or Starlark files. -4. **Verify & Push**: Run local verification with `--config=fast-tests` and push the updated branch to trigger a clean pipeline. +1. **Diagnosis**: {flake_reason if is_flake else "Review extracted errors."} +2. **Action**: {fix_advice} +3. **Verify**: Check the new build status once re-triggered. """ return plan @@ -131,7 +200,7 @@ def main(): safe_jname = re.sub(r"[^a-zA-Z0-9]", "_", args.job_name) log_path = os.path.join(scratch_dir, f"ci_{safe_jname}_{args.job_id}.log") - fetch_log(args.build_id, args.job_id, log_path) + fetch_log(args.job_name, args.build_id, args.job_id, log_path) print(f"🚀 Analyzing CI failure log for '{args.job_name}' at '{log_path}'...") errors = parse_log(log_path) diff --git a/.bazelrc b/.bazelrc index 90f3bc8fdb..cd41961452 100644 --- a/.bazelrc +++ b/.bazelrc @@ -19,7 +19,9 @@ test --test_output=errors # Python targets as required. build --incompatible_default_to_explicit_init_py build --//python/config_settings:incompatible_default_to_explicit_init_py=True -build --aspects=//tests/support/pyrefly:pyrefly.bzl%pyrefly_aspect +build --config=pyrefly +build:pyrefly --aspects=//tests/support/pyrefly:pyrefly.bzl%pyrefly_aspect +build:pyrefly --output_groups=+pyrefly # Ensure ongoing compatibility with this flag. common --incompatible_disallow_struct_provider_syntax diff --git a/docs/howto/debuggers.md b/docs/howto/debuggers.md index 199a366675..2fd8dada57 100644 --- a/docs/howto/debuggers.md +++ b/docs/howto/debuggers.md @@ -107,10 +107,10 @@ For the remainder of this document, we assume you are using vscode. # Import debugpy, provided by VS Code try: # debugpy._vendored is needed for force_pydevd to perform path manipulation. - import debugpy._vendored # type: ignore[import-not-found] + import debugpy._vendored # pydev_monkey patches os and subprocess functions to handle new launched processes. - from _pydev_bundle import pydev_monkey # type: ignore[import-not-found] + from _pydev_bundle import pydev_monkey except ImportError as exc: print(f"Error: This script must be run via VS Code's debug adapter. Details: {exc}") sys.exit(-1) diff --git a/examples/wheel/main.py b/examples/wheel/main.py index 37b4f69811..5b221542c3 100644 --- a/examples/wheel/main.py +++ b/examples/wheel/main.py @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import examples.wheel.lib.module_with_data as module_with_data -import examples.wheel.lib.module_with_type_annotations as module_with_type_annotations -import examples.wheel.lib.simple_module as simple_module +import examples.wheel.lib.module_with_data as module_with_data # pyrefly: ignore[missing-import] +import examples.wheel.lib.module_with_type_annotations as module_with_type_annotations # pyrefly: ignore[missing-import] +import examples.wheel.lib.simple_module as simple_module # pyrefly: ignore[missing-import] def function(): diff --git a/examples/wheel/private/directory_writer.py b/examples/wheel/private/directory_writer.py index 4b69f3a5d0..d2297124cf 100644 --- a/examples/wheel/private/directory_writer.py +++ b/examples/wheel/private/directory_writer.py @@ -18,10 +18,9 @@ import argparse import json from pathlib import Path -from typing import Tuple -def _file_input(value) -> Tuple[Path, str]: +def _file_input(value) -> tuple[Path, str]: path, content = value.split("=", maxsplit=1) return (Path(path), json.loads(content)) diff --git a/examples/wheel/wheel_test.py b/examples/wheel/wheel_test.py index 8dcad42138..d289cb7c8a 100644 --- a/examples/wheel/wheel_test.py +++ b/examples/wheel/wheel_test.py @@ -31,6 +31,7 @@ def setUp(self): self.runfiles = runfiles.Create() def _get_path(self, filename): + assert self.runfiles is not None # type assert runfiles_path = os.path.join("rules_python/examples/wheel", filename) path = self.runfiles.Rlocation(runfiles_path) # The runfiles API can return None if the path doesn't exist or @@ -110,7 +111,7 @@ def test_py_package_wheel(self): ], ) self.assertFileSha256Equal( - filename, "39bec133cf79431e8d057eae550cd91aa9dfbddfedb53d98ebd36e3ade2753d0" + filename, "7322902ab63fd702afb9730843496637058b5d7449208c624875d06d191d386e" ) def test_customized_wheel(self): @@ -155,7 +156,7 @@ def test_customized_wheel(self): examples/wheel/lib/module_with_type_annotations.py,sha256=2p_0YFT0TBUufbGCAR_u2vtxF1nM0lf3dX4VGeUtYq0,637 examples/wheel/lib/module_with_type_annotations.pyi,sha256=fja3ql_WRJ1qO8jyZjWWrTTMcg1J7EpOQivOHY_8vI4,630 examples/wheel/lib/simple_module.py,sha256=z2hwciab_XPNIBNH8B1Q5fYgnJvQTeYf0ZQJpY8yLLY,637 -examples/wheel/main.py,sha256=mFiRfzQEDwCHr-WVNQhOH26M42bw1UMF6IoqvtuDTrw,1047 +examples/wheel/main.py,sha256=THX1qSP_5NUcJrzcFFtpCO7XKFFgPpvanwZo4X_1e-o,1152 example_customized-0.0.1.dist-info/WHEEL,sha256=sobxWSyDDkdg_rinUth-jxhXHqoNqlmNMJY3aTZn2Us,91 example_customized-0.0.1.dist-info/METADATA,sha256=QYQcDJFQSIqan8eiXqL67bqsUfgEAwf2hoK_Lgi1S-0,559 example_customized-0.0.1.dist-info/entry_points.txt,sha256=pqzpbQ8MMorrJ3Jp0ntmpZcuvfByyqzMXXi2UujuXD0,137 @@ -206,7 +207,7 @@ def test_customized_wheel(self): second = second.main:s""", ) self.assertFileSha256Equal( - filename, "685f68fc6665f53c9b769fd1ba12cce9937ab7f40ef4e60c82ef2de8653935de" + filename, "6d08fbb30864cee89396e7857c910c92bec56b6586d40a64b796b4812af15fbf" ) def test_filename_escaping(self): @@ -278,7 +279,7 @@ def test_custom_package_root_wheel(self): for line in record_contents.splitlines(): self.assertFalse(line.startswith("/")) self.assertFileSha256Equal( - filename, "2fbfc3baaf6fccca0f97d02316b8344507fe6c8136991a66ee5f162235adb19f" + filename, "0b5a35251ad35fd9e14f3f7e77993f59a7341268f24fb0a255b403cee60d429e" ) def test_custom_package_root_multi_prefix_wheel(self): @@ -312,7 +313,7 @@ def test_custom_package_root_multi_prefix_wheel(self): for line in record_contents.splitlines(): self.assertFalse(line.startswith("/")) self.assertFileSha256Equal( - filename, "3e67971ca1e8a9ba36a143df7532e641f5661c56235e41d818309316c955ba58" + filename, "437127690584a035dc37542f64c38d1a6d6652655afc81f5a9472706343aae23" ) def test_custom_package_root_multi_prefix_reverse_order_wheel(self): @@ -346,7 +347,7 @@ def test_custom_package_root_multi_prefix_reverse_order_wheel(self): for line in record_contents.splitlines(): self.assertFalse(line.startswith("/")) self.assertFileSha256Equal( - filename, "372ef9e11fb79f1952172993718a326b5adda192d94884b54377c34b44394982" + filename, "265cc2ba4c99d0b62f1922f357de961f15f416bcee93ff307e4d4f04e4c067a3" ) def test_python_requires_wheel(self): @@ -371,7 +372,7 @@ def test_python_requires_wheel(self): """, ) self.assertFileSha256Equal( - filename, "10a325ba8f77428b5cfcff6345d508f5eb77c140889eb62490d7382f60d4ebfe" + filename, "cb1d0bf64df1cbf23b7d4473a1c113cedbbea0d23209cdb4d2e5fe2edc68ceec" ) def test_python_abi3_binary_wheel(self): @@ -436,7 +437,7 @@ def test_rule_creates_directory_and_is_included_in_wheel(self): ], ) self.assertFileSha256Equal( - filename, "85e44c43cc19ccae9fe2e1d629230203aa11791bed1f7f68a069fb58d1c93cd2" + filename, "2358a8ee58dd7ed1a89862e368a0eb00e83ec5de28995ecf0f3c38c2524102dc" ) def test_rule_expands_workspace_status_keys_in_wheel_metadata(self): diff --git a/python/bin/repl_stub.py b/python/bin/repl_stub.py index 858cf810b9..bb08ab2f87 100644 --- a/python/bin/repl_stub.py +++ b/python/bin/repl_stub.py @@ -57,9 +57,10 @@ def complete(self, text, state): # TODO(jpwoodbu): Use readline.backend instead of readline.__doc__ once we can depend on having # Python >=3.13. - if "libedit" in readline.__doc__: # type: ignore + doc = readline.__doc__ or "" + if "libedit" in doc: readline.parse_and_bind("bind ^I rl_complete") - elif "GNU readline" in readline.__doc__: # type: ignore + elif "GNU readline" in doc: readline.parse_and_bind("tab: complete") else: print("Could not enable tab completion: unable to determine readline backend") diff --git a/python/private/py_console_script_gen.py b/python/private/py_console_script_gen.py index 2be986f732..887441c503 100644 --- a/python/private/py_console_script_gen.py +++ b/python/private/py_console_script_gen.py @@ -59,7 +59,7 @@ raise if __name__ == "__main__": - sys.exit({entry_point}()) # type: ignore + sys.exit({entry_point}()) # pyrefly: ignore[not-callable] """ @@ -69,10 +69,11 @@ class EntryPointsParser(configparser.ConfigParser): See https://packaging.python.org/en/latest/specifications/entry-points/ """ - optionxform = staticmethod(str) + def optionxform(self, optionstr: str) -> str: + return str(optionstr) -def _guess_entry_point(guess: str, console_scripts: dict[string, string]) -> str | None: # noqa: F821 +def _guess_entry_point(guess: str, console_scripts: dict[str, str]) -> str | None: for key, candidate in console_scripts.items(): if guess == key: return candidate @@ -82,7 +83,7 @@ def run( *, entry_points: pathlib.Path, out: pathlib.Path, - console_script: str, + console_script: str | None, console_script_guess: str, shebang: str, ): diff --git a/python/private/py_test_main_validator.py b/python/private/py_test_main_validator.py index e3849c5f57..e66bf6849e 100644 --- a/python/private/py_test_main_validator.py +++ b/python/private/py_test_main_validator.py @@ -24,25 +24,28 @@ import ast import sys -# Statement node types that never run any code on their own, regardless of -# their contents. A module whose top-level body consists solely of these (and -# inert assignments/expressions/guards, see below) is considered inert. -_INERT_NODE_TYPES = [ - ast.FunctionDef, - ast.AsyncFunctionDef, - ast.ClassDef, - ast.Import, - ast.ImportFrom, - ast.Global, - ast.Pass, -] - -# `ast.TypeAlias` (PEP 695, e.g. `type Alias = int`) only exists on Python -# 3.12+. Add it dynamically so the validator still imports on older versions. -if hasattr(ast, "TypeAlias"): - _INERT_NODE_TYPES.append(ast.TypeAlias) - -_INERT_NODE_TYPES = tuple(_INERT_NODE_TYPES) + +def _compute_inert_node_types() -> tuple[type[ast.AST], ...]: + # Statement node types that never run any code on their own, regardless of + # their contents. A module whose top-level body consists solely of these (and + # inert assignments/expressions/guards, see below) is considered inert. + node_types: list[type[ast.AST]] = [ + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ast.Import, + ast.ImportFrom, + ast.Global, + ast.Pass, + ] + # `ast.TypeAlias` (PEP 695, e.g. `type Alias = int`) only exists on Python + # 3.12+. Add it dynamically so the validator still imports on older versions. + if hasattr(ast, "TypeAlias"): + node_types.append(ast.TypeAlias) + return tuple(node_types) + + +_INERT_NODE_TYPES = _compute_inert_node_types() # `ast.TryStar` (PEP 654, `try/except*`) only exists on Python 3.11+. _TRY_NODE_TYPES = (ast.Try, ast.TryStar) if hasattr(ast, "TryStar") else (ast.Try,) diff --git a/python/private/pypi/dependency_resolver/dependency_resolver.py b/python/private/pypi/dependency_resolver/dependency_resolver.py index 34f2fb1e5a..2b24625c33 100644 --- a/python/private/pypi/dependency_resolver/dependency_resolver.py +++ b/python/private/pypi/dependency_resolver/dependency_resolver.py @@ -14,13 +14,14 @@ "Set defaults for the pip-compile command to run it under Bazel" +from __future__ import annotations + import atexit import functools import os import shutil import sys from pathlib import Path -from typing import List, Optional, Tuple import click import piptools.writer as piptools_writer @@ -32,7 +33,7 @@ # Replace the os.replace function with shutil.copy to work around os.replace not being able to # replace or move files across filesystems. -os.replace = shutil.copy +os.replace = shutil.copy # pyrefly: ignore[bad-assignment] # Next, we override the annotation_style_split and annotation_style_line functions to replace the # backslashes in the paths with forward slashes. This is so that we can have the same requirements @@ -91,13 +92,13 @@ def _locate(bazel_runfiles, file): @click.option("--requirements-windows") @click.argument("extra_args", nargs=-1, type=click.UNPROCESSED) def main( - srcs: Tuple[str, ...], + srcs: tuple[str, ...], requirements_txt: str, target_label_prefix: str, - requirements_linux: Optional[str], - requirements_darwin: Optional[str], - requirements_windows: Optional[str], - extra_args: Tuple[str, ...], + requirements_linux: str | None, + requirements_darwin: str | None, + requirements_windows: str | None, + extra_args: tuple[str, ...], ) -> None: bazel_runfiles = runfiles.Create() @@ -137,6 +138,7 @@ def main( os.environ["LANG"] = "C.UTF-8" argv = [] + requirements_out = requirements_file_relative UPDATE = True # Detect if we are running under `bazel test`. @@ -172,9 +174,7 @@ def main( os.environ["CUSTOM_COMPILE_COMMAND"] = update_command os.environ["PIP_CONFIG_FILE"] = os.getenv("PIP_CONFIG_FILE") or os.devnull - argv.append( - f"--output-file={requirements_file_relative if UPDATE else requirements_out}" - ) + argv.append(f"--output-file={requirements_out}") argv.extend( (src_relative if Path(src_relative).exists() else resolved_src) for src_relative, resolved_src in zip(srcs_relative, resolved_srcs) @@ -230,9 +230,9 @@ def main( def run_pip_compile( - args: List[str], + args: list[str], *, - srcs_relative: List[str], + srcs_relative: list[str], verbose_command: str, ) -> None: try: diff --git a/python/private/pypi/whl_installer/arguments.py b/python/private/pypi/whl_installer/arguments.py index 8471c94ffe..e6f5989c5d 100644 --- a/python/private/pypi/whl_installer/arguments.py +++ b/python/private/pypi/whl_installer/arguments.py @@ -14,7 +14,7 @@ import argparse import json -from typing import Any, Dict, Set +from typing import Any def parser(**kwargs: Any) -> argparse.ArgumentParser: @@ -57,7 +57,7 @@ def parser(**kwargs: Any) -> argparse.ArgumentParser: return parser -def deserialize_structured_args(args: Dict[str, str]) -> Dict: +def deserialize_structured_args(args: dict[str, Any]) -> dict[str, Any]: """Deserialize structured arguments passed from the starlark rules. Args: @@ -72,7 +72,7 @@ def deserialize_structured_args(args: Dict[str, str]) -> Dict: return args -def get_platforms(args: argparse.Namespace) -> Set: +def get_platforms(args: argparse.Namespace) -> set: """Aggregate platforms into a single set. Args: diff --git a/python/private/repl_template.py b/python/private/repl_template.py index dd8beb9784..8a6a62ca1a 100644 --- a/python/private/repl_template.py +++ b/python/private/repl_template.py @@ -35,9 +35,10 @@ def start_repl(): compiled_code = compile(source_code, filename=startup_file, mode="exec") eval(compiled_code, new_globals) - bazel_runfiles = runfiles.Create() + bazel_runfiles = runfiles.CreateOrRaise() + stub_path = bazel_runfiles.root() / STUB_PATH runpy.run_path( - bazel_runfiles.Rlocation(STUB_PATH), + str(stub_path), init_globals=new_globals, run_name="__main__", ) diff --git a/python/runfiles/BUILD.bazel b/python/runfiles/BUILD.bazel index 4e119eddbe..73663472dc 100644 --- a/python/runfiles/BUILD.bazel +++ b/python/runfiles/BUILD.bazel @@ -40,7 +40,6 @@ py_library( # to the --experimental_python_import_all_repositories setting. "../..", ], - tags = ["pyrefly"], visibility = ["//visibility:public"], ) diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index 02fceb3020..af87b54437 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -23,20 +23,23 @@ ::: """ +from __future__ import annotations + import inspect import os import pathlib import posixpath import sys from collections import defaultdict -from typing import Dict, Generator, Optional, Tuple, Union +from collections.abc import Generator +from typing import cast if sys.version_info >= (3, 11): from typing import Self elif sys.version_info >= (3, 10): from typing import TypeAlias - Self: TypeAlias = "Path" # type: ignore + Self: TypeAlias = "Path" # pyrefly: ignore[invalid-type-form] else: from typing import Any as Self @@ -50,8 +53,8 @@ class _RepositoryMapping: def __init__( self, - exact_mappings: Dict[Tuple[str, str], str], - prefixed_mappings: Dict[Tuple[str, str], str], + exact_mappings: dict[tuple[str, str], str], + prefixed_mappings: dict[tuple[str, str], str], ) -> None: """Initialize repository mapping with exact and prefixed mappings. @@ -72,7 +75,7 @@ def __init__( ) @staticmethod - def create_from_file(repo_mapping_path: Optional[str]) -> "_RepositoryMapping": + def create_from_file(repo_mapping_path: str | None) -> _RepositoryMapping: """Create RepositoryMapping from a repository mapping manifest file. Args: @@ -107,7 +110,7 @@ def create_from_file(repo_mapping_path: Optional[str]) -> "_RepositoryMapping": return _RepositoryMapping(exact_mappings, prefixed_mappings) - def lookup(self, source_repo: Optional[str], target_apparent: str) -> Optional[str]: + def lookup(self, source_repo: str | None, target_apparent: str) -> str | None: """Look up repository mapping for the given source and target. This handles both exact mappings and prefix-based mappings introduced by the @@ -161,31 +164,29 @@ class Path(pathlib.Path): # Mypy isn't smart enough to realize `self` in the methods # refers to our Path class instead of pathlib.Path - _runfiles: Optional["Runfiles"] - _source_repo: Optional[str] + _runfiles: Runfiles | None + _source_repo: str | None # For Python < 3.12 compatibility when subclassing Path directly _flavour = getattr(type(pathlib.Path()), "_flavour", None) def __new__( cls, - *args: Union[str, os.PathLike], - runfiles: Optional["Runfiles"] = None, - source_repo: Optional[str] = None, + *args: str | os.PathLike, + runfiles: Runfiles | None = None, + source_repo: str | None = None, ) -> Self: """Private constructor. Use Runfiles.root() to create instances.""" - obj = super().__new__(cls, *args) - # Type checkers might complain about adding attributes to Path, - # but this is standard for pathlib subclasses. - obj._runfiles = runfiles # type: ignore - obj._source_repo = source_repo # type: ignore - return obj + obj = cast("Path", super().__new__(cls, *args)) + obj._runfiles = runfiles + obj._source_repo = source_repo + return cast(Self, obj) def __init__( self, - *args: Union[str, os.PathLike], - runfiles: Optional["Runfiles"] = None, - source_repo: Optional[str] = None, + *args: str | os.PathLike, + runfiles: Runfiles | None = None, + source_repo: str | None = None, ) -> None: # In Python 3.12+, pathlib was refactored and Path.__init__ now accepts # *args. Prior to 3.12, Path did not define __init__, so @@ -218,7 +219,7 @@ def absolute(self) -> Self: ) # override - def with_segments(self, *pathsegments: Union[str, os.PathLike]) -> Self: + def with_segments(self, *pathsegments: str | os.PathLike) -> Self: """Used by Python 3.12+ pathlib to create new path objects.""" return type(self)( *pathsegments, @@ -228,15 +229,17 @@ def with_segments(self, *pathsegments: Union[str, os.PathLike]) -> Self: # For Python < 3.12 # override - def _make_child(self, args: Tuple[str, ...]) -> Self: - obj = super()._make_child(args) # type: ignore - obj._runfiles = self._runfiles # type: ignore - obj._source_repo = self._source_repo # type: ignore - return obj + def _make_child(self, args: tuple[str, ...]) -> Self: + # _make_child is an internal CPython method in Python < 3.12 omitted from + # typeshed stubs. We ignore [misc] for mypy and [missing-attribute] for pyrefly. + obj = cast("Path", super()._make_child(args)) # type: ignore[misc] # pyrefly: ignore[missing-attribute] + obj._runfiles = self._runfiles + obj._source_repo = self._source_repo + return cast(Self, obj) # override @property - def parents(self) -> Tuple[Self, ...]: + def parents(self) -> tuple[Self, ...]: return tuple( type(self)( p, @@ -322,14 +325,16 @@ def is_fifo(self) -> bool: def is_socket(self) -> bool: return self._as_path().is_socket() + # Path.open in pathlib has multiple overloads in typeshed. We use a + # simplified delegation signature here. # override def open( # pyrefly: ignore[bad-override] self, mode: str = "r", buffering: int = -1, - encoding: Optional[str] = None, - errors: Optional[str] = None, - newline: Optional[str] = None, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, ): return self._as_path().open( mode=mode, @@ -344,9 +349,7 @@ def read_bytes(self) -> bytes: return self._as_path().read_bytes() # override - def read_text( - self, encoding: Optional[str] = None, errors: Optional[str] = None - ) -> str: + def read_text(self, encoding: str | None = None, errors: str | None = None) -> str: return self._as_path().read_text(encoding=encoding, errors=errors) # override @@ -371,23 +374,25 @@ def __repr__(self) -> str: return "runfiles.Path({!r})".format(self.runfile_path) def __str__(self) -> str: + assert self._runfiles is not None # type assert path_posix = super().__str__().replace("\\", "/") if not path_posix or path_posix == ".": # pylint: disable=protected-access - return self._runfiles._python_runfiles_root # type: ignore - resolved = self._runfiles.Rlocation(path_posix, source_repo=self._source_repo) # type: ignore + return self._runfiles._python_runfiles_root # type: ignore[attr-defined] # pyrefly: ignore[missing-attribute] + resolved = self._runfiles.Rlocation(path_posix, source_repo=self._source_repo) if resolved is not None: return resolved # pylint: disable=protected-access - return posixpath.join(self._runfiles._python_runfiles_root, path_posix) # type: ignore + return posixpath.join(self._runfiles._python_runfiles_root, path_posix) # type: ignore[attr-defined] # pyrefly: ignore[missing-attribute] def __fspath__(self) -> str: return str(self) - def runfiles_root(self) -> Self: + def runfiles_root(self) -> "Path": """Returns a Path object representing the runfiles root.""" - return self._runfiles.root(source_repo=self._source_repo) # type: ignore + assert self._runfiles is not None # type assert + return self._runfiles.root(source_repo=self._source_repo) class _ManifestBased: @@ -401,7 +406,7 @@ def __init__(self, path: str) -> None: self._path = path self._runfiles = _ManifestBased._LoadRunfiles(path) - def RlocationChecked(self, path: str) -> Optional[str]: + def RlocationChecked(self, path: str) -> str | None: """Returns the runtime path of a runfile.""" exact_match = self._runfiles.get(path) if exact_match: @@ -420,7 +425,7 @@ def RlocationChecked(self, path: str) -> Optional[str]: return prefix_match + "/" + path[prefix_end + 1 :] @staticmethod - def _LoadRunfiles(path: str) -> Dict[str, str]: + def _LoadRunfiles(path: str) -> dict[str, str]: """Loads the runfiles manifest.""" result = {} with open(path, "r", encoding="utf-8", newline="\n") as f: @@ -452,7 +457,7 @@ def _GetRunfilesDir(self) -> str: return self._path[: -len("_manifest")] return "" - def EnvVars(self) -> Dict[str, str]: + def EnvVars(self) -> dict[str, str]: directory = self._GetRunfilesDir() return { "RUNFILES_MANIFEST_FILE": self._path, @@ -482,7 +487,7 @@ def RlocationChecked(self, path: str) -> str: def _GetRunfilesDir(self) -> str: return self._runfiles_root - def EnvVars(self) -> Dict[str, str]: + def EnvVars(self) -> dict[str, str]: return { "RUNFILES_DIR": self._runfiles_root, # TODO(laszlocsomor): remove JAVA_RUNFILES once the Java launcher can @@ -497,14 +502,14 @@ class Runfiles: Runfiles are data-dependencies of Bazel-built binaries and tests. """ - def __init__(self, strategy: Union[_ManifestBased, _DirectoryBased]) -> None: + def __init__(self, strategy: _ManifestBased | _DirectoryBased) -> None: self._strategy = strategy self._python_runfiles_root = strategy._GetRunfilesDir() self._repo_mapping = _RepositoryMapping.create_from_file( strategy.RlocationChecked("_repo_mapping") ) - def root(self, source_repo: Optional[str] = None) -> Path: + def root(self, source_repo: str | None = None) -> Path: """Returns a Path object representing the runfiles root. The repository mapping used by the returned Path object is that of the @@ -514,7 +519,7 @@ def root(self, source_repo: Optional[str] = None) -> Path: source_repo = self.CurrentRepository(frame=2) return Path(runfiles=self, source_repo=source_repo) - def Rlocation(self, path: str, source_repo: Optional[str] = None) -> Optional[str]: + def Rlocation(self, path: str, source_repo: str | None = None) -> str | None: """Returns the runtime path of a runfile. Runfiles are data-dependencies of Bazel-built binaries and tests. @@ -591,7 +596,7 @@ def Rlocation(self, path: str, source_repo: Optional[str] = None) -> Optional[st # we're not using Bzlmod return self._strategy.RlocationChecked(path) - def EnvVars(self) -> Dict[str, str]: + def EnvVars(self) -> dict[str, str]: """Returns environment variables for subprocesses. The caller should set the returned key-value pairs in the environment of @@ -693,7 +698,7 @@ def CreateDirectoryBased(runfiles_dir_path: str) -> "Runfiles": # TODO: Update return type to Self when 3.11 is the min version # https://peps.python.org/pep-0673/ @staticmethod - def Create(env: Optional[Dict[str, str]] = None) -> Optional["Runfiles"]: + def Create(env: dict[str, str] | None = None) -> Runfiles | None: """Returns a new `Runfiles` instance. The returned object is either: @@ -731,7 +736,7 @@ def Create(env: Optional[Dict[str, str]] = None) -> Optional["Runfiles"]: # TODO: Update return type to Self when 3.11 is the min version # https://peps.python.org/pep-0673/ @staticmethod - def CreateOrRaise(env: Optional[Dict[str, str]] = None) -> "Runfiles": + def CreateOrRaise(env: dict[str, str] | None = None) -> Runfiles: """Returns a new `Runfiles` instance, or raises an error. The returned object is either: @@ -781,11 +786,11 @@ def CreateDirectoryBased(runfiles_dir_path: str) -> Runfiles: return Runfiles.CreateDirectoryBased(runfiles_dir_path) -def Create(env: Optional[Dict[str, str]] = None) -> Optional[Runfiles]: +def Create(env: dict[str, str] | None = None) -> Runfiles | None: return Runfiles.Create(env) -def CreateOrRaise(env: Optional[Dict[str, str]] = None) -> Runfiles: +def CreateOrRaise(env: dict[str, str] | None = None) -> Runfiles: """Refer to `Runfiles.CreateOrRaise`. :::{versionadded} VERSION_NEXT_FEATURE diff --git a/sphinxdocs/.bazelrc b/sphinxdocs/.bazelrc index ce4d782113..6989c2656a 100644 --- a/sphinxdocs/.bazelrc +++ b/sphinxdocs/.bazelrc @@ -31,3 +31,7 @@ build --lockfile_mode=update common:fast-tests --build_tests_only=true common:fast-tests --build_tag_filters=-large,-enormous,-integration-test common:fast-tests --test_tag_filters=-large,-enormous,-integration-test + +build --config=pyrefly +build:pyrefly --aspects=//tests/support/pyrefly:pyrefly.bzl%pyrefly_aspect +build:pyrefly --output_groups=+pyrefly diff --git a/sphinxdocs/MODULE.bazel b/sphinxdocs/MODULE.bazel index 51de5e94f9..c1f06ccb50 100644 --- a/sphinxdocs/MODULE.bazel +++ b/sphinxdocs/MODULE.bazel @@ -21,7 +21,7 @@ dev_pip.parse( requirements_lock = "//dev:requirements.txt", uv_lock = "//dev:uv.lock", ) -use_repo(dev_pip, "dev_pip") +use_repo(dev_pip, "dev_pip", "pypi") bazel_dep(name = "rules_bazel_integration_test", version = "0.37.1", dev_dependency = True) @@ -40,3 +40,18 @@ use_repo( "bazel_binaries_bazelisk", "build_bazel_bazel_self", ) + +bazel_dep(name = "rules_pyrefly", version = "0.1.0", dev_dependency = True) + +pyrefly = use_extension( + "@rules_pyrefly//pyrefly:extensions.bzl", + "pyrefly", + dev_dependency = True, +) +pyrefly.toolchain(version = "1.2.0") +use_repo(pyrefly, "pyrefly_toolchains") + +register_toolchains( + "@pyrefly_toolchains//:all", + dev_dependency = True, +) diff --git a/sphinxdocs/integration_tests/runner.py b/sphinxdocs/integration_tests/runner.py index cab9730bb8..c7cc753e93 100644 --- a/sphinxdocs/integration_tests/runner.py +++ b/sphinxdocs/integration_tests/runner.py @@ -72,19 +72,19 @@ def setUp(self): } def run_bazel(self, *args: str, check: bool = True) -> ExecuteResult: - args = [str(self.bazel), *args] + cmd_args = [str(self.bazel), *args] env = self.bazel_env - _logger.info("executing: %s", shlex.join(args)) + _logger.info("executing: %s", shlex.join(cmd_args)) cwd = self.repo_root proc_result = subprocess.run( - args=args, + args=cmd_args, text=True, capture_output=True, cwd=cwd, env=env, check=False, ) - exec_result = ExecuteResult(args, env, cwd, proc_result) + exec_result = ExecuteResult(cmd_args, env, cwd, proc_result) if check and exec_result.exit_code: raise ExecuteError(exec_result) else: diff --git a/sphinxdocs/sphinxdocs/private/proto_to_markdown.py b/sphinxdocs/sphinxdocs/private/proto_to_markdown.py index 05278a5c02..aeecc359f7 100644 --- a/sphinxdocs/sphinxdocs/private/proto_to_markdown.py +++ b/sphinxdocs/sphinxdocs/private/proto_to_markdown.py @@ -12,13 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import argparse import itertools import pathlib import sys -from typing import Callable, TextIO, TypeVar +from collections.abc import Callable, Iterator, Sequence +from typing import TextIO, TypeVar -from stardoc.proto import stardoc_output_pb2 +from stardoc.proto import ( # pyrefly: ignore[missing-import] + stardoc_output_pb2, +) _AttributeType = stardoc_output_pb2.AttributeType @@ -73,7 +78,7 @@ def _join_csv_and(values: list[str]) -> str: return ", ".join(values) -def _position_iter(values: list[_T]) -> tuple[bool, bool, _T]: +def _position_iter(values: Sequence[_T]) -> Iterator[tuple[bool, bool, _T]]: for i, value in enumerate(values): yield i == 0, i == len(values) - 1, value @@ -438,7 +443,9 @@ def _render_provider(self, provider: stardoc_output_pb2.ProviderInfo): self._write(":::::\n") self._write("::::::\n") - def _render_attributes(self, attributes: list[stardoc_output_pb2.AttributeInfo]): + def _render_attributes( + self, attributes: Sequence[stardoc_output_pb2.AttributeInfo] + ): for attr in attributes: attr_type = self._rule_attr_type_string(attr) self._write(f":attr {attr.name}:\n") @@ -491,10 +498,10 @@ def _render_attributes(self, attributes: list[stardoc_output_pb2.AttributeInfo]) def _render_signature( self, name: str, - parameters: list[_T], + parameters: Sequence[_T], *, - get_name: Callable[_T, str], - get_default: Callable[_T, str] = lambda v: None, + get_name: Callable[[_T], str], + get_default: Callable[[_T], str | None] = lambda v: None, ): self._write(name, "(") for _, is_last, param in _position_iter(parameters): diff --git a/sphinxdocs/sphinxdocs/private/sphinx_build.py b/sphinxdocs/sphinxdocs/private/sphinx_build.py index 52a334d9b9..f20fbd676a 100644 --- a/sphinxdocs/sphinxdocs/private/sphinx_build.py +++ b/sphinxdocs/sphinxdocs/private/sphinx_build.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import concurrent.futures import contextlib import io @@ -10,13 +12,55 @@ import sys import threading import traceback -import typing +import types +from typing import TextIO, TypedDict + +import sphinx.application # pyrefly: ignore[missing-import] +from sphinx.cmd.build import main # pyrefly: ignore[missing-import] + + +class WorkRequestInput(TypedDict, total=False): + """Input file with digest for a Bazel persistent worker WorkRequest. + + See https://github.com/bazelbuild/bazel/blob/master/src/main/protobuf/worker_protocol.proto (Input message). + """ + + path: str + digest: str + + +class WorkRequest(TypedDict, total=False): + """Bazel persistent worker WorkRequest protocol structure. + + See https://github.com/bazelbuild/bazel/blob/master/src/main/protobuf/worker_protocol.proto (WorkRequest message). + """ -import sphinx.application -from sphinx.cmd.build import main + id: int + requestId: int + arguments: list[str] + inputs: list[WorkRequestInput] + cancel: bool -WorkRequest = object -WorkResponse = object + +class WorkResponse(TypedDict, total=False): + """Bazel persistent worker WorkResponse protocol structure. + + See https://github.com/bazelbuild/bazel/blob/master/src/main/protobuf/worker_protocol.proto (WorkResponse message). + """ + + id: int + requestId: int + exitCode: int + output: str + wasCancelled: bool + + +class RequestInfo(TypedDict, total=False): + """JSON structure written for the Sphinx extension with worker request metadata.""" + + exec_root: str + inputs: list[WorkRequestInput] + changed_sources: list[str] class SphinxMainError(Exception): @@ -36,7 +80,7 @@ def __init__(self, message, exit_code): class DirectorySyncerError(Exception): """Raised when one or more errors occur during directory synchronization.""" - def __init__(self, errors: typing.List[BaseException]): + def __init__(self, errors: list[BaseException]): self.errors = errors message = f"Encountered {len(errors)} error(s) during sync:\n" + "\n".join( f" - {e}" for e in errors @@ -57,17 +101,17 @@ def __init__( self, srcdir: pathlib.Path, destdir: pathlib.Path, - max_workers: typing.Optional[int] = None, + max_workers: int | None = None, ): self._srcdir = srcdir self._destdir = destdir self._max_workers = max_workers or min(32, (os.cpu_count() or 4) + 4) - self._current_shas: typing.Dict[str, str] = {} + self._current_shas: dict[str, str] = {} self._lock = threading.Lock() self._finished_cond = threading.Condition(self._lock) self._remaining = 0 - self._errors: typing.List[BaseException] = [] - self._executor: typing.Optional[concurrent.futures.ThreadPoolExecutor] = None + self._errors: list[BaseException] = [] + self._executor: concurrent.futures.ThreadPoolExecutor | None = None def _reset_state(self) -> None: with self._lock: @@ -84,6 +128,7 @@ def _wait_for_completion(self) -> None: def _submit_task(self, fn, *args) -> None: with self._lock: self._remaining += 1 + assert self._executor is not None future = self._executor.submit(fn, *args) future.add_done_callback(self._handle_task_done) @@ -118,7 +163,7 @@ def copytree(self) -> None: self._submit_task(self._copy_dir, self._srcdir, self._destdir) self._wait_for_completion() - def sync(self, entries: typing.Dict[str, str]) -> None: + def sync(self, entries: dict[str, str]) -> None: """Synchronizes destdir to match entries {relative_path: sha} concurrently.""" self._reset_state() @@ -198,9 +243,7 @@ def _copy_dir(self, src: pathlib.Path, dest: pathlib.Path) -> None: class Worker: """A Bazel persistent worker for Sphinx builds.""" - def __init__( - self, instream: "typing.TextIO", outstream: "typing.TextIO", exec_root: str - ): + def __init__(self, instream: TextIO, outstream: TextIO, exec_root: str): # NOTE: Sphinx performs its own logging re-configuration, so any # logging config we do isn't respected by Sphinx. Controlling where # stdout and stderr goes are the main mechanisms. Recall that @@ -219,7 +262,7 @@ def __init__( # dict[str srcdir, dict[str path, str digest]] self._digests = {} - self._syncers: typing.Dict[pathlib.Path, DirectorySyncer] = {} + self._syncers: dict[pathlib.Path, DirectorySyncer] = {} # Internal output directories the worker gives to Sphinx that need # to be cleaned up upon exit. @@ -266,11 +309,12 @@ def run(self) -> None: ) except Exception: logger.exception("Unhandled error: request=%s", request) + request_id = request.get("requestId", 0) if request else 0 + req_id_str = request.get("id") if request else "unknown" output = ( - f"Unhandled error:\nRequest id: {request.get('id')}\n" + f"Unhandled error:\nRequest id: {req_id_str}\n" + traceback.format_exc() ) - request_id = 0 if not request else request.get("requestId", 0) self._send_response( { "exitCode": 3, @@ -281,17 +325,17 @@ def run(self) -> None: finally: logger.info("Worker shutting down") - def _get_next_request(self) -> "object | None": + def _get_next_request(self) -> WorkRequest | None: line = self._instream.readline() if not line: return None return json.loads(line) - def _send_response(self, response: "WorkResponse") -> None: + def _send_response(self, response: WorkResponse) -> None: self._outstream.write(json.dumps(response) + "\n") self._outstream.flush() - def _prepare_sphinx(self, request): + def _prepare_sphinx(self, request: WorkRequest): sphinx_args = request["arguments"] srcdir = pathlib.Path(sphinx_args[0]) destdir = pathlib.Path(f"{srcdir}.worker-in.d") @@ -300,9 +344,12 @@ def _prepare_sphinx(self, request): current_digests = self._digests.setdefault(str(srcdir), {}) is_first_request = not current_digests changed_paths = [] - request_info = {"exec_root": self._exec_root, "inputs": request["inputs"]} + request_info: RequestInfo = { + "exec_root": self._exec_root, + "inputs": request.get("inputs", []), + } srcdir_prefix = str(srcdir) + "/" - for entry in request["inputs"]: + for entry in request.get("inputs", []): path = entry["path"] # In persistent worker mode, request["inputs"] includes action-level # tools (e.g. sphinx-build, sphinx_build.py) and params files that @@ -322,7 +369,7 @@ def _prepare_sphinx(self, request): changed_paths.append(path) self._digests[str(srcdir)] = incoming_digests - self._extension.changed_paths = changed_paths + self._extension.changed_paths = set(changed_paths) request_info["changed_sources"] = changed_paths bazel_outdir = sphinx_args[1] @@ -365,7 +412,7 @@ def _redirect_streams(self): with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): yield stdout, stderr - def _process_request(self, request: "WorkRequest") -> "WorkResponse | None": + def _process_request(self, request: WorkRequest) -> WorkResponse | None: logger.info("Request: %s", json.dumps(request, sort_keys=True, indent=2)) if request.get("cancel"): return None @@ -446,14 +493,13 @@ def _process_request(self, request: "WorkRequest") -> "WorkResponse | None": return response -class BazelWorkerExtension: +class BazelWorkerExtension(types.ModuleType): """A Sphinx extension implemented as a class acting like a module.""" - def __init__(self): - # Make it look like a Module object - self.__name__ = _WORKER_SPHINX_EXT_MODULE_NAME + def __init__(self, name: str = _WORKER_SPHINX_EXT_MODULE_NAME): + super().__init__(name) # set[str] of src-dir relative path names - self.changed_paths = set() + self.changed_paths: set[str] = set() def setup(self, app): app.add_config_value(_REQUEST_INFO_CONFIG_NAME, "", "") diff --git a/sphinxdocs/sphinxdocs/src/sphinx_bzl/bzl.py b/sphinxdocs/sphinxdocs/src/sphinx_bzl/bzl.py index c115737ba5..9cef0cce67 100644 --- a/sphinxdocs/sphinxdocs/src/sphinx_bzl/bzl.py +++ b/sphinxdocs/sphinxdocs/src/sphinx_bzl/bzl.py @@ -13,17 +13,23 @@ # limitations under the License. """Sphinx extension for documenting Bazel/Starlark objects.""" +from __future__ import annotations + import ast import collections import enum import os -import typing -from collections.abc import Collection -from typing import Callable, Iterable, TypeVar +from collections.abc import Callable, Collection, Iterable, Iterator, Set +from typing import Any, TypeVar, cast -from docutils import nodes as docutils_nodes -from docutils.parsers.rst import directives as docutils_directives, states -from sphinx import ( +from docutils import ( # pyrefly: ignore[missing-source-for-stubs] + nodes as docutils_nodes, +) +from docutils.parsers.rst import ( # pyrefly: ignore[missing-source-for-stubs] + directives as docutils_directives, + states, +) +from sphinx import ( # pyrefly: ignore[missing-import] addnodes, builders, directives as sphinx_directives, @@ -31,9 +37,11 @@ environment, roles, ) -from sphinx.highlighting import lexer_classes -from sphinx.locale import _ -from sphinx.util import ( +from sphinx.highlighting import ( # pyrefly: ignore[missing-import] + lexer_classes, +) +from sphinx.locale import _ # pyrefly: ignore[missing-import] +from sphinx.util import ( # pyrefly: ignore[missing-import] docfields, docutils as sphinx_docutils, inspect, @@ -68,7 +76,7 @@ def _log_debug(message, *args): _logger.debug("%s" + message, _LOG_PREFIX, *args) -def _position_iter(values: Collection[_T]) -> tuple[bool, bool, _T]: +def _position_iter(values: Collection[_T]) -> Iterator[tuple[bool, bool, _T]]: last_i = len(values) - 1 for i, value in enumerate(values): yield i == 0, i == last_i, value @@ -133,9 +141,9 @@ def _index_node_tuple( entry_type: str, entry_name: str, target: str, - main: typing.Union[str, None] = None, - category_key: typing.Union[str, None] = None, -) -> tuple[str, str, str, typing.Union[str, None], typing.Union[str, None]]: + main: str | None = None, + category_key: str | None = None, +) -> tuple[str, str, str, str | None, str | None]: # For this tuple definition, see: # https://www.sphinx-doc.org/en/master/extdev/nodes.html#sphinx.addnodes.index # For the definition of entry_type, see: @@ -157,8 +165,8 @@ def __init__( *, repo: str, label: str, - namespace: str = None, - symbol: str = None, + namespace: str | None = None, + symbol: str | None = None, ): """Creates an instance. @@ -197,7 +205,11 @@ def __init__( @classmethod def from_env( - cls, env: environment.BuildEnvironment, *, symbol: str = None, label: str = None + cls, + env: environment.BuildEnvironment, + *, + symbol: str | None = None, + label: str | None = None, ) -> "_BzlObjectId": label = label or env.ref_context["bzl:file"] if symbol: @@ -250,7 +262,7 @@ class _TypeExprParser(ast.NodeVisitor): def __init__(self, make_xref: Callable[[str], docutils_nodes.Node]): self.root_node = addnodes.desc_inline("bzl", classes=["type-expr"]) self.make_xref = make_xref - self._doc_node_stack = [self.root_node] + self._doc_node_stack: list[docutils_nodes.Element] = [self.root_node] @classmethod def xrefs_from_type_expr( @@ -266,7 +278,7 @@ def xrefs_from_type_expr( def _append(self, node: docutils_nodes.Node): self._doc_node_stack[-1] += node - def _append_and_push(self, node: docutils_nodes.Node): + def _append_and_push(self, node: docutils_nodes.Element): self._append(node) self._doc_node_stack.append(node) @@ -339,17 +351,18 @@ def generic_visit(self, node): class _BzlXrefField(docfields.Field): """Abstract base class to create cross references for fields.""" + # docfields.Field lacks type stubs, so @override triggers bad-override. @override - def make_xrefs( + def make_xrefs( # pyrefly: ignore[bad-override] self, rolename: str, domain: str, target: str, innernode: type[sphinx_typing.TextlikeNode] = addnodes.literal_emphasis, - contnode: typing.Union[docutils_nodes.Node, None] = None, - env: typing.Union[environment.BuildEnvironment, None] = None, - inliner: typing.Union[states.Inliner, None] = None, - location: typing.Union[docutils_nodes.Element, None] = None, + contnode: docutils_nodes.Node | None = None, + env: environment.BuildEnvironment | None = None, + inliner: states.Inliner | None = None, + location: docutils_nodes.Node | tuple[str, int] | None = None, ) -> list[docutils_nodes.Node]: if rolename in ("arg", "attr"): return self._make_xrefs_for_arg_attr( @@ -366,11 +379,12 @@ def _make_xrefs_for_arg_attr( domain: str, arg_name: str, innernode: type[sphinx_typing.TextlikeNode] = addnodes.literal_emphasis, - contnode: typing.Union[docutils_nodes.Node, None] = None, - env: typing.Union[environment.BuildEnvironment, None] = None, - inliner: typing.Union[states.Inliner, None] = None, - location: typing.Union[docutils_nodes.Element, None] = None, + contnode: docutils_nodes.Node | None = None, + env: environment.BuildEnvironment | None = None, + inliner: states.Inliner | None = None, + location: docutils_nodes.Node | tuple[str, int] | None = None, ) -> list[docutils_nodes.Node]: + assert env is not None bzl_file = env.ref_context["bzl:file"] anchor_prefix = ".".join(env.ref_context["bzl:doc_id_stack"]) if not anchor_prefix: @@ -381,7 +395,8 @@ def _make_xrefs_for_arg_attr( anchor_id = f"{anchor_prefix}.{arg_name}" full_id = _full_id_from_env(env, [arg_name]) - env.get_domain(domain).add_object( + bzl_domain = cast(_BzlDomain, env.get_domain(domain)) + bzl_domain.add_object( _ObjectEntry( full_id=full_id, display_name=arg_name, @@ -454,10 +469,10 @@ def make_field( self, types: dict[str, list[docutils_nodes.Node]], domain: str, - item: tuple, - env: environment.BuildEnvironment = None, - inliner: typing.Union[states.Inliner, None] = None, - location: typing.Union[docutils_nodes.Element, None] = None, + item: tuple[str, list[docutils_nodes.Node]], + env: environment.BuildEnvironment | None = None, + inliner: states.Inliner | None = None, + location: docutils_nodes.Element | None = None, ) -> docutils_nodes.field: field_text = item[1][0].astext() parts = [p.strip() for p in field_text.split(",")] @@ -498,8 +513,9 @@ class _BzlCurrentFile(sphinx_docutils.SphinxDirective): required_arguments = 1 final_argument_whitespace = False + # SphinxDirective lacks type stubs, so @override triggers bad-override. @override - def run(self) -> list[docutils_nodes.Node]: + def run(self) -> list[docutils_nodes.Node]: # pyrefly: ignore[bad-override] label = self.arguments[0].strip() repo, slashes, file_label = label.partition("//") file_label = slashes + file_label @@ -528,7 +544,8 @@ def run(self) -> list[docutils_nodes.Node]: index_description = f"File {label}" absolute_label = repo + label - self.env.get_domain("bzl").add_object( + bzl_domain = cast(_BzlDomain, self.env.get_domain("bzl")) + bzl_domain.add_object( _ObjectEntry( full_id=absolute_label, display_name=absolute_label, @@ -602,18 +619,22 @@ class _BzlObject(sphinx_directives.ObjectDescription[_BzlObjectId]): "origin-key": docutils_directives.unchanged, } + # ObjectDescription lacks type stubs, so @override triggers bad-override. @override - def before_content(self) -> None: + def before_content(self) -> None: # pyrefly: ignore[bad-override] symbol_name = self.names[-1].symbol if symbol_name: self.env.ref_context["bzl:object_id_stack"].append(symbol_name) self.env.ref_context["bzl:doc_id_stack"].append(symbol_name) + # ObjectDescription lacks type stubs, so @override triggers bad-override. @override - def transform_content(self, content_node: addnodes.desc_content) -> None: + def transform_content( # pyrefly: ignore[bad-override] + self, contentnode: addnodes.desc_content + ) -> None: def first_child_with_class_name( root, class_name - ) -> typing.Union[None, docutils_nodes.Element]: + ) -> docutils_nodes.Element | None: matches = root.findall( lambda node: ( isinstance(node, docutils_nodes.Element) @@ -632,7 +653,7 @@ def match_arg_field_name(node): # fmt: on # Move the spans for the arg type and default value to be first. - arg_name_fields = list(content_node.findall(match_arg_field_name)) + arg_name_fields = list(contentnode.findall(match_arg_field_name)) for arg_name_field in arg_name_fields: arg_body_field = arg_name_field.next_node(descend=False, siblings=True) # arg_type_node = first_child_with_class_name(arg_body_field, "arg-type-span") @@ -647,10 +668,12 @@ def match_arg_field_name(node): # doc text) if arg_default_node: + assert arg_default_node.parent is not None arg_default_node.parent.remove(arg_default_node) arg_body_field.insert(0, arg_default_node) if arg_type_node: + assert arg_type_node.parent is not None arg_type_node.parent.remove(arg_type_node) decorated_arg_type_node = docutils_nodes.inline( "", @@ -663,21 +686,23 @@ def match_arg_field_name(node): # arg_body_field.insert(0, arg_type_node) arg_body_field.insert(0, decorated_arg_type_node) + # ObjectDescription lacks type stubs, so @override triggers bad-override. @override - def after_content(self) -> None: + def after_content(self) -> None: # pyrefly: ignore[bad-override] if self.names[-1].symbol: self.env.ref_context["bzl:object_id_stack"].pop() self.env.ref_context["bzl:doc_id_stack"].pop() # docs on how to build signatures: # https://www.sphinx-doc.org/en/master/extdev/nodes.html#sphinx.addnodes.desc_signature + # ObjectDescription lacks type stubs, so @override triggers bad-override. @override - def handle_signature( - self, sig_text: str, sig_node: addnodes.desc_signature + def handle_signature( # pyrefly: ignore[bad-override] + self, sig: str, signode: addnodes.desc_signature ) -> _BzlObjectId: - self._signature_add_object_type(sig_node) + self._signature_add_object_type(signode) - relative_name, lparen, params_text = sig_text.partition("(") + relative_name, lparen, params_text = sig.partition("(") if lparen: params_text = lparen + params_text @@ -696,8 +721,8 @@ def handle_signature( if display_prefix: display_prefix = display_prefix + "." - sig_node += addnodes.desc_addname(display_prefix, display_prefix) - sig_node += addnodes.desc_name(base_symbol_name, base_symbol_name) + signode += addnodes.desc_addname(display_prefix, display_prefix) + signode += addnodes.desc_name(base_symbol_name, base_symbol_name) if type_expr := self.options.get("type"): @@ -718,7 +743,7 @@ def make_xref(name, title=None): addnodes.desc_sig_space(), _TypeExprParser.xrefs_from_type_expr(type_expr, make_xref), ) - sig_node += attr_annotation_node + signode += attr_annotation_node if params_text: try: @@ -728,7 +753,7 @@ def make_xref(name, title=None): # signature might not be valid syntax. Rather than fail, just # provide a plain-text description of the approximate signature. # See https://github.com/bazelbuild/stardoc/issues/225 - sig_node += addnodes.desc_parameterlist( + signode += addnodes.desc_parameterlist( # Offset by 1 to remove the surrounding parentheses params_text[1:-1], params_text[1:-1], @@ -764,14 +789,14 @@ def make_xref(name, title=None): support_smartquotes=False, ) paramlist_node += node - sig_node += paramlist_node + signode += paramlist_node if signature.return_annotation is not signature.empty: - sig_node += addnodes.desc_returns("", signature.return_annotation) + signode += addnodes.desc_returns("", signature.return_annotation) obj_id = _BzlObjectId.from_env(self.env, symbol=relative_name) - sig_node["bzl:object_id"] = obj_id.full_id + signode["bzl:object_id"] = obj_id.full_id return obj_id def _signature_add_object_type(self, sig_node: addnodes.desc_signature): @@ -779,27 +804,28 @@ def _signature_add_object_type(self, sig_node: addnodes.desc_signature): sig_node += addnodes.desc_annotation("", self._get_signature_object_type()) sig_node += addnodes.desc_sig_space() + # ObjectDescription lacks type stubs, so @override triggers bad-override. @override - def add_target_and_index( - self, obj_desc: _BzlObjectId, sig: str, sig_node: addnodes.desc_signature + def add_target_and_index( # pyrefly: ignore[bad-override] + self, name: _BzlObjectId, sig: str, signode: addnodes.desc_signature ) -> None: - super().add_target_and_index(obj_desc, sig, sig_node) - if obj_desc.symbol: - display_name = obj_desc.symbol - location = obj_desc.label - if obj_desc.namespace: - location += f"%{obj_desc.namespace}" + super().add_target_and_index(name, sig, signode) + if name.symbol: + display_name = name.symbol + location = name.label + if name.namespace: + location += f"%{name.namespace}" else: - display_name = obj_desc.target_name - location = obj_desc.package + display_name = name.target_name + location = name.package anchor_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"]) if anchor_prefix: - anchor_id = f"{anchor_prefix}.{obj_desc.doc_id}" + anchor_id = f"{anchor_prefix}.{name.doc_id}" else: - anchor_id = obj_desc.doc_id + anchor_id = name.doc_id - sig_node["ids"].append(anchor_id) + signode["ids"].append(anchor_id) object_type_display = self._get_object_type_display_name() index_description = f"{display_name} ({object_type_display} in {location})" @@ -812,7 +838,7 @@ def add_target_and_index( ) object_entry = _ObjectEntry( - full_id=obj_desc.full_id, + full_id=name.full_id, display_name=display_name, object_type=self.objtype, search_priority=1, @@ -838,29 +864,38 @@ def add_target_and_index( extra_alt_names = self._get_alt_names(object_entry) alt_names.extend(extra_alt_names) - self.env.get_domain(self.domain).add_object(object_entry, alt_names=alt_names) + domain = self._get_bzl_domain() + domain.add_object(object_entry, alt_names=alt_names) + + def _get_bzl_domain(self) -> _BzlDomain: + domain_name = self.domain or "bzl" + return cast(_BzlDomain, self.env.get_domain(domain_name)) def _get_additional_index_types(self): return [] + # ObjectDescription lacks type stubs, so @override triggers bad-override. @override - def _object_hierarchy_parts( + def _object_hierarchy_parts( # pyrefly: ignore[bad-override] self, sig_node: addnodes.desc_signature ) -> tuple[str, ...]: return _parse_full_id(sig_node["bzl:object_id"]) + # ObjectDescription lacks type stubs, so @override triggers bad-override. @override - def _toc_entry_name(self, sig_node: addnodes.desc_signature) -> str: + def _toc_entry_name( # pyrefly: ignore[bad-override] + self, sig_node: addnodes.desc_signature + ) -> str: return sig_node["_toc_parts"][-1] def _get_object_type_display_name(self) -> str: - return self.env.get_domain(self.domain).object_types[self.objtype].lname + return self._get_bzl_domain().object_types[self.objtype].lname def _get_signature_object_type(self) -> str: return self._get_object_type_display_name() - def _get_alt_names(self, object_entry): - alt_names = [] + def _get_alt_names(self, object_entry: _ObjectEntry) -> list[str]: + alt_names: list[str] = [] full_id = object_entry.full_id label, _, symbol = full_id.partition("%") if symbol: @@ -947,7 +982,7 @@ def _get_signature_object_type(self) -> str: return "" @override - def _get_alt_names(self, object_entry): + def _get_alt_names(self, object_entry: _ObjectEntry) -> list[str]: alt_names = super()._get_alt_names(object_entry) _, _, symbol = object_entry.full_id.partition("%") # Allow refering to `mod_ext_name.tag_name`, even if the extension @@ -1230,7 +1265,7 @@ def _get_signature_object_type(self) -> str: return "" @override - def _get_alt_names(self, object_entry): + def _get_alt_names(self, object_entry: _ObjectEntry) -> list[str]: alt_names = super()._get_alt_names(object_entry) _, _, symbol = object_entry.full_id.partition("%") # Allow refering to `ProviderName.field`, even if the provider @@ -1249,23 +1284,26 @@ class _BzlTarget(_BzlObject): _TARGET_TYPE = _TargetType.TARGET - def handle_signature(self, sig_text, sig_node): - self._signature_add_object_type(sig_node) - if ":" in sig_text: - package, target_name = sig_text.split(":", 1) + @override + def handle_signature( + self, sig: str, signode: addnodes.desc_signature + ) -> _BzlObjectId: + self._signature_add_object_type(signode) + if ":" in sig: + package, target_name = sig.split(":", 1) else: - target_name = sig_text + target_name = sig package = self.env.ref_context["bzl:file"] package = package[: package.find(":BUILD")] package = package + ":" if self._TARGET_TYPE == _TargetType.FLAG: - sig_node += addnodes.desc_addname("--", "--") - sig_node += addnodes.desc_addname(package, package) - sig_node += addnodes.desc_name(target_name, target_name) + signode += addnodes.desc_addname("--", "--") + signode += addnodes.desc_addname(package, package) + signode += addnodes.desc_name(target_name, target_name) obj_id = _BzlObjectId.from_env(self.env, label=package + target_name) - sig_node["bzl:object_id"] = obj_id.full_id + signode["bzl:object_id"] = obj_id.full_id return obj_id @override @@ -1286,6 +1324,7 @@ class _BzlFlag(_BzlTarget): def _get_signature_object_type(self) -> str: return "flag" + @override def _get_additional_index_types(self): return ["target"] @@ -1427,7 +1466,7 @@ class _BzlIndex(domains.Index): shortname = "Bzl" def generate( - self, docnames: Iterable[str] = None + self, docnames: Iterable[str] | None = None ) -> tuple[list[tuple[str, list[domains.IndexEntry]]], bool]: content = collections.defaultdict(list) @@ -1607,22 +1646,26 @@ class _BzlDomain(domains.Domain): "alt_names": {}, } + # domains.Domain lacks type stubs, so @override triggers bad-override. @override - def get_full_qualified_name( + def get_full_qualified_name( # pyrefly: ignore[bad-override] self, node: docutils_nodes.Element - ) -> typing.Union[str, None]: + ) -> str | None: bzl_file = node.get("bzl:file") symbol_name = node.get("bzl:symbol") ref_target = node.get("reftarget") return ".".join(filter(None, [bzl_file, symbol_name, ref_target])) + # domains.Domain lacks type stubs, so @override triggers bad-override. @override - def get_objects(self) -> Iterable[_GetObjectsTuple]: - for entry in self.data["objects"].values(): + def get_objects(self) -> Iterable[_GetObjectsTuple]: # pyrefly: ignore[bad-override] + objects: dict[str, _ObjectEntry] = self.data["objects"] + for entry in objects.values(): yield entry.to_get_objects_tuple() + # domains.Domain lacks type stubs, so @override triggers bad-override. @override - def resolve_any_xref( + def resolve_any_xref( # pyrefly: ignore[bad-override] self, env: environment.BuildEnvironment, fromdocname: str, @@ -1644,8 +1687,9 @@ def resolve_any_xref( matches = [(f"bzl:{entry.object_type}", ref_node)] return matches + # domains.Domain lacks type stubs, so @override triggers bad-override. @override - def resolve_xref( + def resolve_xref( # pyrefly: ignore[bad-override] self, env: environment.BuildEnvironment, fromdocname: str, @@ -1654,7 +1698,7 @@ def resolve_xref( target: str, node: addnodes.pending_xref, contnode: docutils_nodes.Element, - ) -> typing.Union[docutils_nodes.Element, None]: + ) -> docutils_nodes.Element | None: _log_debug( "resolve_xref: fromdocname=%s, typ=%s, target=%s", fromdocname, typ, target ) @@ -1671,7 +1715,7 @@ def resolve_xref( def _find_entry_for_xref( self, fromdocname: str, object_type: str, target: str - ) -> typing.Union[_ObjectEntry, None]: + ) -> _ObjectEntry | None: if target.startswith("--"): target = target.strip("-") object_type = "flag" @@ -1742,8 +1786,7 @@ def add_object(self, entry: _ObjectEntry, alt_names=None) -> None: else: base_name = label.split(":")[-1] - if alt_names is not None: - alt_names = list(alt_names) + alt_names = list(alt_names) if alt_names else [] # Add the repo-less version as an alias alt_names.append(label + (f"%{symbol}" if symbol else "")) @@ -1755,8 +1798,9 @@ def add_object(self, entry: _ObjectEntry, alt_names=None) -> None: self.data["doc_names"].setdefault(docname, {}) self.data["doc_names"][docname][base_name] = entry + # domains.Domain lacks type stubs, so @override triggers bad-override. @override - def clear_doc(self, docname: str) -> None: + def clear_doc(self, docname: str) -> None: # pyrefly: ignore[bad-override] if docname not in self.data["doc_names"]: return for base_name, entry in self.data["doc_names"][docname].items(): @@ -1776,9 +1820,7 @@ def clear_doc(self, docname: str) -> None: del self.data["alt_names"][alt_name] del self.data["doc_names"][docname] - def merge_domaindata( - self, docnames: list[str], otherdata: dict[str, typing.Any] - ) -> None: + def merge_domaindata(self, docnames: Set[str], otherdata: dict[str, Any]) -> None: # Merge in simple dict[key, value] data for top_key in ("objects",): self.data[top_key].update(otherdata.get(top_key, {})) @@ -1828,7 +1870,9 @@ def _on_missing_reference(app, env: environment.BuildEnvironment, node, contnode if new_target != original_target: # Access the intersphinx extension's internal mapping # we try to resolve the reference again with the stripped name - from sphinx.ext.intersphinx import missing_reference + from sphinx.ext.intersphinx import ( # pyrefly: ignore[missing-import] + missing_reference, + ) node["reftarget"] = new_target return missing_reference(app, env, node, contnode) diff --git a/sphinxdocs/tests/proto_to_markdown/BUILD.bazel b/sphinxdocs/tests/proto_to_markdown/BUILD.bazel index 632d6d946f..e1c358773c 100644 --- a/sphinxdocs/tests/proto_to_markdown/BUILD.bazel +++ b/sphinxdocs/tests/proto_to_markdown/BUILD.bazel @@ -19,6 +19,6 @@ py_test( srcs = ["proto_to_markdown_test.py"], deps = [ "//sphinxdocs/private:proto_to_markdown_lib", - "@dev_pip//absl_py", + "@pypi//absl_py", ], ) diff --git a/sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py b/sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py index d88d2bf127..753e8f7659 100644 --- a/sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py +++ b/sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py @@ -15,9 +15,13 @@ import io from absl.testing import absltest -from google.protobuf import text_format +from google.protobuf import ( # pyrefly: ignore[missing-source-for-stubs] + text_format, +) from sphinxdocs.private import proto_to_markdown -from stardoc.proto import stardoc_output_pb2 +from stardoc.proto import ( # pyrefly: ignore[missing-import] + stardoc_output_pb2, +) _EVERYTHING_MODULE = """\ module_docstring: "MODULE_DOC_STRING" diff --git a/sphinxdocs/tests/sphinx_build/BUILD.bazel b/sphinxdocs/tests/sphinx_build/BUILD.bazel index b9e77220df..ec0878d862 100644 --- a/sphinxdocs/tests/sphinx_build/BUILD.bazel +++ b/sphinxdocs/tests/sphinx_build/BUILD.bazel @@ -5,7 +5,7 @@ py_test( srcs = ["directory_syncer_test.py"], deps = [ "//sphinxdocs/private:sphinx_build_lib", - "@dev_pip//absl_py", - "@dev_pip//sphinx", + "@pypi//absl_py", + "@pypi//sphinx", ], ) diff --git a/sphinxdocs/tests/sphinx_docs/BUILD.bazel b/sphinxdocs/tests/sphinx_docs/BUILD.bazel index 4bbaf90691..71bc1f3d79 100644 --- a/sphinxdocs/tests/sphinx_docs/BUILD.bazel +++ b/sphinxdocs/tests/sphinx_docs/BUILD.bazel @@ -44,8 +44,8 @@ sphinx_build_binary( name = "sphinx-build", tags = ["manual"], # Only needed as part of sphinx doc building deps = [ - "@dev_pip//myst_parser", - "@dev_pip//sphinx", + "@pypi//myst_parser", + "@pypi//sphinx", ], ) @@ -58,5 +58,5 @@ py_test( name = "sphinx_docs_output_test", srcs = ["sphinx_docs_output_test.py"], data = [":docs"], - deps = ["@dev_pip//absl_py"], + deps = ["@pypi//absl_py"], ) diff --git a/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel index eecbb90897..78f7d5e4bc 100644 --- a/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel +++ b/sphinxdocs/tests/sphinx_docs_conf_in_other_dir/BUILD.bazel @@ -27,8 +27,8 @@ sphinx_build_binary( name = "sphinx-build", tags = ["manual"], deps = [ - "@dev_pip//myst_parser", - "@dev_pip//sphinx", + "@pypi//myst_parser", + "@pypi//sphinx", ], ) diff --git a/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel index 2cbc773f77..ffc9697e7f 100644 --- a/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel +++ b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel @@ -94,9 +94,9 @@ sphinx_build_binary( tags = ["manual"], # Only needed as part of sphinx doc building deps = [ "//sphinxdocs/src/sphinx_bzl", - "@dev_pip//myst_parser", - "@dev_pip//sphinx", - "@dev_pip//typing_extensions", # Needed by sphinx_stardoc + "@pypi//myst_parser", + "@pypi//sphinx", + "@pypi//typing_extensions", # Needed by sphinx_stardoc ], ) @@ -104,5 +104,5 @@ py_test( name = "sphinx_output_test", srcs = ["sphinx_output_test.py"], data = [":docs"], - deps = ["@dev_pip//absl_py"], + deps = ["@pypi//absl_py"], ) diff --git a/sphinxdocs/tests/support/pyrefly/BUILD.bazel b/sphinxdocs/tests/support/pyrefly/BUILD.bazel new file mode 100644 index 0000000000..447af06f8f --- /dev/null +++ b/sphinxdocs/tests/support/pyrefly/BUILD.bazel @@ -0,0 +1,8 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") + +package(default_visibility = ["//:__subpackages__"]) + +bzl_library( + name = "pyrefly", + srcs = ["pyrefly.bzl"], +) diff --git a/sphinxdocs/tests/support/pyrefly/pyrefly.bzl b/sphinxdocs/tests/support/pyrefly/pyrefly.bzl new file mode 100644 index 0000000000..ac0d7ea331 --- /dev/null +++ b/sphinxdocs/tests/support/pyrefly/pyrefly.bzl @@ -0,0 +1,5 @@ +"""Aspect definition for Pyrefly static type checking.""" + +load("@rules_pyrefly//pyrefly:pyrefly.bzl", "pyrefly") + +pyrefly_aspect = pyrefly() diff --git a/tests/bootstrap_impls/bazel_tools_importable_test.py b/tests/bootstrap_impls/bazel_tools_importable_test.py index c374dd5dcf..7445100dda 100644 --- a/tests/bootstrap_impls/bazel_tools_importable_test.py +++ b/tests/bootstrap_impls/bazel_tools_importable_test.py @@ -5,9 +5,9 @@ class BazelToolsImportableTest(unittest.TestCase): def test_bazel_tools_importable(self): try: - import bazel_tools - import bazel_tools.tools.python - import bazel_tools.tools.python.runfiles # noqa: F401 + import bazel_tools # pyrefly: ignore[missing-import] + import bazel_tools.tools.python # pyrefly: ignore[missing-import] + import bazel_tools.tools.python.runfiles # pyrefly: ignore[missing-import] # noqa: F401 except ImportError as exc: raise AssertionError( "Failed to import bazel_tools.python.runfiles\n" diff --git a/tests/bootstrap_impls/bin.py b/tests/bootstrap_impls/bin.py index 3d467dcf29..0713b5f1be 100644 --- a/tests/bootstrap_impls/bin.py +++ b/tests/bootstrap_impls/bin.py @@ -23,4 +23,4 @@ print("sys.flags.safe_path:", sys.flags.safe_path) print("file:", __file__) print("sys.executable:", sys.executable) -print("sys._base_executable:", sys._base_executable) +print("sys._base_executable:", getattr(sys, "_base_executable", None)) diff --git a/tests/bootstrap_impls/sys_path_order_test.py b/tests/bootstrap_impls/sys_path_order_test.py index a9018c39ce..d55a93528b 100644 --- a/tests/bootstrap_impls/sys_path_order_test.py +++ b/tests/bootstrap_impls/sys_path_order_test.py @@ -67,7 +67,7 @@ def test_sys_path_order(self): f"{i}: ({category}) {value}" for i, (category, value) in enumerate(categorized_paths) ) - if None in (last_stdlib, first_user, first_runtime_site): + if last_stdlib is None or first_user is None or first_runtime_site is None: self.fail( "Failed to find position for one of:\n" + f"{last_stdlib=} {first_user=} {first_runtime_site=}\n" diff --git a/tests/build_data/build_data_test.py b/tests/build_data/build_data_test.py index 6be4e52c84..69f2e48e33 100644 --- a/tests/build_data/build_data_test.py +++ b/tests/build_data/build_data_test.py @@ -5,7 +5,7 @@ class BuildDataTest(unittest.TestCase): def test_target_build_data(self): - import bazel_binary_info + import bazel_binary_info # pyrefly: ignore[missing-import] self.assertIn("build_data.txt", bazel_binary_info.BUILD_DATA_FILE) @@ -19,7 +19,9 @@ def test_target_build_data(self): def test_tool_build_data(self): rf = runfiles.Create() + assert rf is not None # type assert path = rf.Rlocation("rules_python/tests/build_data/tool_build_data.txt") + assert path is not None # type assert with open(path) as fp: build_data = fp.read() diff --git a/tests/build_data/print_build_data.py b/tests/build_data/print_build_data.py index 0af77d72be..54d2d45361 100644 --- a/tests/build_data/print_build_data.py +++ b/tests/build_data/print_build_data.py @@ -1,3 +1,3 @@ -import bazel_binary_info +import bazel_binary_info # pyrefly: ignore[missing-import] print(bazel_binary_info.get_build_data()) diff --git a/tests/cc/current_py_cc_headers/abi3_headers_linkage_test.py b/tests/cc/current_py_cc_headers/abi3_headers_linkage_test.py index 2d64828278..1eb229d29c 100644 --- a/tests/cc/current_py_cc_headers/abi3_headers_linkage_test.py +++ b/tests/cc/current_py_cc_headers/abi3_headers_linkage_test.py @@ -10,9 +10,11 @@ class CheckLinkageTest(unittest.TestCase): @unittest.skipUnless(sys.platform.startswith("win"), "requires windows") def test_linkage_windows(self): rf = runfiles.Create() + assert rf is not None # type assert dll_path = rf.Rlocation( "rules_python/tests/cc/current_py_cc_headers/bin_abi3.dll" ) + assert dll_path is not None # type assert pe = pefile.PE(dll_path) if not hasattr(pe, "DIRECTORY_ENTRY_IMPORT"): self.fail("No import directory found.") diff --git a/tests/cc/py_extension/py_extension_pkg_test.py b/tests/cc/py_extension/py_extension_pkg_test.py index e3176d6a6c..68c6dc3ee6 100644 --- a/tests/cc/py_extension/py_extension_pkg_test.py +++ b/tests/cc/py_extension/py_extension_pkg_test.py @@ -1,6 +1,8 @@ import unittest -from tests.cc.py_extension import ext_pkg_test +from tests.cc.py_extension import ( + ext_pkg_test, # pyrefly: ignore[missing-module-attribute] +) class PyExtensionPkgTest(unittest.TestCase): @@ -9,7 +11,7 @@ def test_import_via_package(self): def test_direct_import(self): with self.assertRaises(ModuleNotFoundError): - import ext_pkg_test # buildifier: disable=g-import-not-at-top # noqa: F401 + import ext_pkg_test # pyrefly: ignore[missing-import] # noqa: F401 if __name__ == "__main__": diff --git a/tests/cc/py_extension/py_extension_test.py b/tests/cc/py_extension/py_extension_test.py index d82fe22bcc..7ffcdd3e66 100644 --- a/tests/cc/py_extension/py_extension_test.py +++ b/tests/cc/py_extension/py_extension_test.py @@ -2,7 +2,7 @@ import sys import unittest -import ext_shared +import ext_shared # pyrefly: ignore[missing-import] from elftools.elf.dynamic import DynamicSection from elftools.elf.elffile import ELFFile @@ -26,9 +26,9 @@ def test_inspect_elf(self): self.assertTrue(isinstance(dynamic_section, DynamicSection)) needed_libs = [ - tag.needed + tag.needed # pyrefly: ignore[missing-attribute] for tag in dynamic_section.iter_tags() - if tag.entry.d_tag == "DT_NEEDED" + if tag.entry.d_tag == "DT_NEEDED" # pyrefly: ignore[missing-attribute] ] self.assertIn("libadd_one_shared.so", needed_libs) diff --git a/tests/entry_points/py_console_script_gen_test.py b/tests/entry_points/py_console_script_gen_test.py index 92fa42f167..54e86bb671 100644 --- a/tests/entry_points/py_console_script_gen_test.py +++ b/tests/entry_points/py_console_script_gen_test.py @@ -162,7 +162,7 @@ def test_a_single_entry_point(self): raise if __name__ == "__main__": - sys.exit(baz()) # type: ignore + sys.exit(baz()) # pyrefly: ignore[not-callable] """ ) self.assertEqual(want, got) diff --git a/tests/integration/runner.py b/tests/integration/runner.py index c187623b3c..9efcbebb89 100644 --- a/tests/integration/runner.py +++ b/tests/integration/runner.py @@ -103,19 +103,19 @@ def run_bazel(self, *args: str, check: bool = True) -> ExecuteResult: Returns: An `ExecuteResult` from running Bazel """ - args = [str(self.bazel), *args] + cmd_args = [str(self.bazel), *args] env = self.bazel_env - _logger.info("executing: %s", shlex.join(args)) + _logger.info("executing: %s", shlex.join(cmd_args)) cwd = self.repo_root proc_result = subprocess.run( - args=args, + args=cmd_args, text=True, capture_output=True, cwd=cwd, env=env, check=False, ) - exec_result = ExecuteResult(args, env, cwd, proc_result) + exec_result = ExecuteResult(cmd_args, env, cwd, proc_result) if check and exec_result.exit_code: raise ExecuteError(exec_result) else: diff --git a/tests/integration/uv_lock_pypi_server.py b/tests/integration/uv_lock_pypi_server.py index 0d940e7569..1f350b809f 100644 --- a/tests/integration/uv_lock_pypi_server.py +++ b/tests/integration/uv_lock_pypi_server.py @@ -118,7 +118,7 @@ def main(): app = app_from_config(config) app = setup_routes_from_config(app, config) - server = make_server(args.host, args.port, app) + server = make_server(args.host, args.port, app) # pyrefly: ignore[bad-argument-type] port = server.server_address[1] base_url = "http://{}:{}".format(args.host, port) diff --git a/tests/multi_pypi/pypi_alpha/pypi_alpha_test.py b/tests/multi_pypi/pypi_alpha/pypi_alpha_test.py index 0521327563..ff0561a6f4 100644 --- a/tests/multi_pypi/pypi_alpha/pypi_alpha_test.py +++ b/tests/multi_pypi/pypi_alpha/pypi_alpha_test.py @@ -1,6 +1,8 @@ import sys -from more_itertools import __version__ +from more_itertools import ( + __version__, # pyrefly: ignore[missing-module-attribute] +) if __name__ == "__main__": expected_version = "9.1.0" diff --git a/tests/multi_pypi/pypi_beta/pypi_beta_test.py b/tests/multi_pypi/pypi_beta/pypi_beta_test.py index 8c34de0735..bbb50dd8a8 100644 --- a/tests/multi_pypi/pypi_beta/pypi_beta_test.py +++ b/tests/multi_pypi/pypi_beta/pypi_beta_test.py @@ -1,6 +1,8 @@ import sys -from more_itertools import __version__ +from more_itertools import ( + __version__, # pyrefly: ignore[missing-module-attribute] +) if __name__ == "__main__": expected_version = "9.0.0" diff --git a/tests/news/news_test.py b/tests/news/news_test.py index a8ed7a2849..66476145a2 100644 --- a/tests/news/news_test.py +++ b/tests/news/news_test.py @@ -6,6 +6,7 @@ def _get_news_dir(): rf = runfiles.Create() + assert rf is not None # type assert path = rf.Rlocation("rules_python/news") if path: return pathlib.Path(path) diff --git a/tests/py_zipapp/BUILD.bazel b/tests/py_zipapp/BUILD.bazel index a68448d964..e0f878cbe1 100644 --- a/tests/py_zipapp/BUILD.bazel +++ b/tests/py_zipapp/BUILD.bazel @@ -17,6 +17,8 @@ py_binary( }, }), main = "main.py", + # Pyrefly's bazel-check validator rejects explicit import paths with '.' components from :bin_deps. + tags = ["no-pyrefly"], deps = [":bin_deps"], ) @@ -62,6 +64,8 @@ py_binary( "//python/config_settings:venvs_site_packages": "no", }, main = "main.py", + # Pyrefly's bazel-check validator rejects explicit import paths with '.' components from :bin_deps. + tags = ["no-pyrefly"], deps = [":bin_deps"], ) @@ -106,6 +110,8 @@ py_library( srcs = ["some_dep.py"], experimental_venvs_site_packages = "//python/config_settings:venvs_site_packages", imports = ["."], + # Pyrefly's bazel-check validator rejects explicit import paths with '.' components. + tags = ["no-pyrefly"], ) py_library( diff --git a/tests/repl/BUILD.bazel b/tests/repl/BUILD.bazel index b3986cc023..8fc239a06a 100644 --- a/tests/repl/BUILD.bazel +++ b/tests/repl/BUILD.bazel @@ -26,6 +26,7 @@ py_reconfig_test( }, main = "repl_test.py", python_version = "3.12", + deps = ["//python/runfiles"], ) py_reconfig_test( @@ -41,4 +42,5 @@ py_reconfig_test( main = "repl_test.py", python_version = "3.12", repl_dep = ":helper/test_module", + deps = ["//python/runfiles"], ) diff --git a/tests/repl/repl_test.py b/tests/repl/repl_test.py index 2b3d5c7a4d..76b407b49e 100644 --- a/tests/repl/repl_test.py +++ b/tests/repl/repl_test.py @@ -3,12 +3,12 @@ import sys # noqa: F401 import tempfile import unittest +from collections.abc import Iterable from pathlib import Path -from typing import Iterable -from python import runfiles +from python.runfiles import runfiles -rfiles = runfiles.Create() +rfiles = runfiles.CreateOrRaise() # Signals the tests below whether we should be expecting the import of # helpers/test_module.py on the REPL to work or not. @@ -29,10 +29,11 @@ def setUp(self): rpath = "rules_python/python/bin/repl" if IS_WINDOWS: rpath += ".exe" - self.repl = rfiles.Rlocation(rpath) - assert self.repl + repl = rfiles.Rlocation(rpath) + assert repl is not None, f"Could not find {rpath}" # type assert if IS_WINDOWS: - self.repl = os.path.normpath(self.repl) + repl = os.path.normpath(repl) + self.repl: str = repl def run_code_in_repl(self, lines: Iterable[str], *, env=None) -> str: """Runs the lines of code in the REPL and returns the text output.""" @@ -89,7 +90,7 @@ def test_repl_version(self): def test_cannot_import_test_module_directly(self): """Validates that we cannot import helper/test_module.py since it's not a direct dep.""" with self.assertRaises(ModuleNotFoundError): - import test_module # noqa: F401 + import test_module # pyrefly: ignore[missing-import] # noqa: F401 @unittest.skipIf( not EXPECT_TEST_MODULE_IMPORTABLE, "test only works without repl_dep set" diff --git a/tests/runfiles/pathlib_test.py b/tests/runfiles/pathlib_test.py index a959138235..5aefc4f4d3 100644 --- a/tests/runfiles/pathlib_test.py +++ b/tests/runfiles/pathlib_test.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import pathlib import tempfile @@ -25,7 +27,7 @@ def setUp(self) -> None: def _create_runfiles(self) -> runfiles.Runfiles: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) - assert r is not None + assert r is not None # type assert return r def tearDown(self) -> None: diff --git a/tests/runfiles/runfiles_test.py b/tests/runfiles/runfiles_test.py index 38a89ede7e..ce74a3d4ac 100644 --- a/tests/runfiles/runfiles_test.py +++ b/tests/runfiles/runfiles_test.py @@ -12,12 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import json import os import pathlib import tempfile import unittest -from typing import Any, List, Optional +from typing import Any from python.runfiles import runfiles from python.runfiles.runfiles import _RepositoryMapping @@ -29,9 +31,9 @@ class RunfilesTest(unittest.TestCase): def testRlocationArgumentValidation(self) -> None: r = runfiles.Create({"RUNFILES_DIR": "whatever"}) assert r is not None # mypy doesn't understand the unittest api. - self.assertRaises(ValueError, lambda: r.Rlocation(None)) # type: ignore + self.assertRaises(ValueError, lambda: r.Rlocation(None)) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] self.assertRaises(ValueError, lambda: r.Rlocation("")) - self.assertRaises(TypeError, lambda: r.Rlocation(1)) # type: ignore + self.assertRaises(TypeError, lambda: r.Rlocation(1)) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] self.assertRaisesRegex( ValueError, "is not normalized", lambda: r.Rlocation("../foo") ) @@ -71,7 +73,7 @@ def testRlocationWithData(self) -> None: settings_path = r.Rlocation( "rules_python/tests/support/current_build_settings.json" ) - assert settings_path is not None + assert settings_path is not None # type assert settings = json.loads(pathlib.Path(settings_path).read_text()) self.assertIn("bootstrap_impl", settings) @@ -771,11 +773,11 @@ def IsWindows() -> bool: class _MockFile: def __init__( - self, name: Optional[str] = None, contents: Optional[List[Any]] = None + self, name: str | None = None, contents: list[Any] | None = None ) -> None: self._contents = contents or [] self._name = name or "x" - self._path: Optional[str] = None + self._path: str | None = None def __enter__(self) -> Any: tmpdir = os.environ.get("TEST_TMPDIR") @@ -795,7 +797,7 @@ def __exit__( os.rmdir(os.path.dirname(self._path)) def Path(self) -> str: - assert self._path is not None + assert self._path is not None # type assert return self._path diff --git a/tests/runtime_env_toolchain/toolchain_runs_test.py b/tests/runtime_env_toolchain/toolchain_runs_test.py index 13b5775ff0..f3dcee3786 100644 --- a/tests/runtime_env_toolchain/toolchain_runs_test.py +++ b/tests/runtime_env_toolchain/toolchain_runs_test.py @@ -1,5 +1,4 @@ import json -import pathlib import platform import sys import unittest @@ -9,11 +8,11 @@ class RunTest(unittest.TestCase): def test_ran(self): - rf = runfiles.Create() - settings_path = rf.Rlocation( - "rules_python/tests/support/current_build_settings.json" + rf = runfiles.CreateOrRaise() + settings_path = ( + rf.root() / "rules_python/tests/support/current_build_settings.json" ) - settings = json.loads(pathlib.Path(settings_path).read_text()) + settings = json.loads(settings_path.read_text()) if platform.system() == "Windows": self.assertEqual( diff --git a/tests/support/pyrefly/pyrefly.bzl b/tests/support/pyrefly/pyrefly.bzl index bcb791403f..ac0d7ea331 100644 --- a/tests/support/pyrefly/pyrefly.bzl +++ b/tests/support/pyrefly/pyrefly.bzl @@ -2,6 +2,4 @@ load("@rules_pyrefly//pyrefly:pyrefly.bzl", "pyrefly") -pyrefly_aspect = pyrefly( - opt_in_tags = ["pyrefly"], -) +pyrefly_aspect = pyrefly() diff --git a/tests/support/pytest_test/pytest_bootstrap_template.py b/tests/support/pytest_test/pytest_bootstrap_template.py index 9769531f47..2587353306 100644 --- a/tests/support/pytest_test/pytest_bootstrap_template.py +++ b/tests/support/pytest_test/pytest_bootstrap_template.py @@ -1,6 +1,6 @@ import sys -import pytest_bazel +import pytest_bazel # pyrefly: ignore[missing-import] TEST_FILES = """%TEST_FILES%""".splitlines() diff --git a/tests/toolchains/python_toolchain_test.py b/tests/toolchains/python_toolchain_test.py index ff45fc0863..dcd2438cd7 100644 --- a/tests/toolchains/python_toolchain_test.py +++ b/tests/toolchains/python_toolchain_test.py @@ -13,9 +13,11 @@ def test_expected_toolchain_matches(self): expect_version = os.environ["EXPECT_PYTHON_VERSION"] rf = runfiles.Create() + assert rf is not None # type assert settings_path = rf.Rlocation( "rules_python/tests/support/current_build_settings.json" ) + assert settings_path is not None # type assert settings = json.loads(pathlib.Path(settings_path).read_text()) expected = "python_{}".format(expect_version.replace(".", "_")) diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index dcef2ab53a..dc02394960 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -16,11 +16,13 @@ py_library( py_library( name = "release_test_helper", + testonly = True, srcs = ["release_test_helper.py"], target_compatible_with = NOT_WINDOWS, deps = [ "//tools/private/release:mock_gh", "//tools/private/release:release_lib", + "@pypi//pytest", ], ) diff --git a/tests/tools/private/release/git_test.py b/tests/tools/private/release/git_test.py index 4a8cada4cc..e39787f187 100644 --- a/tests/tools/private/release/git_test.py +++ b/tests/tools/private/release/git_test.py @@ -10,7 +10,7 @@ @pytest.fixture(name="git_obj") def fixture_git_obj(mocker): git = Git(".") - git.mock_run_git = mocker.patch.object(git, "_run_git") + git.mock_run_git = mocker.patch.object(git, "_run_git") # pyrefly: ignore[missing-attribute] return git diff --git a/tests/uv/lock/lock_run_test.py b/tests/uv/lock/lock_run_test.py index 6de5a96378..2de9147d99 100644 --- a/tests/uv/lock/lock_run_test.py +++ b/tests/uv/lock/lock_run_test.py @@ -7,6 +7,7 @@ from python import runfiles rfiles = runfiles.Create() +assert rfiles is not None, "Failed to create runfiles" def _relative_rpath(path: str) -> Path: diff --git a/tests/venv_site_packages_libs/shared_lib_loading_test.py b/tests/venv_site_packages_libs/shared_lib_loading_test.py index a3f7bfcd5a..aa440e4053 100644 --- a/tests/venv_site_packages_libs/shared_lib_loading_test.py +++ b/tests/venv_site_packages_libs/shared_lib_loading_test.py @@ -6,13 +6,13 @@ # Optional imports for ELF/Mach-O analysis if os.name == "posix" and sys.platform != "darwin": - from elftools.elf.elffile import ELFFile + from elftools.elf.elffile import ELFFile # pyrefly: ignore[missing-import] else: ELFFile = None if sys.platform == "darwin": - from macholib import mach_o - from macholib.MachO import MachO + from macholib import mach_o # pyrefly: ignore[missing-import] + from macholib.MachO import MachO # pyrefly: ignore[missing-import] else: mach_o = None MachO = None @@ -36,7 +36,7 @@ def setUp(self): @unittest.skipIf(os.name == "nt", "Tests Unix-specific extension loading") def test_shared_library_linking_unix(self): try: - import ext_with_libs.adder + import ext_with_libs.adder # pyrefly: ignore[missing-import] except ImportError as e: spec = importlib.util.find_spec("ext_with_libs.adder") if not spec or not spec.origin: @@ -75,7 +75,7 @@ def test_shared_library_linking_unix(self): def test_shared_library_loading_windows(self): # We import markupsafe._speedups (a .cp311-win_amd64.pyd extension) try: - import markupsafe._speedups + import markupsafe._speedups # pyrefly: ignore[missing-import] module = markupsafe._speedups except ImportError as e: @@ -120,30 +120,32 @@ def _get_linking_info(self, path): def _get_elf_info(self, path): """Extracts linking information from an ELF file.""" + assert ELFFile is not None # type assert info = {"rpaths": [], "needed": [], "undefined_symbols": []} with open(path, "rb") as f: elf = ELFFile(f) dynamic = elf.get_section_by_name(".dynamic") if dynamic: for tag in dynamic.iter_tags(): - if tag.entry.d_tag == "DT_NEEDED": - info["needed"].append(tag.needed) - elif tag.entry.d_tag == "DT_RPATH": - info["rpaths"].append(tag.rpath) - elif tag.entry.d_tag == "DT_RUNPATH": - info["rpaths"].append(tag.runpath) + if tag.entry.d_tag == "DT_NEEDED": # pyrefly: ignore[missing-attribute] + info["needed"].append(tag.needed) # pyrefly: ignore[missing-attribute] + elif tag.entry.d_tag == "DT_RPATH": # pyrefly: ignore[missing-attribute] + info["rpaths"].append(tag.rpath) # pyrefly: ignore[missing-attribute] + elif tag.entry.d_tag == "DT_RUNPATH": # pyrefly: ignore[missing-attribute] + info["rpaths"].append(tag.runpath) # pyrefly: ignore[missing-attribute] dynsym = elf.get_section_by_name(".dynsym") if dynsym: info["undefined_symbols"] = [ s.name for s in dynsym.iter_symbols() - if s.entry["st_shndx"] == "SHN_UNDEF" + if s.entry["st_shndx"] == "SHN_UNDEF" # pyrefly: ignore[missing-attribute] ] return info def _get_macho_info(self, path): """Extracts linking information from a Mach-O file.""" + assert MachO is not None and mach_o is not None # type assert info = {"rpaths": [], "needed": []} macho = MachO(path) for header in macho.headers: diff --git a/tools/private/release/mock_gh.py b/tools/private/release/mock_gh.py index e5def53799..0b5b672517 100644 --- a/tools/private/release/mock_gh.py +++ b/tools/private/release/mock_gh.py @@ -158,4 +158,5 @@ def get_pr_comments(self, pr_num: int) -> list[dict]: return self.pr_comments.get(pr_num, []) def get_merge_commits_for_prs(self, pending_items: list) -> list: + # pyrefly: ignore[bad-argument-type] return resolve_merge_commits_for_prs(self, pending_items) diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 27eb3226b1..4ad846c0ac 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -420,7 +420,7 @@ def _run_internal(self) -> int: body = self.gh.get_issue_body(args.issue) if args.add: - items_to_add = [] + items_to_add: list[dict[str, Any]] = [] for pr_ref in args.add: try: pr_num = self.gh.resolve_pr_number(pr_ref) diff --git a/tools/private/release/promote.py b/tools/private/release/promote.py index 560c531383..44ccd5ba52 100644 --- a/tools/private/release/promote.py +++ b/tools/private/release/promote.py @@ -77,9 +77,10 @@ def run(self) -> int: if not latest_rc: print(f"Error: No release candidate tags found matching {version}-rc*") return 1 - commit_sha = self.git.get_commit_sha(latest_rc) + rc_commit_sha = self.git.get_commit_sha(latest_rc) else: latest_rc = None + rc_commit_sha = None # Verify issue can be found and read it early print(f"Verifying tracking issue #{issue_num} format...") @@ -102,16 +103,17 @@ def run(self) -> int: return 1 if is_first_release: - if commit_sha != branch_sha: + assert rc_commit_sha is not None # type assert + if rc_commit_sha != branch_sha: print( - f"Error: The latest RC tag {latest_rc} ({commit_sha[:8]}) is not at" + f"Error: The latest RC tag {latest_rc} ({rc_commit_sha[:8]}) is not at" f" the head of release branch {remote_branch} ({branch_sha[:8]})." ) metadata = { "status": "error-rc-tag-not-branch-head", "rc": latest_rc, "branch_commit": branch_sha[:8], - "tag_commit": commit_sha[:8], + "tag_commit": rc_commit_sha[:8], } try: updated_body = update_task_in_body( @@ -130,6 +132,7 @@ def run(self) -> int: f" error status." ) return 1 + commit_sha = rc_commit_sha else: # Patch release: tag branch head directly commit_sha = branch_sha diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 220348bd47..9c73d653d5 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -1,4 +1,5 @@ import re +from typing import Any class BackportTask: @@ -261,7 +262,7 @@ def parse_backports(body): return items -def add_backports_to_body(body: str, items: list[dict]) -> str: +def add_backports_to_body(body: str, items: list[dict[str, Any]]) -> str: """Adds new backport checklist items to the ## Backports section. Args: diff --git a/tools/private/update_deps/args.py b/tools/private/update_deps/args.py index 293294c370..610b1abc72 100644 --- a/tools/private/update_deps/args.py +++ b/tools/private/update_deps/args.py @@ -28,7 +28,11 @@ def path_from_runfiles(input: str) -> pathlib.Path: Returns: the pathlib.Path path to a file which is verified to exist. """ - path = pathlib.Path(runfiles.Create().Rlocation(input)) + rf = runfiles.Create() + assert rf is not None # type assert + rlocation_path = rf.Rlocation(input) + assert rlocation_path is not None # type assert + path = pathlib.Path(rlocation_path) if not path.exists(): raise ValueError(f"Path '{path}' does not exist") diff --git a/tools/private/update_deps/update_coverage_deps.py b/tools/private/update_deps/update_coverage_deps.py index 8a4ccb41ba..74ac657bad 100755 --- a/tools/private/update_deps/update_coverage_deps.py +++ b/tools/private/update_deps/update_coverage_deps.py @@ -111,8 +111,8 @@ def _map( filename: str, python_version: str, url: str, - digests: list, - platform: str, + digests: dict[str, str], + platform: str | tuple[str, str], **kwargs: Any, ): if platform and platform not in _supported_platforms: diff --git a/tools/private/update_deps/update_pip_deps.py b/tools/private/update_deps/update_pip_deps.py index 406697bc4d..9951a7abbb 100755 --- a/tools/private/update_deps/update_pip_deps.py +++ b/tools/private/update_deps/update_pip_deps.py @@ -27,7 +27,7 @@ import textwrap from dataclasses import dataclass -from pip._internal.cli.main import main as pip_main +from pip._internal.cli.main import main as pip_main # pyrefly: ignore[missing-import] from tools.private.update_deps.args import path_from_runfiles from tools.private.update_deps.update_file import update_file diff --git a/tools/wheelmaker.py b/tools/wheelmaker.py index 70e375b4ee..483e8fcefe 100644 --- a/tools/wheelmaker.py +++ b/tools/wheelmaker.py @@ -24,6 +24,7 @@ import stat import sys import zipfile +from collections.abc import Sequence from pathlib import Path _ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) @@ -101,7 +102,7 @@ def normalize_pep440(version): def arcname_from( name: str, distribution_prefix: str, - strip_path_prefixes: Sequence[str] = (), # noqa: F821 + strip_path_prefixes: Sequence[str] = (), add_path_prefix: str = "", ) -> str: """Return the within-archive name for a given file path name. @@ -287,7 +288,12 @@ def __init__( self._wheelname_fragment_distribution_name + "-" + self._version ) - self._whlfile = None + self._whlfile: _WhlFile | None = None + + @property + def whlfile(self) -> _WhlFile: + assert self._whlfile is not None # type assert + return self._whlfile def __enter__(self): self._whlfile = _WhlFile( @@ -303,8 +309,9 @@ def __enter__(self): return self def __exit__(self, type, value, traceback): - self._whlfile.close() - self._whlfile = None + if self._whlfile is not None: + self._whlfile.close() + self._whlfile = None def wheelname(self) -> str: components = [ @@ -325,14 +332,14 @@ def disttags(self): return ["-".join([self._python_tag, self._abi, self._platform])] def distinfo_path(self, basename): - return self._whlfile.distinfo_path(basename) + return self.whlfile.distinfo_path(basename) def data_path(self, basename): - return self._whlfile.data_path(basename) + return self.whlfile.data_path(basename) def add_file(self, package_filename, real_filename): """Add given file to the distribution.""" - self._whlfile.add_file(package_filename, real_filename) + self.whlfile.add_file(package_filename, real_filename) def add_wheelfile(self): """Write WHEEL file to the distribution""" @@ -344,7 +351,7 @@ def add_wheelfile(self): """.format("true" if self._platform == "any" else "false") for tag in self.disttags(): wheel_contents += "Tag: %s\n" % tag - self._whlfile.add_string(self.distinfo_path("WHEEL"), wheel_contents) + self.whlfile.add_string(self.distinfo_path("WHEEL"), wheel_contents) def add_metadata(self, metadata, name, description): """Write METADATA file to the distribution.""" @@ -356,11 +363,11 @@ def add_metadata(self, metadata, name, description): # provided. metadata += description if description else "UNKNOWN" metadata += "\n" - self._whlfile.add_string(self.distinfo_path("METADATA"), metadata) + self.whlfile.add_string(self.distinfo_path("METADATA"), metadata) def add_recordfile(self): """Write RECORD file to the distribution.""" - self._whlfile.add_recordfile() + self.whlfile.add_recordfile() def get_files_to_package(input_files): @@ -548,7 +555,7 @@ def parse_args() -> argparse.Namespace: return parser.parse_args(sys.argv[1:]) -def _parse_file_pairs(content: List[str]) -> List[List[str]]: # noqa: F821 +def _parse_file_pairs(content: list[str]) -> list[list[str]]: """ Parse ; delimited lists of files into a 2D list. """ From 086835971b8a13842663e57476eaeb59186ffc92 Mon Sep 17 00:00:00 2001 From: Stanley C <19547104+stanbot8@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:32:53 -0700 Subject: [PATCH 174/179] fix: preserve arguments with spaces (#4026) The runtime environment launcher collapsed interpreter arguments into one shell word. It now forwards each argument without reparsing it, preserving the original argument boundaries. This includes a regression test for interpreter arguments containing spaces. Related: https://github.com/bazelbuild/bazel/issues/30644 --- python/private/runtime_env_toolchain_interpreter.sh | 2 +- tests/runtime_env_toolchain/BUILD.bazel | 1 + tests/runtime_env_toolchain/toolchain_runs_test.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/python/private/runtime_env_toolchain_interpreter.sh b/python/private/runtime_env_toolchain_interpreter.sh index c78cfe1a9b..57d9838919 100755 --- a/python/private/runtime_env_toolchain_interpreter.sh +++ b/python/private/runtime_env_toolchain_interpreter.sh @@ -77,7 +77,7 @@ if [ -e "$self_dir/pyvenv.cfg" ] || [ -e "$self_dir/../pyvenv.cfg" ]; then # binary, not the actual one invoked. # NOTE: exec -a would be simpler, but isn't posix-compatible, and dash shell # (Ubuntu/debian default) doesn't support it; see #3009. - exec sh -c "$PYTHON_BIN \$@" "$venv_bin" "$@" + exec sh -c 'exec "$@"' "$venv_bin" "$PYTHON_BIN" "$@" else exec "$PYTHON_BIN" "$@" fi diff --git a/tests/runtime_env_toolchain/BUILD.bazel b/tests/runtime_env_toolchain/BUILD.bazel index f1bda251f9..42bd9cfc7e 100644 --- a/tests/runtime_env_toolchain/BUILD.bazel +++ b/tests/runtime_env_toolchain/BUILD.bazel @@ -44,6 +44,7 @@ py_reconfig_test( py_reconfig_test( name = "bootstrap_script_test", srcs = ["toolchain_runs_test.py"], + args = ["'argument with spaces'"], bootstrap_impl = "script", data = [ "//tests/support:current_build_settings", diff --git a/tests/runtime_env_toolchain/toolchain_runs_test.py b/tests/runtime_env_toolchain/toolchain_runs_test.py index f3dcee3786..50c6878c13 100644 --- a/tests/runtime_env_toolchain/toolchain_runs_test.py +++ b/tests/runtime_env_toolchain/toolchain_runs_test.py @@ -25,6 +25,7 @@ def test_ran(self): ) if settings["bootstrap_impl"] == "script": + self.assertEqual(sys.argv[1:], ["argument with spaces"]) # Verify we're running in a venv self.assertNotEqual(sys.prefix, sys.base_prefix) # .venv/ occurs for a build-time venv. @@ -34,4 +35,4 @@ def test_ran(self): if __name__ == "__main__": - unittest.main() + unittest.main(argv=sys.argv[:1]) From 353b24e3db2fbd6e5abc24856bf60a1031900e9c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 00:02:05 -0700 Subject: [PATCH 175/179] agents(rules): update python rules with typing and runfiles conventions (#4035) Update the Python agent rules to standardize type annotation practices and simplify runfiles initialization across Python files. Clarify best practices by requiring explanatory comments for type suppressions, adopting modern union syntax (`X | None`), and preferring generic collections from `collections.abc` and builtins over legacy `typing` equivalents. Additionally, instruct agents to prefer fail-fast `runfiles.CreateOrRaise()` when setting up Bazel runfiles. --- .agents/rules/python.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/rules/python.md b/.agents/rules/python.md index c149130fe2..21f6671b93 100644 --- a/.agents/rules/python.md +++ b/.agents/rules/python.md @@ -18,10 +18,22 @@ (e.g. `[missing-import]`) over `tags = ["no-pyrefly"]`. * **No blanket ignores**: NEVER use bare `# type: ignore` or literal `# type: ignore[...]`. Use error-specific ignores instead. +* **Ignore comments**: When adding `# pyrefly: ignore[...]` or type + suppressions, add an explanatory comment indicating why it is suppressed. * **Type assertions**: When adding assertions for type narrowing, add an end-of-line comment: `assert foo is not None # type assert`. * **Consent for `Any`**: Require user consent before changing type annotations to `Any`. +* **Union syntax (`X | None`)**: Use `X | None` instead of `typing.Optional[X]`. + Add `from __future__ import annotations` if necessary. +* **Collections generics**: Use `collections.abc` (e.g., `Sequence`, `Iterable`, + `Iterator`, `Callable`, `Mapping`) and builtin generics (`list`, `dict`, + `tuple`, `set`) instead of `typing.XXX` collection types. + +## Runfiles +* **Fail-fast creation**: Prefer `runfiles.CreateOrRaise()` over + `runfiles.Create()` followed by manual `assert` when initializing runfiles in + tests and runtime scripts. ## Delegating Functions * Module-level functions delegating to class methods should have a docstring From c2ebcd380eca13477087ceeaba41aafd50fc840e Mon Sep 17 00:00:00 2001 From: armandomontanez Date: Tue, 11 Aug 2026 09:17:29 -0700 Subject: [PATCH 176/179] feat(bzlmod): make __init__.py generation configurable module-wide (#3997) In #3841, a warning pushing users to migrate away from implicit `__init__.py` generation was added. While it's good to flag this bad behavior, silencing it requires users to either explicitly configure this option on every `py_binary` and `py_test` target, or configure the option globally in their `.bazelrc`. To better facilitate a migration, this change introduces a mechanism for modules to configure this option module-wide. This has multiple benefits: 1. Everyone working in the module doesn't need to remember to explicitly set `legacy_create_init` on every target. 2. Everyone that depends on the module receives the correct behavior as configured by the module. 3. It becomes possible to tell BCR-wide which modules have adopted this migration. Work towards #2945 --------- Co-authored-by: Richard Levasseur --- examples/bzlmod/MODULE.bazel | 4 ++ examples/bzlmod/other_module/MODULE.bazel | 4 ++ news/3997.added.md | 5 +++ python/extensions/config.bzl | 40 ++++++++++++++++++ python/private/internal_config_repo.bzl | 4 ++ python/private/py_executable.bzl | 45 +++++++++++++++----- tests/explicit_init_py/BUILD.bazel | 6 +++ tests/modules/other/BUILD.bazel | 26 ++++++++++++ tests/modules/other/MODULE.bazel | 14 +++++++ tests/modules/other/ext.bzl | 37 +++++++++++++++++ tests/support/explicit_init_py_test.bzl | 50 +++++++++++++++++++++++ 11 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 news/3997.added.md create mode 100644 tests/explicit_init_py/BUILD.bazel create mode 100644 tests/modules/other/ext.bzl create mode 100644 tests/support/explicit_init_py_test.bzl diff --git a/examples/bzlmod/MODULE.bazel b/examples/bzlmod/MODULE.bazel index 106f25134e..d6b066fd1f 100644 --- a/examples/bzlmod/MODULE.bazel +++ b/examples/bzlmod/MODULE.bazel @@ -22,6 +22,10 @@ bazel_dep(name = "rules_java", version = "8.16.1") # were fixed. bazel_dep(name = "rules_rust", version = "0.67.0") +# Adopt migration away from legacy __init__.py generation. +rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") +rules_python_config.explicit_init_py(default = True) + # We next initialize the python toolchain using the extension. # You can set different Python versions in this block. python = use_extension("@rules_python//python/extensions:python.bzl", "python") diff --git a/examples/bzlmod/other_module/MODULE.bazel b/examples/bzlmod/other_module/MODULE.bazel index a128c39ca0..be18ff21fd 100644 --- a/examples/bzlmod/other_module/MODULE.bazel +++ b/examples/bzlmod/other_module/MODULE.bazel @@ -10,6 +10,10 @@ local_path_override( path = "../../..", ) +# Adopt migration away from legacy __init__.py generation. +rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") +rules_python_config.explicit_init_py(default = True) + python = use_extension("@rules_python//python/extensions:python.bzl", "python") python.defaults( # In a submodule this is ignored diff --git a/news/3997.added.md b/news/3997.added.md new file mode 100644 index 0000000000..34cbfc20e1 --- /dev/null +++ b/news/3997.added.md @@ -0,0 +1,5 @@ +(bzlmod) Added the `{obj}`explicit_init_py`` tag class to the +`{obj}`config`` module extension for configuring implicit `__init__.py` file +generation module-wide. +([#3997](https://github.com/bazel-contrib/rules_python/pull/3997), +[#2945](https://github.com/bazel-contrib/rules_python/issues/2945)) diff --git a/python/extensions/config.bzl b/python/extensions/config.bzl index f19a07aaef..a6d03c6f70 100644 --- a/python/extensions/config.bzl +++ b/python/extensions/config.bzl @@ -22,9 +22,43 @@ to repositories that are expensive to create or invalidate frequently. }, ) +_explicit_init_py = tag_class( + doc = """ +Require explicit `__init__.py` files *in this module*. + +Disables the legacy `__init__.py` generation for all `py_*` targets in this +module, requiring all Python targets to explicitly provide `__init__.py` files +when they're needed. + +To override this at a per-target level, set `legacy_create_init` on applicable +`py_binary` or `py_test` targets: + +```starlark +py_binary( + name = "hello_python", + # ... + # This Binary still relies on legacy behavior, so + # enable the legacy behavior as an exceptional case. + legacy_create_init = 1, +) +``` + +:::{note} +In the future, this will be enabled by default. +::: + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + attrs = { + "default": attr.bool(doc = "Whether explicit __init__.py files are required by default.", mandatory = True), + }, +) + def _config_impl(module_ctx): transition_setting_generators = {} transition_settings = [] + explicit_init_py_modules = {} for mod in module_ctx.modules: for tag in mod.tags.add_transition_setting: setting = str(tag.setting) @@ -32,11 +66,16 @@ def _config_impl(module_ctx): transition_setting_generators[setting] = [] transition_settings.append(setting) transition_setting_generators[setting].append(mod.name) + for tag in mod.tags.explicit_init_py: + explicit_init_py_modules[mod.name] = str(tag.default) + if mod.is_root: + explicit_init_py_modules[""] = str(tag.default) internal_config_repo( name = "rules_python_internal", transition_setting_generators = transition_setting_generators, transition_settings = transition_settings, + explicit_init_py_modules = explicit_init_py_modules, ) pypi_deps() @@ -55,5 +94,6 @@ config = module_extension( implementation = _config_impl, tag_classes = { "add_transition_setting": _add_transition_setting, + "explicit_init_py": _explicit_init_py, }, ) diff --git a/python/private/internal_config_repo.bzl b/python/private/internal_config_repo.bzl index 8ee6a2d017..0630eb5c2f 100644 --- a/python/private/internal_config_repo.bzl +++ b/python/private/internal_config_repo.bzl @@ -37,6 +37,7 @@ config = struct( BuiltinPyInfo = getattr(getattr(native, "legacy_globals", None), "PyInfo", {builtin_py_info_symbol}), BuiltinPyRuntimeInfo = getattr(getattr(native, "legacy_globals", None), "PyRuntimeInfo", {builtin_py_runtime_info_symbol}), BuiltinPyCcLinkParamsProvider = getattr(getattr(native, "legacy_globals", None), "PyCcLinkParamsProvider", {builtin_py_cc_link_params_provider}), + modules_using_explicit_initpy = {modules_using_explicit_initpy}, ) """ @@ -100,6 +101,7 @@ def _internal_config_repo_impl(rctx): builtin_py_info_symbol = "PyInfo" builtin_py_runtime_info_symbol = "PyRuntimeInfo" builtin_py_cc_link_params_provider = "PyCcLinkParamsProvider" + explicit_init_py_modules = {k: str(v) == "True" for k, v in rctx.attr.explicit_init_py_modules.items()} rctx.file("rules_python_config.bzl", _CONFIG_TEMPLATE.format( build_python_zip_default = repo_utils.get_platforms_os_name(rctx) == "windows", @@ -109,6 +111,7 @@ def _internal_config_repo_impl(rctx): supports_whl_extraction = str(supports_whl_extraction), extract_needs_chmod = str(extract_needs_chmod), builtin_py_cc_link_params_provider = builtin_py_cc_link_params_provider, + modules_using_explicit_initpy = str(explicit_init_py_modules), bazel_8_or_later = str(bazel_major_version >= 8), bazel_9_or_later = str(bazel_major_version >= 9), bazel_10_or_later = str(bazel_major_version > 9), @@ -140,6 +143,7 @@ internal_config_repo = repository_rule( configure = True, environ = [], attrs = { + "explicit_init_py_modules": attr.string_dict(), "transition_setting_generators": attr.string_list_dict(), "transition_settings": attr.string_list(), }, diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 2ac2423b86..4ee484dcd0 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -115,15 +115,20 @@ The {any}`RULES_PYTHON_ADDITIONAL_INTERPRETER_ARGS` environment variable "legacy_create_init": lambda: attrb.Int( default = -1, values = [-1, 0, 1], - doc = """\ + doc = """ Whether to implicitly create empty `__init__.py` files in the runfiles tree. These are created in every directory containing Python source code or shared libraries, and every parent directory of those directories, excluding the repo root directory. The default, `-1` (auto), means true unless -`--incompatible_default_to_explicit_init_py` is used. If false, the user is -responsible for creating (possibly empty) `__init__.py` files and adding them to -the `srcs` of Python targets as required. - """, +`--incompatible_default_to_explicit_init_py` or the `explicit_init_py` +module configuration option are used. If false, the user is responsible for +creating (possibly empty) `__init__.py` files and adding them to the `srcs` of +Python targets as required. + +:::{versionchanged} VERSION_NEXT_FEATURE +Now checks module-level `explicit_init_py` configuration before CLI flags. +::: +""", ), # TODO(b/203567235): In the Java impl, any file is allowed. While marked # label, it is more treated as a string, and doesn't have to refer to @@ -284,11 +289,23 @@ def create_binary_semantics(): ) def _should_create_init_files(ctx): - if ctx.attr.legacy_create_init == -1: - return not read_possibly_native_flag(ctx, "default_to_explicit_init_py") - else: + # Each target has the first say in this setting. + if ctx.attr.legacy_create_init != -1: return bool(ctx.attr.legacy_create_init) + # Check if it's configured by a module extension. + canonical_name = ctx.label.repo_name + for sep in ("+", "~"): + if canonical_name.startswith(sep): + canonical_name = "" + module_name = canonical_name.rstrip(sep) if sep not in canonical_name else canonical_name.split(sep)[0] + module_configured_explicit_initpy = rp_config.modules_using_explicit_initpy.get(module_name, None) + if module_configured_explicit_initpy != None: + return not module_configured_explicit_initpy + + # Fall back to CLI setting. + return not read_possibly_native_flag(ctx, "default_to_explicit_init_py") + def _create_executable( ctx, *, @@ -1586,9 +1603,17 @@ WARNING: Target {} is using implicit __init__.py creation. Ensure all __init__.py files are explicitly created and added to the srcs or deps of your targets. - Disable implicit creation by setting: + Disable implicit creation for your module in MODULE.bazel: + + rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") + rules_python_config.explicit_init_py(default = True) + + Or for a specific target by setting: + legacy_create_init = 0 - on the target, or globally by setting: + + Or globally with the following Bazel flag: + --incompatible_default_to_explicit_init_py ====================================================================== """.rstrip().format(ctx.label), diff --git a/tests/explicit_init_py/BUILD.bazel b/tests/explicit_init_py/BUILD.bazel new file mode 100644 index 0000000000..9ea71dc09e --- /dev/null +++ b/tests/explicit_init_py/BUILD.bazel @@ -0,0 +1,6 @@ +load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility + +test_suite( + name = "explicit_init_py", + tests = ["@other//:explicit_init_py_tests"] if BZLMOD_ENABLED else [], +) diff --git a/tests/modules/other/BUILD.bazel b/tests/modules/other/BUILD.bazel index 665049b9f5..2ce7c88325 100644 --- a/tests/modules/other/BUILD.bazel +++ b/tests/modules/other/BUILD.bazel @@ -1,4 +1,5 @@ load("@rules_python//python:py_binary.bzl", "py_binary") +load("@rules_python//tests/support:explicit_init_py_test.bzl", "explicit_init_py_test") load("@rules_python//tests/support:py_reconfig.bzl", "py_reconfig_binary") package( @@ -28,3 +29,28 @@ py_binary( "//nspkg_gamma", ], ) + +explicit_init_py_test( + name = "test_module_dep_no_init", + expect_generated_init = False, + main = "external_main.py", +) + +explicit_init_py_test( + name = "test_legacy_create_init_override", + expect_generated_init = True, + legacy_create_init = 1, + main = "external_main.py", +) + +# These tests are run by @rules_python//tests/explicit_init_py to ensure a +# module's configuration is respected when it's a dependency. +test_suite( + name = "explicit_init_py_tests", + tests = [ + ":test_legacy_create_init_override", + ":test_module_dep_no_init", + "@init_py_test_extension_repo//:test", + "@init_py_test_repo//:test", + ], +) diff --git a/tests/modules/other/MODULE.bazel b/tests/modules/other/MODULE.bazel index 11a633d56b..12ee7672b6 100644 --- a/tests/modules/other/MODULE.bazel +++ b/tests/modules/other/MODULE.bazel @@ -2,4 +2,18 @@ module(name = "other") bazel_dep(name = "rules_python", version = "0") bazel_dep(name = "bazel_skylib", version = "1.7.1") +bazel_dep(name = "rules_testing", version = "0.6.0") bazel_dep(name = "another_module", version = "0") + +# Validate behavior of explicit_init_py configuration extension when used by +# a module that's a dependency. +rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") +rules_python_config.explicit_init_py(default = True) + +init_py_test_repo = use_repo_rule("//:ext.bzl", "init_py_test_repo") + +init_py_test_repo(name = "init_py_test_repo") + +other_ext = use_extension("//:ext.bzl", "other_init_py_test_ext") +other_ext.repo(name = "init_py_test_extension_repo") +use_repo(other_ext, "init_py_test_extension_repo") diff --git a/tests/modules/other/ext.bzl b/tests/modules/other/ext.bzl new file mode 100644 index 0000000000..943909ee71 --- /dev/null +++ b/tests/modules/other/ext.bzl @@ -0,0 +1,37 @@ +"""Module extension declared by 'other' module for testing __init__.py generation.""" + +_BUILD_FILE_CONTENT = """\ +load("@rules_python//tests/support:explicit_init_py_test.bzl", "explicit_init_py_test") + +explicit_init_py_test( + name = "test", + main = "main.py", + expect_generated_init = False, +) +""" + +_MAIN_PY_CONTENT = "print('hello, world')" + +def _repo_impl(rctx): + rctx.file("main.py", _MAIN_PY_CONTENT) + rctx.file("BUILD.bazel", _BUILD_FILE_CONTENT) + +init_py_test_repo = repository_rule(implementation = _repo_impl) + +def _other_init_py_test_ext_impl(module_ctx): + for mod in module_ctx.modules: + for tag in mod.tags.repo: + init_py_test_repo(name = tag.name) + +_repo_tag = tag_class( + attrs = { + "name": attr.string(mandatory = True), + }, +) + +other_init_py_test_ext = module_extension( + implementation = _other_init_py_test_ext_impl, + tag_classes = { + "repo": _repo_tag, + }, +) diff --git a/tests/support/explicit_init_py_test.bzl b/tests/support/explicit_init_py_test.bzl new file mode 100644 index 0000000000..abc32a9441 --- /dev/null +++ b/tests/support/explicit_init_py_test.bzl @@ -0,0 +1,50 @@ +"""Parameterized analysis test for __init__.py generation behavior.""" + +load("@rules_testing//lib:analysis_test.bzl", "analysis_test") +load("@rules_testing//lib:truth.bzl", "matching") +load("@rules_testing//lib:util.bzl", rt_util = "util") +load("//python:py_binary.bzl", "py_binary") +load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility + +def _explicit_init_py_test_impl(env, target): + empty_filenames = target[DefaultInfo].default_runfiles.empty_filenames.to_list() + collection = env.expect.that_collection( + empty_filenames, + container_name = "empty_filenames", + ) + if env.ctx.attr.expect_generated_init: + collection.contains_predicate(matching.str_endswith("__init__.py")) + else: + collection.not_contains_predicate(matching.str_endswith("__init__.py")) + +def explicit_init_py_test(*, name, main, expect_generated_init, legacy_create_init = -1, **kwargs): + """Test that verifies whether __init__.py is generated for a py_binary. + + Args: + name: Test name. + main: Source file for the py_binary subject. + expect_generated_init: Whether __init__.py generation is expected. + legacy_create_init: Value for the legacy_create_init attribute (-1, 0, or 1). + **kwargs: Additional args forwarded to the test rule (e.g. tags). + """ + if not BZLMOD_ENABLED: + native.test_suite(name = name, tests = []) + return + + subject_name = name + "_subject" + rt_util.helper_target( + py_binary, + name = subject_name, + srcs = [main], + main = main, + legacy_create_init = legacy_create_init, + ) + analysis_test( + name = name, + target = subject_name, + impl = _explicit_init_py_test_impl, + attrs = { + "expect_generated_init": attr.bool(mandatory = True), + }, + attr_values = dict(kwargs, expect_generated_init = expect_generated_init), + ) From c1e7fbb2f17ae6640e94109e9bc1270bfd528db0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 09:18:38 -0700 Subject: [PATCH 177/179] build(ci): remove legacy mypy type checking workflow and comments (#4036) Static type checking across the repository has been consolidated under Pyrefly. The standalone GitHub Actions mypy workflow and mypy-specific type ignore comments and suppressions are no longer necessary. Remove the legacy mypy CI job from GitHub Actions workflows, clean up mypy-specific type suppressions and comments in runfiles library code and tests, and standardize type narrowing assertions. --- .github/workflows/ci.yaml | 11 ----------- python/runfiles/runfiles.py | 10 +++++----- tests/runfiles/runfiles_test.py | 22 +++++++++++----------- 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e7b2a4d956..7990f652e6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,17 +17,6 @@ permissions: contents: read jobs: - mypy: - runs-on: ubuntu-latest - steps: - # Checkout the code - - uses: actions/checkout@v7 - - uses: jpetrucciani/mypy-check@master - with: - path: 'python/runfiles' - - uses: jpetrucciani/mypy-check@master - with: - path: 'tests/runfiles' ruff: runs-on: ubuntu-latest steps: diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index af87b54437..1c6dca6088 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -162,7 +162,7 @@ class Path(pathlib.Path): using the associated `Runfiles` instance when converted to a string. """ - # Mypy isn't smart enough to realize `self` in the methods + # Static type checkers may not realize `self` in the methods # refers to our Path class instead of pathlib.Path _runfiles: Runfiles | None _source_repo: str | None @@ -231,8 +231,8 @@ def with_segments(self, *pathsegments: str | os.PathLike) -> Self: # override def _make_child(self, args: tuple[str, ...]) -> Self: # _make_child is an internal CPython method in Python < 3.12 omitted from - # typeshed stubs. We ignore [misc] for mypy and [missing-attribute] for pyrefly. - obj = cast("Path", super()._make_child(args)) # type: ignore[misc] # pyrefly: ignore[missing-attribute] + # typeshed stubs. We ignore [missing-attribute] for pyrefly. + obj = cast("Path", super()._make_child(args)) # pyrefly: ignore[missing-attribute] obj._runfiles = self._runfiles obj._source_repo = self._source_repo return cast(Self, obj) @@ -378,13 +378,13 @@ def __str__(self) -> str: path_posix = super().__str__().replace("\\", "/") if not path_posix or path_posix == ".": # pylint: disable=protected-access - return self._runfiles._python_runfiles_root # type: ignore[attr-defined] # pyrefly: ignore[missing-attribute] + return self._runfiles._python_runfiles_root # pyrefly: ignore[missing-attribute] resolved = self._runfiles.Rlocation(path_posix, source_repo=self._source_repo) if resolved is not None: return resolved # pylint: disable=protected-access - return posixpath.join(self._runfiles._python_runfiles_root, path_posix) # type: ignore[attr-defined] # pyrefly: ignore[missing-attribute] + return posixpath.join(self._runfiles._python_runfiles_root, path_posix) # pyrefly: ignore[missing-attribute] def __fspath__(self) -> str: return str(self) diff --git a/tests/runfiles/runfiles_test.py b/tests/runfiles/runfiles_test.py index ce74a3d4ac..78e2554aa3 100644 --- a/tests/runfiles/runfiles_test.py +++ b/tests/runfiles/runfiles_test.py @@ -30,10 +30,10 @@ class RunfilesTest(unittest.TestCase): def testRlocationArgumentValidation(self) -> None: r = runfiles.Create({"RUNFILES_DIR": "whatever"}) - assert r is not None # mypy doesn't understand the unittest api. - self.assertRaises(ValueError, lambda: r.Rlocation(None)) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] + assert r is not None # type assert + self.assertRaises(ValueError, lambda: r.Rlocation(None)) # pyrefly: ignore[bad-argument-type] self.assertRaises(ValueError, lambda: r.Rlocation("")) - self.assertRaises(TypeError, lambda: r.Rlocation(1)) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] + self.assertRaises(TypeError, lambda: r.Rlocation(1)) # pyrefly: ignore[bad-argument-type] self.assertRaisesRegex( ValueError, "is not normalized", lambda: r.Rlocation("../foo") ) @@ -69,7 +69,7 @@ def testRlocationArgumentValidation(self) -> None: def testRlocationWithData(self) -> None: r = runfiles.Create() - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert settings_path = r.Rlocation( "rules_python/tests/support/current_build_settings.json" ) @@ -86,7 +86,7 @@ def testCreatesManifestBasedRunfiles(self) -> None: "TEST_SRCDIR": "always ignored", } ) - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert self.assertEqual(r.Rlocation("a/b"), "c/d") self.assertIsNone(r.Rlocation("foo")) @@ -98,7 +98,7 @@ def testManifestBasedRunfilesEnvVars(self) -> None: "TEST_SRCDIR": "always ignored", } ) - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert self.assertDictEqual( r.EnvVars(), { @@ -115,7 +115,7 @@ def testManifestBasedRunfilesEnvVars(self) -> None: "TEST_SRCDIR": "always ignored", } ) - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert self.assertDictEqual( r.EnvVars(), { @@ -136,7 +136,7 @@ def testManifestBasedRunfilesEnvVars(self) -> None: "TEST_SRCDIR": "always ignored", } ) - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert self.assertDictEqual( r.EnvVars(), { @@ -153,7 +153,7 @@ def testCreatesDirectoryBasedRunfiles(self) -> None: "TEST_SRCDIR": "always ignored", } ) - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert self.assertEqual(r.Rlocation("a/b"), "runfiles/dir/a/b") self.assertEqual(r.Rlocation("foo"), "runfiles/dir/foo") @@ -164,7 +164,7 @@ def testDirectoryBasedRunfilesEnvVars(self) -> None: "TEST_SRCDIR": "always ignored", } ) - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert self.assertDictEqual( r.EnvVars(), { @@ -763,7 +763,7 @@ def testCurrentRepository(self) -> None: else: expected = "rules_python" r = runfiles.Create() - assert r is not None # mypy doesn't understand the unittest api. + assert r is not None # type assert self.assertEqual(r.CurrentRepository(), expected) @staticmethod From 76704f642cf0c5bec2062ffebd3479545287622d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 16:07:29 -0700 Subject: [PATCH 178/179] agents(analyze-ci-failure): enhance network reset and macOS sandbox flake detection (#4037) CI jobs on Buildkite runners occasionally encounter transient curl network resets during runner bootstrapping and transient darwin-sandbox disk I/O errors (errno 5) on macOS workers. Previously, the automated CI failure analysis script did not match these error signatures, leading to unclassified failures or treating them as codebase defects. Add pattern matching and flake classification heuristics for curl connection resets and runner I/O errors so they are recognized as transient infrastructure flakes. --- .../scripts/analyze_ci_failure.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py index 5708f6d1c2..31d8dd7b6e 100755 --- a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py +++ b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py @@ -114,6 +114,10 @@ def parse_log(log_path): "error waiting for container", "error during connect:", "user command error:", + "curl: (", + "connection reset by peer", + "input/output error", + "errno 5", ] ): if clean_line: @@ -151,6 +155,19 @@ def create_plan(job_name, log_path, errors): ): is_flake = True flake_reason = "Buildkite agent / Docker runner infrastructure failure (dockerd disconnection / grpc context canceled / exit status 125). This is an infrastructure flake, not a codebase bug." + elif any( + "curl:" in e.lower() + or "recv failure: connection reset" in e.lower() + or "connection reset by peer" in e.lower() + for e in errors + ): + is_flake = True + flake_reason = "Network connection reset during runner bootstrap or artifact download (curl recv failure / connection reset). This is an infrastructure network flake." + elif any( + "input/output error" in e.lower() or "errno 5" in e.lower() for e in errors + ): + is_flake = True + flake_reason = "Transient runner host disk / Darwin sandbox I/O error (OSError: [Errno 5] Input/output error). This is an infrastructure flake, not a codebase defect." classification = ( "⚡ **Classification**: **Infrastructure / Flake Issue** (Not a codebase bug)" From 36c342ab1f36a4cda09cd7b1baf8f38c4f1ba6d6 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 12 Aug 2026 00:36:12 -0700 Subject: [PATCH 179/179] fix(pypi): rewrite RECORD file entries for extracted .data contents (#4025) Extracting wheels with .data/ subdirectories moves files to their target directories and deletes .data/, but left .dist-info/RECORD referencing deleted .data/ paths. This causes tools like importlib.metadata.files() to fail locating or reading files. Per PEP 427 and PEP 376, rewrite .dist-info/RECORD entries during wheel extraction to match installed locations relative to site-packages. * Also adds Starlark unit tests for RECORD rewriting and expands importlib_metadata_test. Work towards #3024 --- news/4025.fixed.md | 3 + python/private/attributes.bzl | 8 ++ python/private/pypi/BUILD.bazel | 19 +++ python/private/pypi/gen_wheel_record.bzl | 79 ++++++++++++ python/private/pypi/wheel_record_rewriter.ps1 | 66 ++++++++++ python/private/pypi/wheel_record_rewriter.sh | 60 +++++++++ python/private/pypi/whl_extract.bzl | 48 ++++--- python/private/pypi/whl_library_targets.bzl | 14 +++ tests/pypi/whl_extract/BUILD.bazel | 11 ++ .../whl_extract/wheel_record_rewriter_test.sh | 91 ++++++++++++++ tests/pypi/whl_extract/whl_extract_tests.bzl | 119 ++++++++++++++++++ .../whl_library_targets_tests.bzl | 9 +- .../importlib_metadata_test.py | 80 +++++++++++- 13 files changed, 585 insertions(+), 22 deletions(-) create mode 100644 news/4025.fixed.md create mode 100644 python/private/pypi/gen_wheel_record.bzl create mode 100644 python/private/pypi/wheel_record_rewriter.ps1 create mode 100755 python/private/pypi/wheel_record_rewriter.sh create mode 100644 tests/pypi/whl_extract/BUILD.bazel create mode 100755 tests/pypi/whl_extract/wheel_record_rewriter_test.sh create mode 100644 tests/pypi/whl_extract/whl_extract_tests.bzl diff --git a/news/4025.fixed.md b/news/4025.fixed.md new file mode 100644 index 0000000000..80e81333f6 --- /dev/null +++ b/news/4025.fixed.md @@ -0,0 +1,3 @@ +(pypi) Fixed {obj}`RECORD` file paths for extracted `.data` directory contents +so that {obj}`importlib.metadata.files()` correctly locates installed +distribution files ([#4025](https://github.com/bazel-contrib/rules_python/pull/4025)). diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl index 5909d8872b..e1e77cba03 100644 --- a/python/private/attributes.bzl +++ b/python/private/attributes.bzl @@ -555,6 +555,14 @@ AGNOSTIC_TEST_ATTRS = _init_agnostic_test_attrs() # but still accept Python source-agnostic settings. AGNOSTIC_BINARY_ATTRS = dicts.add(AGNOSTIC_EXECUTABLE_ATTRS) +WINDOWS_CONSTRAINTS_PLAIN_ATTRS = { + "_windows_constraints": attr.label_list( + default = [ + "@platforms//os:windows", + ], + ), +} + WINDOWS_CONSTRAINTS_ATTRS = { "_windows_constraints": lambda: attrb.LabelList( default = [ diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel index bb84ff9280..3c07135d40 100644 --- a/python/private/pypi/BUILD.bazel +++ b/python/private/pypi/BUILD.bazel @@ -41,6 +41,15 @@ alias( visibility = ["//visibility:public"], ) +alias( + name = "wheel_record_rewriter", + actual = select({ + "@platforms//os:windows": "wheel_record_rewriter.ps1", + "//conditions:default": "wheel_record_rewriter.sh", + }), + visibility = ["//visibility:public"], +) + exports_files( srcs = ["deps.bzl"], visibility = ["//tools/private/update_deps:__pkg__"], @@ -490,11 +499,21 @@ bzl_library( ], ) +bzl_library( + name = "gen_wheel_record", + srcs = ["gen_wheel_record.bzl"], + deps = [ + "//python/private:attributes", + "//python/private:common", + ], +) + bzl_library( name = "whl_library_targets", srcs = ["whl_library_targets.bzl"], deps = [ ":env_marker_setting", + ":gen_wheel_record", ":labels", ":namespace_pkgs", ":pep508_deps", diff --git a/python/private/pypi/gen_wheel_record.bzl b/python/private/pypi/gen_wheel_record.bzl new file mode 100644 index 0000000000..6dd211f776 --- /dev/null +++ b/python/private/pypi/gen_wheel_record.bzl @@ -0,0 +1,79 @@ +"""Rule for generating platform-specific RECORD files.""" + +load("//python/private:attributes.bzl", "WINDOWS_CONSTRAINTS_PLAIN_ATTRS") +load("//python/private:common.bzl", "is_windows_platform") + +def _gen_wheel_record_impl(ctx): + is_windows = is_windows_platform(ctx) + rewriter_file = ctx.files._wheel_record_rewriter[0] + out_files = [] + + for in_file in ctx.files.srcs: + dist_info_name = in_file.dirname.rpartition("/")[2] + if dist_info_name: + if dist_info_name.endswith(".dist-info"): + data_dir_basename = ( + dist_info_name[:-len(".dist-info")] + ".data" + ) + else: + data_dir_basename = dist_info_name + ".data" + out_file = ctx.actions.declare_file( + "site-packages/{}/RECORD".format(dist_info_name), + ) + else: + data_dir_basename = "data" + out_file = ctx.actions.declare_file("site-packages/RECORD") + + out_files.append(out_file) + + action_args = ctx.actions.args() + inputs = depset([in_file, rewriter_file]) + + if rewriter_file.path.endswith(".ps1"): + action_exe = "powershell.exe" + action_args.add_all([ + "-ExecutionPolicy", + "Bypass", + "-NoProfile", + "-File", + rewriter_file, + ]) + else: + action_exe = ( + ctx.attr._wheel_record_rewriter[DefaultInfo].files_to_run + ) + + action_args.add(in_file) + action_args.add(out_file) + action_args.add("windows" if is_windows else "unix") + action_args.add(data_dir_basename) + + ctx.actions.run( + inputs = inputs, + outputs = [out_file], + executable = action_exe, + arguments = [action_args], + mnemonic = "PyRewriteWheelRecord", + progress_message = "Rewriting wheel RECORD %{output}", + toolchain = None, + ) + + return [ + DefaultInfo(files = depset(out_files)), + ] + +gen_wheel_record = rule( + implementation = _gen_wheel_record_impl, + attrs = WINDOWS_CONSTRAINTS_PLAIN_ATTRS | { + "srcs": attr.label_list( + doc = "The original RECORD files to rewrite.", + mandatory = True, + allow_files = True, + ), + "_wheel_record_rewriter": attr.label( + default = "//python/private/pypi:wheel_record_rewriter", + allow_files = True, + cfg = "exec", + ), + }, +) diff --git a/python/private/pypi/wheel_record_rewriter.ps1 b/python/private/pypi/wheel_record_rewriter.ps1 new file mode 100644 index 0000000000..60dd0eca11 --- /dev/null +++ b/python/private/pypi/wheel_record_rewriter.ps1 @@ -0,0 +1,66 @@ +[CmdletBinding()] +param( + [Parameter(Position=0, Mandatory=$true)] + [string]$InFile, + + [Parameter(Position=1, Mandatory=$true)] + [string]$OutFile, + + [Parameter(Position=2, Mandatory=$true)] + [string]$TargetOs, + + [Parameter(Position=3, Mandatory=$true)] + [string]$DataDirBasename +) + +$ErrorActionPreference = "Stop" + +$dataPrefix = "$DataDirBasename/" +$quotedDataPrefix = "`"$DataDirBasename/" + +if ($TargetOs -eq "windows") { + $dataRepl = "../../" + $headersRepl = "../../Include/" + $platlibRepl = "" + $purelibRepl = "" + $scriptsRepl = "../../Scripts/" +} else { + $dataRepl = "../../../" + $headersRepl = "../../../include/" + $platlibRepl = "" + $purelibRepl = "" + $scriptsRepl = "../../../bin/" +} + +$lines = Get-Content -Path $InFile +$outLines = [System.Collections.Generic.List[string]]::new() +$Utf8NoBom = New-Object System.Text.UTF8Encoding $False + +foreach ($line in $lines) { + if ($line.StartsWith($quotedDataPrefix)) { + $quote = "`"" + $rest = $line.Substring($quotedDataPrefix.Length) + } elseif ($line.StartsWith($dataPrefix)) { + $quote = "" + $rest = $line.Substring($dataPrefix.Length) + } else { + $outLines.Add($line) + continue + } + + if ($rest.StartsWith("purelib/")) { + $outLines.Add($quote + $purelibRepl + $rest.Substring(8)) + } elseif ($rest.StartsWith("platlib/")) { + $outLines.Add($quote + $platlibRepl + $rest.Substring(8)) + } elseif ($rest.StartsWith("scripts/")) { + $outLines.Add($quote + $scriptsRepl + $rest.Substring(8)) + } elseif ($rest.StartsWith("headers/")) { + $outLines.Add($quote + $headersRepl + $rest.Substring(8)) + } elseif ($rest.StartsWith("data/")) { + $outLines.Add($quote + $dataRepl + $rest.Substring(5)) + } else { + $outLines.Add($line) + } +} + +[System.IO.File]::WriteAllText($OutFile, ($outLines -join "`n") + "`n", $Utf8NoBom) diff --git a/python/private/pypi/wheel_record_rewriter.sh b/python/private/pypi/wheel_record_rewriter.sh new file mode 100755 index 0000000000..10189435e1 --- /dev/null +++ b/python/private/pypi/wheel_record_rewriter.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu + +IN="$1" +OUT="$2" +TARGET_OS="$3" +DATA_DIR_BASENAME="$4" + +DATA_PREFIX="${DATA_DIR_BASENAME}/" +QUOTED_DATA_PREFIX="\"${DATA_DIR_BASENAME}/" + +if [ "$TARGET_OS" = "windows" ]; then + DATA_REPL="../../" + HEADERS_REPL="../../Include/" + PLATLIB_REPL="" + PURELIB_REPL="" + SCRIPTS_REPL="../../Scripts/" +else + DATA_REPL="../../../" + HEADERS_REPL="../../../include/" + PLATLIB_REPL="" + PURELIB_REPL="" + SCRIPTS_REPL="../../../bin/" +fi + +awk -v data_prefix="$DATA_PREFIX" \ + -v quoted_data_prefix="$QUOTED_DATA_PREFIX" \ + -v data_repl="$DATA_REPL" \ + -v headers_repl="$HEADERS_REPL" \ + -v platlib_repl="$PLATLIB_REPL" \ + -v purelib_repl="$PURELIB_REPL" \ + -v scripts_repl="$SCRIPTS_REPL" ' +{ + line = $0 + quote = "" + if (substr(line, 1, length(quoted_data_prefix)) == quoted_data_prefix) { + quote = "\"" + rest = substr(line, length(quoted_data_prefix) + 1) + } else if (substr(line, 1, length(data_prefix)) == data_prefix) { + rest = substr(line, length(data_prefix) + 1) + } else { + print line + next + } + + if (substr(rest, 1, 8) == "purelib/") { + print quote purelib_repl substr(rest, 9) + } else if (substr(rest, 1, 8) == "platlib/") { + print quote platlib_repl substr(rest, 9) + } else if (substr(rest, 1, 8) == "scripts/") { + print quote scripts_repl substr(rest, 9) + } else if (substr(rest, 1, 8) == "headers/") { + print quote headers_repl substr(rest, 9) + } else if (substr(rest, 1, 5) == "data/") { + print quote data_repl substr(rest, 6) + } else { + print line + } +} +' "$IN" > "$OUT" diff --git a/python/private/pypi/whl_extract.bzl b/python/private/pypi/whl_extract.bzl index 0d61b9a07b..d7195fd681 100644 --- a/python/private/pypi/whl_extract.bzl +++ b/python/private/pypi/whl_extract.bzl @@ -4,6 +4,21 @@ load("@rules_python_internal//:rules_python_config.bzl", rp_config = "config") load("//python/private:repo_utils.bzl", "repo_utils") load(":whl_metadata.bzl", "find_whl_metadata") +# Mapping of wheel .data categories to their extraction destination (relative to +# repository root). +_DATA_CATEGORIES = { + # category: repo_dest_dir + "data": "data", + "headers": "include", + # In theory there may be directory collisions in platlib/purelib, so it is + # best to merge the paths here. What is more, this code has to be reasonably + # efficient because some packages like to explicitly indicate if something + # is in `platlib` or `purelib` (e.g. libclang wheel). + "platlib": "site-packages", + "purelib": "site-packages", + "scripts": "bin", +} + def whl_extract(rctx, *, whl_path, logger): """Extract whls in Starlark. @@ -34,22 +49,11 @@ def whl_extract(rctx, *, whl_path, logger): ) # Get the .dist_info dir name - data_dir = dist_info_dir.dirname.get_child(dist_info_dir.basename[:-len(".dist-info")] + ".data") + data_dir = dist_info_dir.dirname.get_child( + dist_info_dir.basename[:-len(".dist-info")] + ".data", + ) if data_dir.exists: - for prefix, dest_prefix in { - # https://docs.python.org/3/library/sysconfig.html#posix-prefix - # We are taking this from the legacy whl installer config - "data": "data", - "headers": "include", - # In theory there may be directory collisions here, so it would be best to - # merge the paths here. We are doing for quite a few levels deep. What is - # more, this code has to be reasonably efficient because some packages like - # to not put everything to the top level, but to indicate explicitly if - # something is in `platlib` or `purelib` (e.g. libclang wheel). - "platlib": "site-packages", - "purelib": "site-packages", - "scripts": "bin", - }.items(): + for prefix, dest_prefix in _DATA_CATEGORIES.items(): src = data_dir.get_child(prefix) if not src.exists: # The prefix does not exist in the wheel, we can continue @@ -61,6 +65,20 @@ def whl_extract(rctx, *, whl_path, logger): logger.debug(lambda: "Renaming: {} -> {}".format(src, dest)) repo_utils.rename(rctx, src, dest) + # Move RECORD to rewrite-record so gen_wheel_record can generate + # the platform-specific RECORD file at build time. + record_file = dist_info_dir.get_child("RECORD") + if record_file.exists: + rewrite_record_dir = rctx.path( + "rewrite-record/" + dist_info_dir.basename, + ) + repo_utils.mkdir(rctx, rewrite_record_dir) + repo_utils.rename( + rctx, + record_file, + rewrite_record_dir.get_child("RECORD"), + ) + # Ensure that there is no data dir left rctx.delete(data_dir) diff --git a/python/private/pypi/whl_library_targets.bzl b/python/private/pypi/whl_library_targets.bzl index ee5c781b3d..b7fdbd55e9 100644 --- a/python/private/pypi/whl_library_targets.bzl +++ b/python/private/pypi/whl_library_targets.bzl @@ -19,6 +19,7 @@ load("//python:py_binary.bzl", "py_binary") load("//python:py_library.bzl", "py_library") load("//python/private:normalize_name.bzl", "normalize_name") load(":env_marker_setting.bzl", "env_marker_setting") +load(":gen_wheel_record.bzl", "gen_wheel_record") load( ":labels.bzl", "DATA_LABEL", @@ -159,6 +160,7 @@ def whl_library_srcs( py_library = py_library, venv_entry_point = venv_entry_point, venv_rewrite_shebang = venv_rewrite_shebang, + gen_wheel_record = gen_wheel_record, env_marker_setting = env_marker_setting, create_inits = _create_inits, )): @@ -225,6 +227,16 @@ def whl_library_srcs( bins_for_data_label.append(rewrite_target_name) data.append(rewrite_target_name) + record_srcs = native.glob(["rewrite-record/*/RECORD"], allow_empty = True) + record_target_name = "record" + if record_srcs: + rules.gen_wheel_record( + name = record_target_name, + srcs = record_srcs, + tags = ["manual"], + ) + data.append(record_target_name) + if filegroups == None: filegroups = { EXTRACTED_WHEEL_FILES: dict( @@ -248,6 +260,8 @@ def whl_library_srcs( srcs = native.glob(**glob_kwargs) if filegroup_name == DATA_LABEL: srcs = srcs + bins_for_data_label + if filegroup_name == DIST_INFO_LABEL and record_srcs: + srcs = srcs + [record_target_name] native.filegroup( name = filegroup_name, srcs = srcs, diff --git a/tests/pypi/whl_extract/BUILD.bazel b/tests/pypi/whl_extract/BUILD.bazel new file mode 100644 index 0000000000..3983477f2b --- /dev/null +++ b/tests/pypi/whl_extract/BUILD.bazel @@ -0,0 +1,11 @@ +load("@rules_shell//shell:sh_test.bzl", "sh_test") +load(":whl_extract_tests.bzl", "whl_extract_test_suite") + +whl_extract_test_suite(name = "whl_extract_tests") + +sh_test( + name = "wheel_record_rewriter_test", + srcs = ["wheel_record_rewriter_test.sh"], + args = ["$(location //python/private/pypi:wheel_record_rewriter)"], + data = ["//python/private/pypi:wheel_record_rewriter"], +) diff --git a/tests/pypi/whl_extract/wheel_record_rewriter_test.sh b/tests/pypi/whl_extract/wheel_record_rewriter_test.sh new file mode 100755 index 0000000000..c5880520ad --- /dev/null +++ b/tests/pypi/whl_extract/wheel_record_rewriter_test.sh @@ -0,0 +1,91 @@ +#!/bin/sh +set -eu + +REWRITER="$1" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +run_rewriter() { + case "$REWRITER" in + *.ps1) + in_file="$1" + out_file="$2" + platform_type="$3" + data_dir="$4" + if command -v cygpath >/dev/null 2>&1; then + in_file="$(cygpath -w "$in_file")" + out_file="$(cygpath -w "$out_file")" + fi + powershell.exe -ExecutionPolicy Bypass -NoProfile -File "$REWRITER" "$in_file" "$out_file" "$platform_type" "$data_dir" + ;; + *) + "$REWRITER" "$@" + ;; + esac +} + +INPUT="$TMP_DIR/input_RECORD" +cat <<'EOF' > "$INPUT" +foo-1.0.data/purelib/pkg/__init__.py,sha256=abc,100 +foo-1.0.data/purelib/pkg/module.py,sha256=def,200 +foo-1.0.data/platlib/pkg/_ext.so,sha256=ghi,300 +foo-1.0.data/data/pkg/data.txt,sha256=111,10 +foo-1.0.data/headers/pkg/header.h,sha256=222,20 +foo-1.0.data/scripts/my_script.sh,sha256=333,30 +"foo-1.0.data/purelib/pkg/my file.py",sha256=abc,100 +"foo-1.0.data/scripts/my tool",sha256=def,200 +"foo-1.0.data/headers/my header.h",sha256=ghi,300 +"foo-1.0.data/data/my data.txt",sha256=jkl,400 +foo-1.0.data/custom_dir/custom.txt,sha256=xyz,123 +top_level/__init__.py,sha256=aaa,50 +foo-1.0.dist-info/METADATA,sha256=bbb,60 +foo-1.0.dist-info/RECORD,, +EOF + +# Test Unix rewrite +UNIX_OUT="$TMP_DIR/unix_RECORD" +run_rewriter "$INPUT" "$UNIX_OUT" "unix" "foo-1.0.data" + +EXPECTED_UNIX="$TMP_DIR/expected_unix" +cat <<'EOF' > "$EXPECTED_UNIX" +pkg/__init__.py,sha256=abc,100 +pkg/module.py,sha256=def,200 +pkg/_ext.so,sha256=ghi,300 +../../../pkg/data.txt,sha256=111,10 +../../../include/pkg/header.h,sha256=222,20 +../../../bin/my_script.sh,sha256=333,30 +"pkg/my file.py",sha256=abc,100 +"../../../bin/my tool",sha256=def,200 +"../../../include/my header.h",sha256=ghi,300 +"../../../my data.txt",sha256=jkl,400 +foo-1.0.data/custom_dir/custom.txt,sha256=xyz,123 +top_level/__init__.py,sha256=aaa,50 +foo-1.0.dist-info/METADATA,sha256=bbb,60 +foo-1.0.dist-info/RECORD,, +EOF + +diff -u --strip-trailing-cr "$EXPECTED_UNIX" "$UNIX_OUT" + +# Test Windows rewrite +WIN_OUT="$TMP_DIR/win_RECORD" +run_rewriter "$INPUT" "$WIN_OUT" "windows" "foo-1.0.data" + +EXPECTED_WIN="$TMP_DIR/expected_win" +cat <<'EOF' > "$EXPECTED_WIN" +pkg/__init__.py,sha256=abc,100 +pkg/module.py,sha256=def,200 +pkg/_ext.so,sha256=ghi,300 +../../pkg/data.txt,sha256=111,10 +../../Include/pkg/header.h,sha256=222,20 +../../Scripts/my_script.sh,sha256=333,30 +"pkg/my file.py",sha256=abc,100 +"../../Scripts/my tool",sha256=def,200 +"../../Include/my header.h",sha256=ghi,300 +"../../my data.txt",sha256=jkl,400 +foo-1.0.data/custom_dir/custom.txt,sha256=xyz,123 +top_level/__init__.py,sha256=aaa,50 +foo-1.0.dist-info/METADATA,sha256=bbb,60 +foo-1.0.dist-info/RECORD,, +EOF + +diff -u --strip-trailing-cr "$EXPECTED_WIN" "$WIN_OUT" diff --git a/tests/pypi/whl_extract/whl_extract_tests.bzl b/tests/pypi/whl_extract/whl_extract_tests.bzl new file mode 100644 index 0000000000..f7e1e6d2cf --- /dev/null +++ b/tests/pypi/whl_extract/whl_extract_tests.bzl @@ -0,0 +1,119 @@ +"""Tests for whl_extract and gen_wheel_record.""" + +load("@rules_testing//lib:analysis_test.bzl", "analysis_test") +load("@rules_testing//lib:test_suite.bzl", "test_suite") +load("@rules_testing//lib:util.bzl", rt_util = "util") +load( + "//python/private/pypi:gen_wheel_record.bzl", # buildifier: disable=bzl-visibility + "gen_wheel_record", +) +load( + "//tests/support/platforms:platforms.bzl", # buildifier: disable=bzl-visibility + "platform_targets", +) + +_tests = [] + +def _test_gen_wheel_record(name): + rt_util.helper_target( + native.genrule, + name = name + "_src", + outs = [name + "_orig/alpha-1.0.dist-info/RECORD"], + cmd = "echo 'alpha-1.0.data/scripts/foo.sh' > $@", + ) + rt_util.helper_target( + gen_wheel_record, + name = name + "_subject", + srcs = [":" + name + "_src"], + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_gen_wheel_record_impl, + ) + +_tests.append(_test_gen_wheel_record) + +def _test_gen_wheel_record_impl(env, target): + files = target[DefaultInfo].files.to_list() + env.expect.that_collection(files).has_size(1) + env.expect.that_str(files[0].short_path).contains( + "site-packages/alpha-1.0.dist-info/RECORD", + ) + +def _test_gen_wheel_record_windows(name): + rt_util.helper_target( + native.genrule, + name = name + "_src", + outs = [name + "_orig/beta-1.0.dist-info/RECORD"], + cmd = "echo 'beta-1.0.data/scripts/foo.sh' > $@", + ) + rt_util.helper_target( + gen_wheel_record, + name = name + "_subject", + srcs = [":" + name + "_src"], + ) + analysis_test( + name = name, + target = name + "_subject", + config_settings = { + "//command_line_option:platforms": [ + platform_targets.WINDOWS_X86_64, + ], + }, + impl = _test_gen_wheel_record_windows_impl, + ) + +_tests.append(_test_gen_wheel_record_windows) + +def _test_gen_wheel_record_windows_impl(env, target): + files = target[DefaultInfo].files.to_list() + env.expect.that_collection(files).has_size(1) + env.expect.that_str(files[0].short_path).contains( + "site-packages/beta-1.0.dist-info/RECORD", + ) + +def _test_gen_wheel_record_multiple_srcs(name): + rt_util.helper_target( + native.genrule, + name = name + "_src1", + outs = [name + "_orig1/gamma-1.0.dist-info/RECORD"], + cmd = "echo 'gamma-1.0.data/scripts/foo.sh' > $@", + ) + rt_util.helper_target( + native.genrule, + name = name + "_src2", + outs = [name + "_orig2/delta-2.0.dist-info/RECORD"], + cmd = "echo 'delta-2.0.data/scripts/bar.sh' > $@", + ) + rt_util.helper_target( + gen_wheel_record, + name = name + "_subject", + srcs = [":" + name + "_src1", ":" + name + "_src2"], + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _test_gen_wheel_record_multiple_srcs_impl, + ) + +_tests.append(_test_gen_wheel_record_multiple_srcs) + +def _test_gen_wheel_record_multiple_srcs_impl(env, target): + files = target[DefaultInfo].files.to_list() + env.expect.that_collection(files).has_size(2) + paths = [f.short_path for f in files] + env.expect.that_bool( + any(["site-packages/gamma-1.0.dist-info/RECORD" in p for p in paths]), + ).equals(True) + env.expect.that_bool( + any(["site-packages/delta-2.0.dist-info/RECORD" in p for p in paths]), + ).equals(True) + +def whl_extract_test_suite(name): + """Create the test suite. + + Args: + name: the name of the test suite + """ + test_suite(name = name, tests = _tests) diff --git a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl index d752159b32..3fe1b99768 100644 --- a/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl +++ b/tests/pypi/whl_library_targets/whl_library_targets_tests.bzl @@ -30,7 +30,7 @@ def _test_filegroups(env): def glob(include, *, exclude = [], allow_empty): _ = exclude # @unused env.expect.that_bool(allow_empty).equals(True) - if include == ["rewrite-bin/*"] or include == ["bin/*"]: + if include in [["rewrite-bin/*"], ["bin/*"], ["rewrite-record/*/RECORD"]]: return [] return include @@ -42,6 +42,7 @@ def _test_filegroups(env): ), rules = struct( venv_rewrite_shebang = lambda **kwargs: None, + gen_wheel_record = lambda **kwargs: None, ), ) @@ -84,6 +85,7 @@ def _test_copy(env): rules = struct( copy_file = lambda **kwargs: calls.append(kwargs), venv_rewrite_shebang = lambda **kwargs: None, + gen_wheel_record = lambda **kwargs: None, ), ) @@ -242,6 +244,7 @@ def _test_sdist_excludes_record(env): m_glob = mocks.glob() m_glob.results.append([]) # bin m_glob.results.append([]) # rewrite-bin + m_glob.results.append([]) # rewrite-record m_glob.results.append([]) # srcs m_glob.results.append([]) # data m_glob.results.append([]) # pyi @@ -259,6 +262,7 @@ def _test_sdist_excludes_record(env): py_library = lambda **kwargs: py_library_calls.append(kwargs), create_inits = lambda **kwargs: [], venv_rewrite_shebang = lambda **kwargs: None, + gen_wheel_record = lambda **kwargs: None, ), ) @@ -284,6 +288,7 @@ def _test_exclude_bazel_files(env): m_glob = mocks.glob() m_glob.results.append([]) # bin m_glob.results.append([]) # rewrite-bin + m_glob.results.append([]) # rewrite-record m_glob.results.append([]) # extracted_whl_files m_glob.results.append([]) # dist_info m_glob.results.append([]) # data @@ -297,6 +302,7 @@ def _test_exclude_bazel_files(env): ), rules = struct( venv_rewrite_shebang = lambda **kwargs: None, + gen_wheel_record = lambda **kwargs: None, ), ) @@ -314,6 +320,7 @@ def _test_exclude_bazel_files(env): env.expect.that_collection(m_glob.calls).contains_exactly([ mocks.glob_call(["bin/*"], allow_empty = True), mocks.glob_call(["rewrite-bin/*"], allow_empty = True), + mocks.glob_call(["rewrite-record/*/RECORD"], allow_empty = True), mocks.glob_call( include = ["**"], exclude = expected_exclude, diff --git a/tests/venv_site_packages_libs/importlib_metadata_test.py b/tests/venv_site_packages_libs/importlib_metadata_test.py index 963d43b6e0..eb16a6b6af 100644 --- a/tests/venv_site_packages_libs/importlib_metadata_test.py +++ b/tests/venv_site_packages_libs/importlib_metadata_test.py @@ -1,4 +1,6 @@ import importlib.metadata +import pathlib +import sys import unittest @@ -10,13 +12,79 @@ def test_importlib_metadata_files(self): len(files), 0, "importlib.metadata.files returned empty list" ) - # Verify it contains some expected files. - # The RECORD file lists paths relative to the installation root (site-packages). - # whl_with_data1-1.0.data/purelib/data_overlap.py should be installed as data_overlap.py - # whl_with_data1-1.0.data/platlib/whl_with_data1/platlib_file.txt should be whl_with_data1/platlib_file.txt + # Verify it contains expected files. + # The RECORD file lists paths relative to the installation root + # (site-packages). + # Per PEP 376 and PEP 427: + # - purelib and platlib files are installed directly under + # site-packages: + # whl_with_data1-1.0.data/purelib/data_overlap.py should be + # installed as data_overlap.py, and + # whl_with_data1-1.0.data/platlib/whl_with_data1/platlib_file.txt + # should be whl_with_data1/platlib_file.txt. + # - scripts, headers, and data files installed outside site-packages + # are recorded relative to site-packages traversing up to the venv + # root (e.g. ../../../bin/ on POSIX, ../../Scripts/ on Windows). + if sys.platform == "win32": + scripts_prefix = "../../Scripts/" + headers_prefix = "../../Include/" + data_prefix = "../../" + else: + scripts_prefix = "../../../bin/" + headers_prefix = "../../../include/" + data_prefix = "../../../" - file_names = [f.name for f in files] - self.assertIn("data_overlap.py", file_names) + expected_paths = sorted( + [ + scripts_prefix + "data_overlap.sh", + data_prefix + "bin/data_overlap.sh", + scripts_prefix + "overlap/both.sh", + scripts_prefix + "overlap/script1.sh", + scripts_prefix + "whl_script.sh", + scripts_prefix + "whl_with_data1_script", + headers_prefix + "data_overlap.h", + data_prefix + "include/data_overlap.h", + headers_prefix + "overlap/both.h", + headers_prefix + "overlap/header1.h", + headers_prefix + "whl_with_data1/header_file.h", + data_prefix + "overlap/both.txt", + data_prefix + "overlap/data1.txt", + data_prefix + "site-packages/data_overlap.py", + data_prefix + "whl_with_data1/data_data_file.txt", + data_prefix + "whl_with_data1/data_data_file.txt", + "data_overlap.py", + "whl_with_data1/data_file.txt", + "whl_with_data1/platlib_file.txt", + ] + ) + file_paths = sorted(str(f).replace("\\", "/") for f in files) + self.assertEqual(file_paths, expected_paths) + + for f in files: + resolved = pathlib.Path(f.locate()) + if resolved.exists(): + self.assertTrue( + resolved.is_file(), + f"Expected {resolved} to be a regular file", + ) + + # Verify file content can be read both as binary and as text + content = f.read_binary() + self.assertIsNotNone(content) + + text = f.read_text(encoding="utf-8") + self.assertIsNotNone(text) + else: + # On Windows, venv bin scripts have a .bat extension appended. + bat_resolved = resolved.parent / (resolved.name + ".bat") + self.assertTrue( + bat_resolved.exists(), + f"Expected file {f} (resolved to {resolved} or {bat_resolved}) to exist", + ) + self.assertTrue( + bat_resolved.is_file(), + f"Expected {bat_resolved} to be a regular file", + ) if __name__ == "__main__":