Skip to content

Instantly share code, notes, and snippets.

@bluec0re
Last active June 6, 2025 09:37
Show Gist options
  • Save bluec0re/9e1ff112b337a12bf2f0694f48977513 to your computer and use it in GitHub Desktop.
Save bluec0re/9e1ff112b337a12bf2f0694f48977513 to your computer and use it in GitHub Desktop.
#!/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