Last active
June 6, 2025 09:37
-
-
Save bluec0re/9e1ff112b337a12bf2f0694f48977513 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Updates Bazel module dependencies defined in a MODULE.bazel file. | |
This script parses a MODULE.bazel file, identifies 'bazel_dep' calls, | |
checks the Bazel Central Registry (BCR) for newer versions, and updates | |
the MODULE.bazel file with the latest versions found. | |
""" | |
import argparse | |
import ast | |
import json | |
import pathlib | |
import sys | |
import http.client | |
from copy import copy | |
from typing import Any | |
# ANSI escape codes for colors | |
RESET = "\033[0m" | |
BOLD = "\033[1m" | |
CYAN = "\033[96m" | |
YELLOW = "\033[93m" | |
GREEN = "\033[92m" | |
MAGENTA = "\033[95m" | |
RED = "\033[91m" | |
# Box drawing characters | |
BOX_HORIZONTAL = "─" | |
BOX_VERTICAL = "│" | |
BOX_TOP_LEFT = "┌" | |
BOX_TOP_RIGHT = "┐" | |
BOX_BOTTOM_LEFT = "└" | |
BOX_BOTTOM_RIGHT = "┘" | |
BOX_TOP_SEPARATOR = "┬" | |
BOX_BOTTOM_SEPARATOR = "┴" | |
BOX_MIDDLE_SEPARATOR = "┼" | |
BOX_LEFT_SEPARATOR = "├" | |
BOX_RIGHT_SEPARATOR = "┤" | |
# Emojis | |
INFO_EMOJI = "ℹ️" | |
SUCCESS_EMOJI = "✅" | |
WARNING_EMOJI = "⚠️" | |
ERROR_EMOJI = "❌" | |
UPDATE_EMOJI = "⬆️" | |
SEARCH_EMOJI = "🔍" | |
def print_table(data: list[dict[str, str]]): | |
""" | |
Prints a list of dictionaries as a formatted table with box characters. | |
The table will have colored headers and specific columns ('name', 'version') | |
will also be colored for better readability. | |
Args: | |
data: A list of dictionaries, where each dictionary represents a row. | |
""" | |
# Determine maximum column widths | |
column_widths: dict[str, int] = {} | |
for row in data: | |
for key, value in row.items(): | |
width = len(str(value)) | |
column_widths[key] = max(len(key), max(column_widths.get(key, 0), width)) | |
# Print header | |
header = list(column_widths.keys()) | |
header_str = ( | |
f"{MAGENTA}{BOX_TOP_LEFT}{BOX_HORIZONTAL}{RESET}" | |
+ f"{MAGENTA}{BOX_HORIZONTAL}{BOX_TOP_SEPARATOR}{BOX_HORIZONTAL}{RESET}".join( | |
f"{BOLD}{CYAN}{key:^{column_widths[key]}}{RESET}" for key in header | |
) | |
+ f"{MAGENTA}{BOX_HORIZONTAL}{BOX_TOP_RIGHT}{RESET}" | |
) | |
print(header_str) | |
# Print data rows | |
for row in data: | |
row_items: list[str] = [] | |
for key in header: | |
value_str = str(row.get(key, "")).ljust(column_widths[key]) | |
if key == "name": | |
row_items.append(f"{YELLOW}{value_str}{RESET}") | |
elif key == "version": | |
row_items.append(f"{GREEN}{value_str}{RESET}") | |
else: | |
row_items.append(value_str) | |
print( | |
f"{MAGENTA}{BOX_VERTICAL}{RESET} " | |
+ f" {MAGENTA}{BOX_VERTICAL}{RESET} ".join(row_items) | |
+ f" {MAGENTA}{BOX_VERTICAL}{RESET}" | |
) | |
# Print footer | |
footer_str = ( | |
f"{MAGENTA}{BOX_BOTTOM_LEFT}{BOX_HORIZONTAL}" | |
+ f"{BOX_HORIZONTAL}{BOX_BOTTOM_SEPARATOR}{BOX_HORIZONTAL}".join( | |
f"{BOX_HORIZONTAL * column_widths[key]}" for key in header | |
) | |
+ f"{BOX_HORIZONTAL}{BOX_BOTTOM_RIGHT}{RESET}" | |
) | |
print(footer_str) | |
def fetch_metadata(name: str) -> None | dict[str, Any]: | |
""" | |
Fetches module metadata from the Bazel Central Registry (BCR). | |
Args: | |
name: The name of the Bazel module. | |
Returns: | |
A dictionary containing the module's metadata if successful, | |
otherwise None. | |
""" | |
conn = http.client.HTTPSConnection("bcr.bazel.build") | |
conn.request("GET", f"/modules/{name}/metadata.json") | |
resp = conn.getresponse() | |
if resp.status != 200: | |
print( | |
f"{ERROR_EMOJI} {RED}Error fetching metadata for {name}: {resp.status} {resp.reason}{RESET}" | |
) | |
return None | |
metadata = json.load(resp) | |
return metadata | |
def has_update(name: str, version: str) -> tuple[str, str] | None: | |
""" | |
Checks if a newer version of a Bazel module is available in the BCR. | |
Args: | |
name: The name of the Bazel module. | |
version: The current version of the module. | |
Returns: | |
A tuple containing the module name and the latest version string | |
if an update is available, otherwise None. | |
""" | |
print(f"{INFO_EMOJI} Checking {CYAN}{name}{RESET}@{YELLOW}{version}{RESET}...") | |
metadata = fetch_metadata(name) | |
if not metadata: | |
print( | |
f" {WARNING_EMOJI} {YELLOW}{name} not found in BCR (Bazel Central Registry).{RESET}" | |
) | |
return None | |
latest_version = metadata["versions"][-1] | |
if version != latest_version: | |
print( | |
f" {UPDATE_EMOJI} {GREEN}Update available for {CYAN}{name}{RESET}: {YELLOW}{version}{RESET} -> {BOLD}{GREEN}{latest_version}{RESET}" | |
) | |
return name, latest_version | |
else: | |
print( | |
f" {SUCCESS_EMOJI} {CYAN}{name}{RESET}@{GREEN}{version}{RESET} is up to date." | |
) | |
return None | |
def look_for_updates( | |
deps: list[dict[str, ast.Constant]], | |
) -> list[tuple[str, ast.Constant]]: | |
""" | |
Iterates through a list of dependencies and checks for available updates. | |
Args: | |
deps: A list of dictionaries, where each dictionary represents a | |
dependency and contains 'name' and 'version' (as ast.Constant) keys. | |
Returns: | |
A list of tuples, where each tuple contains the module name (str) and | |
an updated ast.Constant node for the new version. | |
""" | |
print(f"\n{SEARCH_EMOJI} {BOLD}Looking for updates...{RESET}") | |
updates: list[tuple[str, ast.Constant]] = [] | |
for dep in deps: | |
update_info = has_update(dep["name"].value, dep["version"].value) | |
if not update_info: | |
continue | |
v = copy(dep["version"]) | |
v.value = update_info[1] # new_version string | |
updates.append((update_info[0], v)) # name, version_ast_node | |
# Sort updates by line number in descending order to avoid issues | |
# when modifying the file content later (changes in later lines | |
# won't affect the line numbers of earlier changes). | |
updates.sort(key=lambda x: -x[1].lineno) | |
return updates | |
def main(argv: list[str]): | |
""" | |
Main function to parse arguments, find dependencies, check for updates, | |
and update the MODULE.bazel file. | |
""" | |
parser = argparse.ArgumentParser(description="Update bazel module dependencies.") | |
parser.add_argument("workspace", help="The workspace to update.") | |
args = parser.parse_args(args=argv[1:]) | |
module_file = pathlib.Path(args.workspace) / "MODULE.bazel" | |
module_ast = ast.parse(module_file.read_text()) | |
# Extract bazel_dep calls and their 'name' and 'version' arguments | |
# from the MODULE.bazel AST. | |
deps = [ | |
{ | |
kw.arg: kw.value | |
for kw in dep.keywords | |
if kw.arg in ["name", "version"] and isinstance(kw.value, ast.Constant) | |
} | |
for node in module_ast.body | |
for dep in ast.walk(node) | |
if isinstance(dep, ast.Call) | |
and isinstance(dep.func, ast.Name) | |
and dep.func.id == "bazel_dep" | |
and all( | |
any(kw.arg == name for kw in dep.keywords) for name in ["name", "version"] | |
) | |
] | |
print(f"{INFO_EMOJI} {BOLD}Current dependencies in {module_file.name}:{RESET}") | |
print_table([{k: v.value for k, v in dep.items()} for dep in deps]) | |
updates: list[tuple[str, ast.Constant]] = look_for_updates(deps) | |
lines = module_file.read_text().splitlines() | |
# Apply updates to the lines of the MODULE.bazel file. | |
for name, version_item in updates: | |
print( | |
f"{UPDATE_EMOJI} {BOLD}Updating {CYAN}{name}{RESET} to {GREEN}{version_item.value}{RESET} in {module_file.name}..." | |
) | |
assert version_item.lineno == version_item.end_lineno | |
l = lines[version_item.lineno - 1] | |
lines[version_item.lineno - 1] = ( | |
# Reconstruct the line with the new version string, | |
# preserving original formatting around the version value. | |
l[: version_item.col_offset] | |
+ f'"{version_item.value}"' | |
+ l[version_item.end_col_offset :] | |
) | |
module_file.write_text("\n".join(lines) + "\n") | |
if updates: | |
print(f"\n{SUCCESS_EMOJI} {GREEN}All updates applied successfully!{RESET}") | |
else: | |
print( | |
f"\n{INFO_EMOJI} {CYAN}No updates found. Everything is up to date!{RESET}" | |
) | |
return None | |
if __name__ == "__main__": | |
sys.exit(main(sys.argv)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment