| #!/usr/bin/env python3 |
| # |
| # Copyright 2021 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. |
| |
| import argparse |
| import importlib.util |
| import json |
| import locale |
| import pathlib |
| import re |
| import subprocess |
| import sys |
| import tempfile |
| import os |
| |
| from urllib.parse import urlparse |
| from registry import RegistryClient |
| |
| # The registry client points to the bazel central registry repo |
| REGISTRY_CLIENT = RegistryClient(pathlib.Path(__file__).resolve().parent.joinpath("../")) |
| |
| USE_REPO_RULE_IDENTIFIER = "# -- use_repo_rule statements -- #" |
| LOAD_IDENTIFIER = "# -- load statements -- #" |
| REPO_IDENTIFIER = "# -- repo definitions -- #" |
| BAZEL_DEP_IDENTIFIER = "# -- bazel_dep definitions -- #" |
| |
| |
| def abort_migration(): |
| info("Abort migration...") |
| exit(2) |
| |
| |
| def assertExitCode(exit_code, expected_exit_code, error_message, stderr): |
| if exit_code != expected_exit_code: |
| error(f"Command exited with {exit_code}, expected {expected_exit_code}:") |
| eprint(error_message) |
| eprint(stderr) |
| abort_migration() |
| |
| |
| def eprint(*args, **kwargs): |
| """ |
| Print to stderr and flush (just in case). |
| """ |
| print(*args, flush=True, file=sys.stderr, **kwargs) |
| |
| |
| BOLD = "\033[1m" |
| GREEN = "\x1b[32m\033[1m" |
| YELLOW = "\x1b[33m\033[1m" |
| RED = "\x1b[31m\033[1m" |
| RESET = "\033[0m" |
| |
| |
| def info(msg): |
| eprint(msg) |
| |
| |
| def resolved(msg): |
| eprint(f"{GREEN}RESOLVED: {RESET}{msg}") |
| |
| |
| def important(msg): |
| eprint(f"{YELLOW}IMPORTANT: {RESET}{msg}") |
| |
| |
| def action(msg): |
| eprint(f"{RED}ACTION NEEDED: {RESET}{msg}") |
| |
| |
| def warning(msg): |
| eprint(f"{YELLOW}WARNING: {RESET}{msg}") |
| |
| |
| def error(msg): |
| eprint(f"{RED}ERROR: {RESET}{msg}") |
| |
| |
| def ask_input(msg): |
| return input(f"{YELLOW}ACTION: {RESET}{msg}") |
| |
| |
| def yes_or_no(question, default): |
| if not yes_or_no.enable: |
| return default |
| |
| if default: |
| question += " [Y/n]: " |
| else: |
| question += " [y/N]: " |
| |
| var = None |
| while var is None: |
| user_input = ask_input(question).strip().lower() |
| if user_input == "y": |
| var = True |
| elif user_input == "n": |
| var = False |
| elif not user_input: |
| var = default |
| else: |
| eprint(f"Invalid selection: {user_input}") |
| return var |
| |
| |
| def scratch_file(file_path, lines=None, mode="w"): |
| """Write lines to a file.""" |
| abspath = pathlib.Path(file_path) |
| with open(abspath, mode) as f: |
| if lines: |
| for l in lines: |
| f.write(l) |
| f.write("\n") |
| return abspath |
| |
| |
| def append_to_file(filename, content): |
| """ |
| Creates a file with the given filename and content. |
| |
| Args: |
| filename (str): The name of the file to create. |
| content (str): The content to write to the file. |
| """ |
| try: |
| with open(filename, "a") as f: |
| f.write(content) |
| except OSError as e: |
| error(f"Error creating file '{filename}': {e}") |
| |
| |
| def append_migration_info(content): |
| """Adds content to the "migration_info" file in order to help users with details about the migration.""" |
| append_to_file("migration_info.md", content + "\n") |
| |
| |
| def execute_command(args, to_print=False, cwd=None, env=None, shell=False, executable=None): |
| if to_print: |
| info("Executing command: " + " ".join(args)) |
| with tempfile.TemporaryFile() as stdout: |
| with tempfile.TemporaryFile() as stderr: |
| proc = subprocess.Popen( |
| args, |
| executable=executable, |
| stdout=stdout, |
| stderr=stderr, |
| cwd=cwd, |
| env=env, |
| shell=shell, |
| ) |
| exit_code = proc.wait() |
| |
| stdout.seek(0) |
| stdout_result = stdout.read().decode(locale.getpreferredencoding()) |
| stderr.seek(0) |
| stderr_result = stderr.read().decode(locale.getpreferredencoding()) |
| return exit_code, stdout_result, stderr_result |
| |
| |
| def print_repo_definition(repo_def, dep): |
| repo_def_str = "\n".join(repo_def) |
| append_migration_info(f""" |
| <details> |
| <summary>Click here to see where and how the repo was declared in the WORKSPACE file</summary> |
| |
| #### Location |
| ```python |
| {dep["definition_information"]} |
| ``` |
| |
| #### Definition |
| ```python |
| {repo_def_str} |
| ``` |
| **Tip**: URLs usually show which version was used. |
| </details> |
| """) |
| append_migration_info("___") |
| |
| |
| def repo_definition(dep): |
| """Print the repository info to migration_info and return the repository definition.""" |
| # Parse the repository rule class (rule name, and the label for the bzl file where the rule is defined.) |
| rule_class = dep["original_rule_class"] |
| is_macro = False |
| if rule_class.find("%") != -1: |
| # Starlark rule |
| file_label, rule_name = rule_class.split("%") |
| # If the original macro is not publicly visible, we trace back to fine a visible one. |
| if rule_name.startswith("_"): |
| is_macro = True |
| def_info = dep["definition_information"].split("\n") |
| def_info.reverse() |
| for line in def_info: |
| s = re.match(r"^ (.+):[0-9]+:[0-9]+: in ([^\_<].+)$", line) |
| if s: |
| new_file_name, new_rule_name = s.groups() |
| if new_file_name.endswith(file_label.split("//")[1].replace(":", "/")): |
| rule_name = new_rule_name |
| else: |
| warning( |
| f"A visible macro for {rule_name} is defined in a different bzl file `{new_file_name}` " |
| f"other than `{file_label}`, " |
| f"you have to find out the correct label for `{new_file_name}` manually." |
| ) |
| break |
| else: |
| # Native rule |
| file_label = None |
| rule_name = rule_class |
| |
| # Generate the repository definition lines. |
| repo_def = [] |
| if file_label: |
| repo_def.append(f'load("{file_label}", "{rule_name}")') |
| repo_def.append(f"{rule_name}(") |
| for key, value in dep["original_attributes"].items(): |
| if not key.startswith("generator_"): |
| value_str = json.dumps(value, indent=4) |
| # Fix indentation |
| if value_str.endswith("}") or value_str.endswith("]"): |
| value_str = value_str[:-1] + " " + value_str[-1] |
| # Fix boolean format |
| if value_str == "false" or value_str == "true": |
| value_str = value_str[0].upper() + value_str[1:] |
| repo_def.append(f" {key} = {value_str},") |
| repo_def.append(")") |
| |
| if file_label and file_label.startswith("@@"): |
| file_label = file_label[1:] |
| |
| return repo_def, file_label, rule_name, is_macro |
| |
| |
| def detect_unavailable_repo_error(stderr): |
| PATTERNS = [ |
| re.compile(r"unknown repo '([A-Za-z0-9_-]+)' requested from"), |
| re.compile(r"The repository '@([A-Za-z0-9_-]+)' could not be resolved"), |
| re.compile(r"No repository visible as '@([A-Za-z0-9_-]+)' from main repository"), |
| re.compile(r"This could either mean you have to add the '@([A-Za-z0-9_-]+)' repository"), |
| re.compile(r"no repo visible as '@([A-Za-z0-9_-]+)' here"), |
| ] |
| |
| for line in stderr.split("\n"): |
| for p in PATTERNS: |
| m = p.search(line) |
| if m: |
| return m.groups()[0] |
| |
| return None |
| |
| |
| def write_at_given_place(filename, new_content, identifier): |
| """Write content to a file at a position marked by the identifier.""" |
| file_content = "" |
| with open(filename, "r") as f: |
| file_content = f.read() |
| file_content = file_content.replace( |
| identifier, |
| new_content + "\n" + identifier, |
| 1, |
| ) |
| with open(filename, "w") as f: |
| f.write(file_content) |
| |
| |
| def add_repo_with_use_repo_rule(repo, repo_def, file_label, rule_name): |
| """Introduce a repository with use_repo_rule in the MODULE.bazel file.""" |
| use_repo_rule = f'{rule_name} = use_repo_rule("{file_label}", "{rule_name}")' |
| |
| # Check if the use_repo_rule is already in the MODULE.bazel file |
| module_bazel_content = open("MODULE.bazel", "r").read() |
| if use_repo_rule not in module_bazel_content: |
| write_at_given_place("MODULE.bazel", use_repo_rule, USE_REPO_RULE_IDENTIFIER) |
| |
| # Add the repo definition to the MODULE.bazel file |
| write_at_given_place( |
| "MODULE.bazel", |
| "\n".join([""] + repo_def[1:]), |
| REPO_IDENTIFIER, |
| ) |
| |
| |
| def add_repo_to_module_extension(repo, repo_def, file_label, rule_name): |
| """Introduce a repository via a module extension.""" |
| # If the repo was not defined in @bazel_tools, |
| # we need to create a separate module extension for it to avoid cycle. |
| if rule_name.startswith("_"): |
| rule_name = rule_name[1:] |
| need_separate_module_extension = not file_label.startswith("@bazel_tools") |
| ext_name = f"extension_for_{rule_name}".replace("-", "_") if need_separate_module_extension else "non_module_deps" |
| ext_bzl_name = ext_name + ".bzl" |
| |
| # Generate the initial bzl file for the module extension |
| if not pathlib.Path(ext_bzl_name).is_file(): |
| scratch_file( |
| ext_bzl_name, |
| [ |
| LOAD_IDENTIFIER, |
| "", |
| f"def _{ext_name}_impl(ctx):", |
| REPO_IDENTIFIER, |
| "", |
| f"{ext_name} = module_extension(implementation = _{ext_name}_impl)", |
| ], |
| ) |
| |
| # Add repo definition to the module extension's bzl file |
| imported_rule_statement = f'"{rule_name}"' |
| load_statement = f'load("{file_label}", {imported_rule_statement})' |
| bzl_content = open(ext_bzl_name, "r").read() |
| if imported_rule_statement not in bzl_content: |
| write_at_given_place(ext_bzl_name, load_statement, LOAD_IDENTIFIER) |
| write_at_given_place( |
| ext_bzl_name, |
| "\n".join([" " + line.replace("\n", "\n ") for line in repo_def[1:]]), |
| REPO_IDENTIFIER, |
| ) |
| |
| # Add use_repo statement in the MODULE.bazel file |
| use_ext = f'{ext_name} = use_extension("//:{ext_name}.bzl", "{ext_name}")' |
| module_bazel_content = open("MODULE.bazel", "r").read() |
| ext_identifier = f"# End of extension `{ext_name}`" |
| if use_ext not in module_bazel_content: |
| scratch_file("MODULE.bazel", ["", use_ext, ext_identifier], mode="a") |
| write_at_given_place("MODULE.bazel", f'use_repo({ext_name}, "{repo}")', ext_identifier) |
| |
| |
| def url_match_source_repo(source_url, module_name): |
| source_repositories = REGISTRY_CLIENT.get_metadata(module_name).get("repository", []) |
| matched = False |
| parts = urlparse(source_url) |
| for source_repository in source_repositories: |
| if matched: |
| break |
| repo_type, repo_path = source_repository.split(":") |
| # Include repos which were moved to bazel-contrib: |
| # https://github.com/orgs/bazelbuild/discussions/2#discussioncomment-10671359. |
| repo_path = repo_path.replace("bazel-contrib/", "bazelbuild/") |
| if repo_type == "github": |
| matched = ( |
| parts.scheme == "https" |
| and parts.netloc == "github.com" |
| and ( |
| os.path.abspath(parts.path).startswith(f"/{repo_path}/") |
| or os.path.abspath(parts.path).startswith(f"/{repo_path}.git") |
| ) |
| ) |
| elif repo_type == "https": |
| repo = urlparse(source_repository) |
| matched = ( |
| parts.scheme == repo.scheme |
| and parts.netloc == repo.netloc |
| and os.path.abspath(parts.path).startswith(f"{repo.path}/") |
| ) |
| return matched |
| |
| |
| def exists_in_file(filename, content): |
| with open(filename, "r") as f: |
| return content in f.read() |
| |
| |
| def add_go_extension(repo, origin_attrs, resolved_deps, workspace_name): |
| # Introduce `bazel_gazelle` only once. |
| if not exists_in_file("MODULE.bazel", 'bazel_dep(name = "gazelle'): |
| address_unavailable_repo("bazel_gazelle", resolved_deps, workspace_name) |
| |
| # Introduce `io_bazel_rules_go` only once. |
| if not exists_in_file("MODULE.bazel", 'bazel_dep(name = "rules_go'): |
| address_unavailable_repo("io_bazel_rules_go", resolved_deps, workspace_name) |
| |
| # Add go_deps |
| if not exists_in_file("MODULE.bazel", 'use_extension("@bazel_gazelle//:extensions.bzl", "go_deps'): |
| go_deps = """ |
| go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps") |
| # -- End of go extension -- # |
| """ |
| write_at_given_place("MODULE.bazel", go_deps, REPO_IDENTIFIER) |
| |
| # Add go_sdk |
| if not exists_in_file("MODULE.bazel", '@io_bazel_rules_go//go:extensions.bzl", "go_sdk'): |
| go_sdk = """go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk") |
| """ |
| write_at_given_place("MODULE.bazel", go_sdk, "# -- End of go extension -- #") |
| |
| resolved("`" + repo + "` has been introduced as go extension.") |
| append_migration_info("## Migration of `" + repo + "`:") |
| |
| if os.path.exists("go.mod") and os.path.exists("go.sum"): |
| if exists_in_file("MODULE.bazel", origin_attrs["name"]): |
| append_migration_info("It has already been introduced as a go module with the help of `go.mod`.\n") |
| |
| if not exists_in_file("MODULE.bazel", 'go_deps.from_file(go_mod = "//:go.mod")'): |
| from_file = """go_deps.from_file(go_mod = "//:go.mod") |
| go_sdk.from_file(go_mod = "//:go.mod") |
| """ |
| write_at_given_place("MODULE.bazel", from_file, "# -- End of go extension -- #") |
| exit_code, stdout, _ = execute_command(["bazel", "mod", "tidy", "--enable_bzlmod"]) |
| assertExitCode(exit_code, 0, "Failed to run `bazel mod tidy`", stdout) |
| append_migration_info("It has been introduced as a go module with the help of `go.mod`:\n") |
| append_migration_info("```\n" + from_file + "```") |
| else: |
| go_module = ["go_deps.module("] |
| if "importpath" in origin_attrs: |
| go_module.append(' path = "' + origin_attrs["importpath"] + '",') |
| if "sum" in origin_attrs: |
| go_module.append(' sum = "' + origin_attrs["sum"] + '",') |
| if "version" in origin_attrs: |
| go_module.append(' version = "' + origin_attrs["version"] + '",') |
| elif "tag" in origin_attrs: |
| go_module.append(' version = "' + origin_attrs["tag"] + '",') |
| go_module.append(")\n") |
| |
| write_at_given_place( |
| "MODULE.bazel", |
| 'use_repo(go_deps, "' + origin_attrs["name"] + '")', |
| "# -- End of go extension -- #", |
| ) |
| write_at_given_place("MODULE.bazel", "\n".join(go_module), "use_repo(go_deps, ") |
| append_migration_info("It has been introduced as a go module:\n") |
| append_migration_info("```\n" + "\n".join(go_module) + "```") |
| |
| # Add gazelle_override if needed. |
| gazelle_override_attrs = [] |
| if "build_file_proto_mode" in origin_attrs: |
| gazelle_override_attrs.append('"gazelle:proto ' + origin_attrs["build_file_proto_mode"] + '",') |
| if "build_naming_convention" in origin_attrs: |
| gazelle_override_attrs.append('"gazelle:go_naming_convention ' + origin_attrs["build_naming_convention"] + '",') |
| if gazelle_override_attrs: |
| directives = "\n ".join(gazelle_override_attrs) |
| gazelle_override = f"""go_deps.gazelle_override( |
| path = "{origin_attrs["importpath"]}", |
| directives = [ |
| {directives} |
| ], |
| ) |
| """ |
| write_at_given_place("MODULE.bazel", gazelle_override, "# -- End of go extension -- #") |
| append_migration_info("Additionally, `gazelle_override` was used for the initial directives:\n") |
| append_migration_info("```\n" + gazelle_override + "```") |
| |
| |
| def add_maven_extension(repo, maven_artifacts, resolved_deps, workspace_name): |
| # Introduce `rules_jvm_external` only once. |
| if not exists_in_file("MODULE.bazel", 'bazel_dep(name = "rules_jvm_external'): |
| address_unavailable_repo("rules_jvm_external", resolved_deps, workspace_name) |
| |
| # Introduce `maven` extension only once. |
| if not exists_in_file("MODULE.bazel", 'maven = use_extension("@rules_jvm_external//:extensions.bzl"'): |
| maven_extension = """ |
| maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") |
| # -- End of maven extensions -- # |
| """ |
| write_at_given_place( |
| "MODULE.bazel", |
| maven_extension, |
| REPO_IDENTIFIER, |
| ) |
| |
| # Introduce repo rule for `repo` only once. |
| if not exists_in_file("MODULE.bazel", f'use_repo(maven, "{repo}")'): |
| repo_rule = f""" |
| use_repo(maven, "{repo}") |
| # -- End of maven artifacts for repo `{repo}` -- #""" |
| write_at_given_place( |
| "MODULE.bazel", |
| repo_rule, |
| "# -- End of maven extensions", |
| ) |
| |
| # Translate each maven artifact which is lacking in MODULE.bazel file. |
| for maven_artifact in maven_artifacts: |
| parsed_data = json.loads(maven_artifact) |
| group = parsed_data["group"] |
| |
| if exists_in_file("MODULE.bazel", 'group = "' + group): |
| continue |
| |
| append_migration_info("## Migration of `" + group + "` (" + repo + "):") |
| append_migration_info("It has been introduced as a maven artifact:\n") |
| |
| artifact = f""" |
| maven.artifact( |
| name = "{repo}", |
| group = "{group}", |
| artifact = "{parsed_data["artifact"]}", |
| version = "{parsed_data["version"]}" |
| )""" |
| write_at_given_place( |
| "MODULE.bazel", |
| artifact, |
| f"# -- End of maven artifacts for repo `{repo}` ", |
| ) |
| resolved("`" + group + "` has been introduced as maven extension.") |
| append_migration_info("```" + artifact + "\n```") |
| |
| |
| def add_python_extension(repo, origin_attrs, resolved_deps, workspace_name): |
| # Introduce `rules_python` only once. |
| if not exists_in_file("MODULE.bazel", 'bazel_dep(name = "rules_python"'): |
| address_unavailable_repo("rules_python", resolved_deps, workspace_name) |
| |
| # Introduce `pip` extension only once. |
| if not exists_in_file("MODULE.bazel", 'pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")'): |
| pip_extension = """ |
| pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") |
| # -- End of pip extensions -- # |
| """ |
| write_at_given_place( |
| "MODULE.bazel", |
| pip_extension, |
| REPO_IDENTIFIER, |
| ) |
| |
| # Determine python version to use. Check for an existing default or use 3.11. |
| python_version = "3.11" |
| try: |
| with open("MODULE.bazel", "r") as f: |
| match = re.search(r'python\.defaults\s*\(\s*python_version\s*=\s*"([^"]+)"', f.read()) |
| except FileNotFoundError: |
| match = None |
| |
| if match: |
| python_version = match.group(1) |
| important(f"Using existing default python version {python_version} from MODULE.bazel.") |
| else: |
| important( |
| f"{python_version} is used as a default python version. If you need a different version, please change it manually and then rerun the migration tool." |
| ) |
| |
| py_ext = f""" |
| pip.parse( |
| hub_name = "{repo}", |
| requirements_lock = "{origin_attrs["requirements_lock"]}", |
| python_version = "{python_version}", |
| ) |
| use_repo(pip, "{repo}") |
| """ |
| write_at_given_place( |
| "MODULE.bazel", |
| py_ext, |
| "# -- End of pip extensions -- #", |
| ) |
| |
| py_toolchain = [] |
| # Introduce `python` extension only once. |
| if not exists_in_file( |
| "MODULE.bazel", 'python = use_extension("@rules_python//python/extensions:python.bzl", "python")' |
| ): |
| py_toolchain.append('python = use_extension("@rules_python//python/extensions:python.bzl", "python")') |
| |
| # Introduce python default version only once. |
| if not exists_in_file("MODULE.bazel", "python.defaults(python_version ="): |
| py_toolchain.append(f'python.defaults(python_version = "{python_version}")') |
| |
| # Introduce python toolchain only once. |
| if not exists_in_file("MODULE.bazel", f'python.toolchain(python_version = "{python_version}")'): |
| py_toolchain.append(f'python.toolchain(python_version = "{python_version}")') |
| |
| py_toolchain_msg = "\n".join(py_toolchain) |
| write_at_given_place( |
| "MODULE.bazel", |
| py_toolchain_msg, |
| "# -- End of pip extensions -- #", |
| ) |
| |
| resolved("`" + repo + "` has been introduced as python extension.") |
| append_migration_info("## Migration of `" + repo + "`") |
| append_migration_info("It has been introduced as a python extension:\n") |
| append_migration_info("```" + py_ext + "\n" + py_toolchain_msg + "\n```") |
| |
| |
| def add_python_repo(repo): |
| append_migration_info("## Migration of `" + repo + "`") |
| append_migration_info("It has been introduced as a python repo:\n") |
| |
| py_repo = f'use_repo(python, "{repo}")' |
| write_at_given_place( |
| "MODULE.bazel", |
| py_repo, |
| "# -- End of pip extensions -- #", |
| ) |
| resolved("`" + repo + "` has been introduced as a python repo.") |
| append_migration_info("```\n" + py_repo + "\n```") |
| |
| |
| def address_unavailable_repo(repo, resolved_deps, workspace_name): |
| # Check if it's the original main repo name |
| if repo == workspace_name: |
| error_message = [] |
| error_message.append( |
| f"Please remove the usages of referring your own repo via `@{repo}//`, " |
| "targets should be referenced directly with `//`. " |
| ) |
| error_message.append( |
| 'If it\'s used in a macro, you can use `Label("//foo/bar")` ' |
| "to make sure it always points to your repo no matter where the macro is used." |
| ) |
| error_message.append( |
| "You can temporarily work around this by adding `repo_name` attribute " |
| "to the `module` directive in your MODULE.bazel file." |
| ) |
| error("\n".join(error_message)) |
| # TODO(kotlaja): Create more visible section for TODO. |
| append_migration_info("TODO: " + "\n".join(error_message)) |
| |
| # Print the repo definition in the original WORKSPACE file |
| repo_def, file_label, rule_name, is_macro = [], None, None, False |
| urls, maven_artifacts, origin_attrs = [], [], [] |
| for dep in resolved_deps: |
| if dep["original_attributes"]["name"] == repo: |
| repo_def, file_label, rule_name, is_macro = repo_definition(dep) |
| origin_attrs = dep["original_attributes"] |
| urls = origin_attrs.get("urls", []) |
| if "artifacts" in origin_attrs: |
| maven_artifacts = origin_attrs["artifacts"] |
| if origin_attrs.get("url", None): |
| urls.append(origin_attrs["url"]) |
| if origin_attrs.get("remote", None): |
| urls.append(origin_attrs["remote"]) |
| break |
| |
| if not repo_def: |
| msg = f"Repository definition for `{repo}` is not found in ./resolved_deps.py file, please add `--initial/-i` flag to force update it." |
| error(msg) |
| append_migration_info(msg) |
| return False |
| |
| # Support go extension. |
| if "bazel_gazelle" in file_label and "go_repository" in file_label: |
| add_go_extension(repo, origin_attrs, resolved_deps, workspace_name) |
| return True |
| |
| # Support maven extensions. |
| if "rules_jvm_external" in file_label and maven_artifacts: |
| add_maven_extension(repo, maven_artifacts, resolved_deps, workspace_name) |
| return True |
| |
| # Support python extension. |
| if "requirements_lock" in origin_attrs and "pip_repository" in file_label: |
| add_python_extension(repo, origin_attrs, resolved_deps, workspace_name) |
| return True |
| |
| # Support python toolchain dependencies. |
| if "generator_function" in origin_attrs and re.match(r"python_.*toolchains", origin_attrs["generator_function"]): |
| add_python_repo(repo) |
| return True |
| |
| append_migration_info("## Migration of `" + repo + "`:") |
| print_repo_definition(repo_def, dep) |
| |
| # Check if a module is already available in the registry. |
| found_module = None |
| potential_modules = [] |
| for module_name in REGISTRY_CLIENT.get_all_modules(): |
| if repo == module_name: |
| found_module = module_name |
| append_migration_info("Found perfect name match in BCR: `" + module_name + "`\n") |
| elif any(url_match_source_repo(url, module_name) for url in urls): |
| potential_modules.append(module_name) |
| if potential_modules: |
| append_migration_info("Found partially name matches in BCR: `" + "`, `".join(potential_modules) + ("`\n")) |
| if found_module == None and len(potential_modules) > 0: |
| found_module = potential_modules[0] |
| |
| if found_module: |
| metadata = REGISTRY_CLIENT.get_metadata(found_module) |
| version = metadata["versions"][-1] |
| repo_name = "" if repo == found_module else f', repo_name = "{repo}"' |
| bazel_dep_line = f'bazel_dep(name = "{found_module}", version = "{version}"{repo_name})' |
| |
| if not exists_in_file("MODULE.bazel", bazel_dep_line): |
| if yes_or_no( |
| "Do you wish to add the bazel_dep definition to the MODULE.bazel file?", |
| True, |
| ): |
| append_migration_info("It has been introduced as a Bazel module:\n") |
| append_migration_info("\t" + bazel_dep_line + "") |
| resolved("`" + repo + "` has been introduced as a Bazel module.") |
| write_at_given_place("MODULE.bazel", bazel_dep_line, BAZEL_DEP_IDENTIFIER) |
| return True |
| else: |
| append_migration_info("This module has already been added inside the MODULE.bazel file") |
| return True |
| else: |
| append_migration_info("\tIt is not found in BCR. \n") |
| |
| # Ask user if the dependency should be introduced via use_repo_rule |
| # Only ask if the repo is defined in @bazel_tools or the root module to avoid potential cycle. |
| if ( |
| file_label |
| and not is_macro |
| and file_label.startswith(("//", "@bazel_tools//")) |
| and yes_or_no( |
| "Do you wish to introduce the repository with use_repo_rule in MODULE.bazel (requires Bazel 7.3 or later)?", |
| True, |
| ) |
| ): |
| append_migration_info("\tIt has been introduced with `use_repo_rule`:\n") |
| resolved("`" + repo + "` has been introduced with `use_repo_rule`.") |
| add_repo_with_use_repo_rule(repo, repo_def, file_label, rule_name) |
| return True |
| |
| # Ask user if the dependency should be introduced via module extension |
| # Only ask when file_label exists, which means it's a starlark repository rule. |
| elif file_label and yes_or_no("Do you wish to introduce the repository with a module extension?", True): |
| append_migration_info("\tIt has been introduced using a module extension:\n") |
| resolved("`" + repo + "` has been introduced using a module extension.") |
| add_repo_to_module_extension(repo, repo_def, file_label, rule_name) |
| return True |
| elif rule_name == "local_repository" and repo != "bazel_tools": |
| append_migration_info("\tIt has been introduced using a module extension since it is local_repository rule:\n") |
| resolved("`" + repo + "` has been introduced using a module extension (local_repository).") |
| add_repo_to_module_extension(repo, repo_def, "@bazel_tools//tools/build_defs/repo:local.bzl", rule_name) |
| return True |
| |
| append_migration_info("\tPlease manually add this dependency.") |
| return False |
| |
| |
| def detect_bind_issue(stderr): |
| """Search for error message that maybe caused by missing bind statements and return the missing target and its location.""" |
| for line in stderr.split("\n"): |
| s = re.search(r"ERROR: (.*): no such package 'external':", line) |
| if s: |
| return s.groups()[0] |
| return None |
| |
| |
| def address_bind_issue(bind_target_location, resolved_repos): |
| print("") |
| error( |
| f"A bind target detected at {bind_target_location}! `bind` is already deprecated," |
| " you should reference the actual target directly instead of using //external:<target>" |
| " (details at https://bazel.build/external/migration#bind-targets). After this fix, rerun this tool." |
| ) |
| print("") |
| |
| |
| def extract_version_number(bazel_version): |
| """Extracts the semantic version number from a version string |
| Args: |
| bazel_version: the version string that begins with the semantic version |
| e.g. "1.2.3rc1 abc1234" where "abc1234" is a commit hash. |
| Returns: |
| The semantic version string, like "1.2.3". |
| """ |
| for i in range(len(bazel_version)): |
| c = bazel_version[i] |
| if not (c.isdigit() or c == "."): |
| return bazel_version[:i] |
| return bazel_version |
| |
| |
| def parse_bazel_version(bazel_version): |
| """Parses a version string into a 3-tuple of ints |
| int tuples can be compared directly using binary operators (<, >). |
| Args: |
| bazel_version: the Bazel version string |
| Returns: |
| An int 3-tuple of a (major, minor, patch) version. |
| """ |
| |
| version = extract_version_number(bazel_version) |
| return tuple([int(n) for n in version.split(".")]) |
| |
| |
| def prepare_migration(initial_flag): |
| """Preparation work before starting the migration.""" |
| exit_code, stdout, _ = execute_command(["bazel", "--version"]) |
| eprint(stdout.strip() + "\n") |
| if exit_code != 0 or not stdout: |
| warning( |
| "Current bazel is not a release version, we recommend using Bazel 7 or newer releases for Bzlmod migration." |
| ) |
| elif parse_bazel_version(stdout.strip().split(" ")[1]) < (6, 0, 0): |
| error("Current Bazel version is too old, please upgrade to Bazel 7 or newer releases for Bzlmod migration.") |
| abort_migration() |
| |
| # Parse the original workspace name from the WORKSPACE file |
| workspace_name = "main" |
| with open("WORKSPACE", "r") as f: |
| for line in f: |
| s = re.search(r"workspace\(name\s+=\s+[\'\"]([A-Za-z0-9_-]+)[\'\"]", line) |
| if s: |
| workspace_name = s.groups()[0] |
| info(f"Detected original workspace name: {workspace_name}\n") |
| |
| # Delete MODULE.bazel file if `--initial` flag is set. |
| if initial_flag: |
| delete_file_if_exists("MODULE.bazel") |
| delete_file_if_exists("migration_info.md") |
| |
| # Create MODULE.bazel file if it doesn't exist already. |
| if not pathlib.Path("MODULE.bazel").is_file(): |
| scratch_file( |
| "MODULE.bazel", |
| [f'module(name = "{workspace_name}", version="")'], |
| ) |
| module_bazel_content = open("MODULE.bazel", "r").read() |
| for identifier in [ |
| BAZEL_DEP_IDENTIFIER, |
| USE_REPO_RULE_IDENTIFIER, |
| REPO_IDENTIFIER, |
| ]: |
| if identifier not in module_bazel_content: |
| scratch_file("MODULE.bazel", ["", identifier], mode="a") |
| |
| return workspace_name |
| |
| |
| def generate_resolved_file(targets, use_bazel_sync): |
| exit_code, _, stderr = execute_command(["bazel", "clean", "--expunge"]) |
| assertExitCode(exit_code, 0, "Failed to run `bazel clean --expunge`", stderr) |
| bazel_nobuild_command = [ |
| "bazel", |
| "build", |
| "--nobuild", |
| "--noenable_bzlmod", |
| "--enable_workspace", |
| "--experimental_repository_resolved_file=resolved_deps.py", |
| ] + targets |
| bazel_sync_comand = [ |
| "bazel", |
| "sync", |
| "--experimental_repository_resolved_file=resolved_deps.py", |
| ] |
| bazel_command = bazel_sync_comand if use_bazel_sync else bazel_nobuild_command |
| exit_code, _, stderr = execute_command(bazel_command) |
| assertExitCode(exit_code, 0, "Failed to run `" + " ".join(bazel_command) + "`", stderr) |
| |
| # Remove lines containing `"_action_listener":` in the resolved_deps.py file. |
| # Avoiding https://github.com/bazelbuild/bazel-central-registry/issues/2789 |
| with open("resolved_deps.py", "r") as f: |
| lines = f.readlines() |
| with open("resolved_deps.py", "w") as f: |
| for line in lines: |
| if "unknown object com" not in line: |
| f.write(line) |
| |
| |
| def load_resolved_deps(targets, use_bazel_sync, force): |
| """Generate and load the resolved file that contains external deps info.""" |
| if not pathlib.Path("resolved_deps.py").is_file() or force: |
| info("Generating ./resolved_deps.py file - It might take a while...") |
| generate_resolved_file(targets, use_bazel_sync) |
| else: |
| info( |
| "Found existing ./resolved_deps.py file - " |
| "If it's out of date, please add `--initial/-i` flag to force update it." |
| ) |
| |
| spec = importlib.util.spec_from_file_location("resolved_deps", "./resolved_deps.py") |
| module = importlib.util.module_from_spec(spec) |
| sys.modules["resolved_deps"] = module |
| spec.loader.exec_module(module) |
| resolved_deps = module.resolved |
| return resolved_deps |
| |
| |
| def parse_file(filename): |
| direct_deps = set() |
| previous_line_has_external = False |
| with open(filename, "r") as file: |
| for line in file: |
| # Parse for "@". |
| matches_at = re.findall(r"@(\w+)//", line) |
| for match_at in matches_at: |
| if match_at != "bazel_tools": |
| direct_deps.add(match_at) |
| |
| # Parse for "/external/{repo_name}/". |
| matches_external = re.findall(r"/external/(\w+)/", line) |
| if previous_line_has_external == False: |
| # Only first "/external/" is relevant. |
| for match in matches_external: |
| if match != "bazel_tools": |
| direct_deps.add(match) |
| previous_line_has_external = True if matches_external else False |
| |
| return direct_deps |
| |
| |
| def delete_file_if_exists(filename): |
| """Deletes a file if it exists.""" |
| if os.path.exists(filename): |
| try: |
| os.remove(filename) |
| except OSError as e: |
| print(f"Error deleting file '{filename}': {e}") |
| |
| |
| def query_direct_targets(args): |
| targets = args.target |
| direct_deps_file = "query_direct_deps" |
| delete_file_if_exists(direct_deps_file) |
| |
| for target in targets: |
| bazel_command = ["bazel", "query", "--noenable_bzlmod", "--enable_workspace", "--output=build"] + [target] |
| exit_code, stdout, stderr = execute_command(bazel_command) |
| if exit_code != 0 or not stdout: |
| error( |
| "Bazel query: `" |
| + " ".join(bazel_command) |
| + "` contains error:\n" |
| + stderr |
| + "\nDouble check if the target you've specified can be built successfully." |
| ) |
| abort_migration() |
| append_to_file(direct_deps_file, stdout) |
| |
| direct_deps = parse_file(direct_deps_file) |
| append_migration_info("## Direct dependencies:") |
| append_migration_info("* " + "\n* ".join(map(str, direct_deps))) |
| |
| return direct_deps |
| |
| |
| def run_first_part(initial_flag): |
| # Return true if MODULE.bazel file doesn't exist or if flag `--initial` is set. |
| return not pathlib.Path("MODULE.bazel").is_file() or initial_flag |
| |
| |
| def get_error_target(stderr, init_target): |
| pattern = r"Analysis of target '(.*?)' failed" |
| match = re.search(pattern, stderr) |
| if match: |
| return match.group(1) |
| else: |
| return " ".join(init_target) |
| |
| |
| def main(argv=None): |
| if argv is None: |
| argv = sys.argv[1:] |
| |
| parser = argparse.ArgumentParser( |
| prog="migrate_to_bzlmod", |
| description="A helper script for migrating your external dependencies from WORKSPACE to Bzlmod. " |
| + "For given targets, it first tries to generate a list of external dependencies for building your targets, " |
| + "then tries to detect and add missing dependencies in the Bzlmod build. " |
| + "You may still need to fix some problems manually.", |
| epilog=( |
| "Example usage: change into your project directory and run " |
| "`<path to BCR repo>/tools/migrate_to_bzlmod.py --target //foo:bar`" |
| ), |
| ) |
| parser.add_argument( |
| "-s", |
| "--sync", |
| action="store_true", |
| help="use `bazel sync` instead of `bazel build --nobuild` to generate the resolved dependencies. " |
| + "`bazel build --nobuild` only fetches dependencies needed for building specified targets, " |
| + "while `bazel sync` resolves and fetches all dependencies defined in your WORKSPACE file, " |
| + "including bind statements and execution platform & toolchain registrations.", |
| ) |
| parser.add_argument( |
| "-c", |
| "--collaborate", |
| action="store_true", |
| help="collaborate with the user interactively on what to do.", |
| ) |
| parser.add_argument( |
| "-t", |
| "--target", |
| type=str, |
| action="append", |
| help="specify the targets you want to migrate. This flag is repeatable, and the targets are accumulated.", |
| ) |
| parser.add_argument( |
| "-i", |
| "--initial", |
| action="store_true", |
| help="detect direct dependencies, introduce them in MODULE.bazel and rerun generation of resolved dependencies. Running with this flag always overrides the current MODULE.bazel file.", |
| ) |
| |
| args = parser.parse_args(argv) |
| |
| if not args.target: |
| parser.print_help() |
| return 1 |
| |
| run_initial = run_first_part(args.initial) |
| workspace_name = prepare_migration(args.initial) |
| |
| resolved_deps = load_resolved_deps(args.target, args.sync, args.initial) |
| |
| yes_or_no.enable = args.collaborate |
| repro_command = "bazel build --enable_bzlmod --noenable_workspace " + " ".join(args.target) |
| |
| # First part of the migration - Find direct deps with bazel query and add them in MODULE.bazel file. |
| if run_initial: |
| append_migration_info("# Migration info") |
| append_migration_info("Command for local testing:") |
| append_migration_info("```\n" + repro_command + "\n```") |
| print("") |
| direct_deps = query_direct_targets(args) |
| |
| resolved_repos = [] |
| unresolved_deps = [] |
| for direct_dep in direct_deps: |
| if address_unavailable_repo(direct_dep, resolved_deps, workspace_name): |
| resolved_repos.append(direct_dep) |
| else: |
| unresolved_deps.append(direct_dep) |
| |
| if unresolved_deps: |
| print(f"{RED}\nThese repos need manual support:") |
| for dep in unresolved_deps: |
| print(f"\t{RED}" + dep) |
| # TODO(kotlaja): Add these repos at the end. |
| else: |
| info( |
| "To create a MODULE.bazel file from scratch, either delete existing MODULE.bazel file or use the `--initial/-i` flag.\n" |
| ) |
| |
| # Second part of the migration - Build with bzlmod and fix potential errors. |
| while True: |
| # Try to build with Bzlmod enabled |
| targets = args.target |
| bazel_command = [ |
| "bazel", |
| "build", |
| "--nobuild", |
| "--enable_bzlmod", |
| "--noenable_workspace", |
| ] + targets |
| exit_code, _, stderr = execute_command(bazel_command) |
| if exit_code == 0: |
| print("") |
| info( |
| "Congratulations! All external repositories needed for building `" |
| + " ".join(targets) |
| + "` are available with Bzlmod!" |
| ) |
| important("Fix potential build time issues by running the following command:") |
| eprint(f"{BOLD} `{repro_command}`{RESET}") |
| break |
| |
| # 1. Detect build failure caused by unavailable repository |
| repo = detect_unavailable_repo_error(stderr) |
| if repo: |
| if address_unavailable_repo(repo, resolved_deps, workspace_name): |
| continue |
| |
| # 2. Detect build failure caused by unavailable bind statements |
| bind_target_location = detect_bind_issue(stderr) |
| if bind_target_location: |
| if address_bind_issue(bind_target_location, resolved_deps): |
| continue |
| |
| print("") |
| error("Unrecognized error, please fix manually:\n" + stderr) |
| err_target = get_error_target(stderr, args.target) |
| important("Fix the error, then run this migration tool again. Command for reproducing the error:") |
| eprint(f"{BOLD} `bazel build --enable_bzlmod --noenable_workspace {err_target}`{RESET}\n") |
| return 1 |
| |
| print("") |
| important("For details about the migration process, check `migration_info.md` file.\n") |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |