From 69141061a73934c4f3525a954be4e5781bc7fe14 Mon Sep 17 00:00:00 2001 From: Jeremy Nimmer Date: Tue, 10 Dec 2024 09:01:46 -0800 Subject: [PATCH] [build] Support bzlmod for building and installing (#22271) See https://bazel.build/external/migration for background. Note that this commit does not pass all tests when bzlmod is enabled; doing so requires enhancements to our runfiles handling, so for now bzlmod is still disabled by default (except for CMake installs). Details: - Add MODULE.bazel and WORKSPACE.bzlmod for bzlmod use. - Note that WORKSPACE.bzlmod is a copy of WORKSPACE with a tweaked overview comment and a single functional change to our bzlmod flag. - Fix latent bug in repository_excludes for rules_python. - Adjust install rule so installed paths are "drake" not "_main". - Disable drake_py add_test_rule pyc files (for newer rules_python). - Tweak local_config_cc spelling again for bzlmod. - Add linter for module<->workspace syncing. - Turn on bzlmod for CMake to achieve test coverage in CI. --- .gitignore | 3 +- BUILD.bazel | 25 +++- CMakeLists.txt | 4 +- MODULE.bazel | 29 ++++ WORKSPACE | 7 +- WORKSPACE.bzlmod | 38 +++++ cmake/{WORKSPACE.in => WORKSPACE.bzlmod.in} | 1 + cmake/bazel.rc.in | 5 + tools/bazel.rc | 2 + tools/lint/bazel_lint.bzl | 4 +- tools/py_toolchain/BUILD.bazel | 15 ++ tools/skylark/drake_py.bzl | 6 + tools/workspace/BUILD.bazel | 20 +++ tools/workspace/bazel_skylib/BUILD.bazel | 9 +- tools/workspace/bazel_skylib/repository.bzl | 4 + tools/workspace/cc/repository.bzl | 11 +- tools/workspace/default.bzl | 49 ++++++- tools/workspace/rules_cc/BUILD.bazel | 6 + tools/workspace/rules_cc/repository.bzl | 4 + tools/workspace/rules_license/BUILD.bazel | 6 + tools/workspace/rules_license/repository.bzl | 4 + tools/workspace/rules_python/repository.bzl | 27 +++- tools/workspace/workspace_bzlmod_sync_test.py | 133 ++++++++++++++++++ 23 files changed, 387 insertions(+), 25 deletions(-) create mode 100644 MODULE.bazel create mode 100644 WORKSPACE.bzlmod rename cmake/{WORKSPACE.in => WORKSPACE.bzlmod.in} (98%) create mode 100644 tools/workspace/workspace_bzlmod_sync_test.py diff --git a/.gitignore b/.gitignore index d51640741708..d9dd610777e7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,9 @@ /build/ /cmake-build-*/ -# Bazel build artifacts (symlinks) +# Bazel build artifacts /bazel-* +/MODULE.bazel.lock # Platform artifacts generated by `install_prereqs` /gen/ diff --git a/BUILD.bazel b/BUILD.bazel index 357d1695e541..6454b1a5e64f 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -17,6 +17,15 @@ exports_files([ "package.xml", ]) +exports_files( + [ + "MODULE.bazel", + "WORKSPACE", + "WORKSPACE.bzlmod", + ], + visibility = ["//tools/workspace:__pkg__"], +) + # A legacy hack module to disambiguate the 'drake' module when Drake is being # used as a non-bzlmod external. We should remove this when we drop support for # WORKSPACE (i.e., Bazel >= 9). @@ -84,12 +93,24 @@ filegroup( _INSTALL_TEST_COMMANDS = "install_test_commands" +# These are the (only) files from our root directory which are installed. Note +# that even though the "data_dest" and "doc_dest" seem to be redundant with the +# default value for those attributes, that is not the case with bzlmod (where +# the default repository is named "_main" not "drake"). install( - name = "install", - install_tests_script = _INSTALL_TEST_COMMANDS, + name = "install_files", data = ["package.xml"], + data_dest = "share/drake", docs = ["LICENSE.TXT"], + doc_dest = "share/doc/drake", + visibility = ["//visibility:private"], +) + +install( + name = "install", + install_tests_script = _INSTALL_TEST_COMMANDS, deps = [ + ":install_files", "//bindings/pydrake:install", "//common:install", "//examples:install", diff --git a/CMakeLists.txt b/CMakeLists.txt index 33d5f89a1d25..4dbd2a6396d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -542,8 +542,10 @@ endforeach() # however, that the macOS wheel builds also need to know this path, so if it # ever changes, tools/wheel/macos/build-wheel.sh will also need to be updated. configure_file(cmake/bazel.rc.in drake_build_cwd/.bazelrc @ONLY) -configure_file(cmake/WORKSPACE.in drake_build_cwd/WORKSPACE.bazel @ONLY) +configure_file(cmake/WORKSPACE.bzlmod.in drake_build_cwd/WORKSPACE.bzlmod @ONLY) file(CREATE_LINK "${PROJECT_SOURCE_DIR}/.bazeliskrc" drake_build_cwd/.bazeliskrc SYMBOLIC) +file(CREATE_LINK "${PROJECT_SOURCE_DIR}/MODULE.bazel" drake_build_cwd/MODULE.bazel SYMBOLIC) +file(CREATE_LINK "${PROJECT_SOURCE_DIR}/WORKSPACE" drake_build_cwd/WORKSPACE SYMBOLIC) find_package(Git) diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 000000000000..3f529af02e10 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,29 @@ +# This file marks a workspace root for the Bazel build system. +# See `https://bazel.build/`. + +# This file lists Drake's external dependencies as known to bzlmod. +# +# When bzlmod is disabled, this file is NOT used. Instead, only WORKSPACE is +# used. +# +# When bzlmod is enabled, this file + WORKSPACE.bzlmod are both used, and +# WORKSPACE is ignored. + +module(name = "drake") + +bazel_dep(name = "bazel_skylib", version = "1.7.1") +bazel_dep(name = "rules_cc", version = "0.0.17") +bazel_dep(name = "rules_java", version = "8.6.1") +bazel_dep(name = "rules_license", version = "1.0.0") +bazel_dep(name = "rules_python", version = "0.40.0") + +cc_configure = use_extension( + "@rules_cc//cc:extensions.bzl", + "cc_configure_extension", +) +use_repo(cc_configure, "local_config_cc") + +# TODO(#20731) Move all of our dependencies from WORKSPACE.bzlmod into this +# file, so that downstream projects can consume Drake exclusively via bzlmod +# (and so that we can delete our WORKSPACE files prior to Bazel 9 which drops +# suppose for it). diff --git a/WORKSPACE b/WORKSPACE index 32b0c13270b5..b60e7a1ec6d9 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -1,11 +1,16 @@ # This file marks a workspace root for the Bazel build system. # See `https://bazel.build/`. +# +# When bzlmod is disabled, only this file is used. The related files +# MODULE.bazel and WORKSPACE.bzlmod are NOT used. +# +# When bzlmod is enabled, this file is ignored. workspace(name = "drake") load("//tools/workspace:default.bzl", "add_default_workspace") -add_default_workspace() +add_default_workspace(bzlmod = False) load("@build_bazel_apple_support//crosstool:setup.bzl", "apple_cc_configure") diff --git a/WORKSPACE.bzlmod b/WORKSPACE.bzlmod new file mode 100644 index 000000000000..6ffbfef2bcb2 --- /dev/null +++ b/WORKSPACE.bzlmod @@ -0,0 +1,38 @@ +# -*- bazel -*- +# +# This file lists Drake's external dependencies as known to bzlmod. +# +# When bzlmod is disabled, this file is NOT used. Instead, only WORKSPACE is +# used. +# +# When bzlmod is enabled, this file + MODULE.bazel are both used, and WORKSPACE +# is ignored. + +workspace(name = "drake") + +load("//tools/workspace:default.bzl", "add_default_workspace") + +add_default_workspace(bzlmod = True) + +load("@build_bazel_apple_support//crosstool:setup.bzl", "apple_cc_configure") + +apple_cc_configure() + +# Add some special heuristic logic for using CLion with Drake. +load("//tools/clion:repository.bzl", "drake_clion_environment") + +drake_clion_environment() + +load("@bazel_skylib//lib:versions.bzl", "versions") + +# This needs to be in WORKSPACE or a repository rule for native.bazel_version +# to actually be defined. The minimum_bazel_version value should match the +# version passed to the find_package(Bazel) call in the root CMakeLists.txt. +versions.check(minimum_bazel_version = "7.1") + +# The cargo_universe programs are only used by Drake's new_release tooling, not +# by any compilation rules. As such, we can put it directly into the WORKSPACE +# instead of into our `//tools/workspace:default.bzl` repositories. +load("@rules_rust//crate_universe:repositories.bzl", "crate_universe_dependencies") # noqa + +crate_universe_dependencies(bootstrap = True) diff --git a/cmake/WORKSPACE.in b/cmake/WORKSPACE.bzlmod.in similarity index 98% rename from cmake/WORKSPACE.in rename to cmake/WORKSPACE.bzlmod.in index 04df10d0beb2..ff3ecc61e7b8 100644 --- a/cmake/WORKSPACE.in +++ b/cmake/WORKSPACE.bzlmod.in @@ -22,6 +22,7 @@ _BAZEL_WORKSPACE_EXCLUDES = split_cmake_list("@BAZEL_WORKSPACE_EXCLUDES@") # For anything not already overridden, use Drake's default externals. add_default_workspace( repository_excludes = ["python"] + _BAZEL_WORKSPACE_EXCLUDES, + bzlmod = True, ) load("@build_bazel_apple_support//crosstool:setup.bzl", "apple_cc_configure") diff --git a/cmake/bazel.rc.in b/cmake/bazel.rc.in index 9251c964f484..b8141595f2d3 100644 --- a/cmake/bazel.rc.in +++ b/cmake/bazel.rc.in @@ -7,6 +7,11 @@ startup --output_base="@BAZEL_OUTPUT_BASE@" # Inherit Drake's default options. @BAZELRC_IMPORT@ +# By default Drake (currently) opts-out of bzlmod, but for CMake builds we want +# to enable it as a mechanism for covering our bzlmod changes in CI, and also +# to be forward-looking since bzlmod is all Bazel >= 9 will support. +common --enable_bzlmod=true + # Environment variables to be used in repository rules (if any). common @BAZEL_REPO_ENV@ diff --git a/tools/bazel.rc b/tools/bazel.rc index 761432ef959e..f5b658bc9533 100644 --- a/tools/bazel.rc +++ b/tools/bazel.rc @@ -1,4 +1,6 @@ # Don't use bzlmod yet. +# TODO(jwnimmer-tri) When we enable bzlmod by default here, we should nix the +# redundant setting drake/cmake/bazel.rc.in at the same time. common --enable_workspace=true common --enable_bzlmod=false diff --git a/tools/lint/bazel_lint.bzl b/tools/lint/bazel_lint.bzl index b89f93096742..8c558f910238 100644 --- a/tools/lint/bazel_lint.bzl +++ b/tools/lint/bazel_lint.bzl @@ -72,11 +72,11 @@ def bazel_lint( name = name, files = native.glob( [ + "*.bazel", "*.bzl", + "*.bzlmod", "*.BUILD", - "*.BUILD.bazel", "BUILD", - "BUILD.bazel", "WORKSPACE", ], exclude = exclude, diff --git a/tools/py_toolchain/BUILD.bazel b/tools/py_toolchain/BUILD.bazel index f37098dca43f..ed37723e1d53 100644 --- a/tools/py_toolchain/BUILD.bazel +++ b/tools/py_toolchain/BUILD.bazel @@ -1,5 +1,9 @@ load("@python//:version.bzl", "PYTHON_BIN_PATH") load("@rules_python//python:defs.bzl", "py_runtime", "py_runtime_pair") +load( + "@rules_python//python:py_exec_tools_toolchain.bzl", + "py_exec_tools_toolchain", +) load("//tools/lint:lint.bzl", "add_lint_tests") py_runtime( @@ -19,4 +23,15 @@ toolchain( toolchain_type = "@rules_python//python:toolchain_type", ) +py_exec_tools_toolchain( + name = "exec_tools", + precompiler = "@rules_python//tools/precompiler:precompiler", +) + +toolchain( + name = "exec_tools_toolchain", + toolchain = ":exec_tools", + toolchain_type = "@rules_python//python:exec_tools_toolchain_type", +) + add_lint_tests() diff --git a/tools/skylark/drake_py.bzl b/tools/skylark/drake_py.bzl index 35e822ecf9d5..d10e7b8fcc96 100644 --- a/tools/skylark/drake_py.bzl +++ b/tools/skylark/drake_py.bzl @@ -150,6 +150,12 @@ def drake_py_binary( srcs = srcs, main = main, deps = deps, + # We use the same srcs for both the py_binary and the py_test so we + # must disable pre-compilation during the py_test target; otherwise + # both targets would declare an identical set of `*.pyc` output + # files from their build actions and bazel would error out because + # of the malformed BUILD file. + precompile = "disabled", isolate = isolate, args = test_rule_args, data = data + test_rule_data, diff --git a/tools/workspace/BUILD.bazel b/tools/workspace/BUILD.bazel index b58a816652e8..25cd6c660c92 100644 --- a/tools/workspace/BUILD.bazel +++ b/tools/workspace/BUILD.bazel @@ -72,6 +72,26 @@ drake_py_test( deps = [":module_py"], ) +drake_py_test( + name = "workspace_bzlmod_sync_test", + srcs = ["workspace_bzlmod_sync_test.py"], + allow_import_unittest = True, + data = [ + ":default.bzl", + "//:MODULE.bazel", + "//:WORKSPACE", + "//:WORKSPACE.bzlmod", + "//tools/workspace/bazel_skylib:repository.bzl", + "//tools/workspace/rules_cc:repository.bzl", + "//tools/workspace/rules_license:repository.bzl", + ], + tags = ["lint"], + deps = [ + ":module_py", + "@rules_python//python/runfiles", + ], +) + drake_py_binary( name = "cmake_configure_file", srcs = ["cmake_configure_file.py"], diff --git a/tools/workspace/bazel_skylib/BUILD.bazel b/tools/workspace/bazel_skylib/BUILD.bazel index b77b93ae0dbc..e9c64f198ee8 100644 --- a/tools/workspace/bazel_skylib/BUILD.bazel +++ b/tools/workspace/bazel_skylib/BUILD.bazel @@ -1,6 +1,9 @@ -# This file exists to make our directory into a Bazel package, so that our -# neighboring *.bzl file can be loaded elsewhere. - load("//tools/lint:lint.bzl", "add_lint_tests") +# Required for workspace_bzlmod_sync_test.py. +exports_files( + ["repository.bzl"], + visibility = ["//tools/workspace:__pkg__"], +) + add_lint_tests() diff --git a/tools/workspace/bazel_skylib/repository.bzl b/tools/workspace/bazel_skylib/repository.bzl index d572a346dd8a..a3e8d01345f8 100644 --- a/tools/workspace/bazel_skylib/repository.bzl +++ b/tools/workspace/bazel_skylib/repository.bzl @@ -4,6 +4,10 @@ def bazel_skylib_repository(name, mirrors = None): github_archive( name = name, repository = "bazelbuild/bazel-skylib", + upgrade_advice = """ + When updating, you must also manually propagate to the new version + number into the MODULE.bazel file (at the top level of Drake). + """, commit = "1.7.1", sha256 = "e3fea03ff75a9821e84199466799ba560dbaebb299c655b5307f4df1e5970696", # noqa mirrors = mirrors, diff --git a/tools/workspace/cc/repository.bzl b/tools/workspace/cc/repository.bzl index 6ab3d1102d14..c22ddfe57371 100644 --- a/tools/workspace/cc/repository.bzl +++ b/tools/workspace/cc/repository.bzl @@ -30,10 +30,6 @@ Argument: load("//tools/workspace:execute.bzl", "execute_or_fail") -# We can probe whether bzlmod is enabled by checking if labels use one or two -# leading '@' charaters. (The label doesn't need to be valid.) -BZLMOD_ENABLED = "@@" in str(Label("//:foo")) - def _check_compiler_version(compiler_id, actual_version, supported_version): """ Check if the compiler is of a supported version and report an error if not. @@ -91,8 +87,13 @@ def _impl(repository_ctx): else: cc_environment = {} + # For Bazel 7.x sometimes we need a weird spelling of @local_config_cc. + # We can probably remove this once our minimum supported Bazel is >= 8. local_config_cc = "@local_config_cc" - if BZLMOD_ENABLED: + if all([ + native.bazel_version.startswith("7."), + "@@" in str(Label("//:foo")), + ]): local_config_cc = "@bazel_tools~cc_configure_extension~local_config_cc" executable = repository_ctx.path("identify_compiler") execute_or_fail(repository_ctx, [ diff --git a/tools/workspace/default.bzl b/tools/workspace/default.bzl index 2ab8c31daeb6..d69d7529905c 100644 --- a/tools/workspace/default.bzl +++ b/tools/workspace/default.bzl @@ -110,7 +110,21 @@ load("//tools/workspace/xmlrunner_py:repository.bzl", "xmlrunner_py_repository") load("//tools/workspace/yaml_cpp_internal:repository.bzl", "yaml_cpp_internal_repository") # noqa load("//tools/workspace/zlib:repository.bzl", "zlib_repository") -def add_default_repositories(excludes = [], mirrors = DEFAULT_MIRRORS): +# This is the list of modules that our MODULE.bazel already incorporates. +# It is cross-checked by the workspace_bzlmod_sync_test.py test. +REPOS_ALREADY_PROVIDED_BY_BAZEL_MODULES = [ + "bazel_skylib", + "rules_cc", + "rules_java", + "rules_license", + "rules_python", +] + +def add_default_repositories( + excludes = [], + mirrors = DEFAULT_MIRRORS, + *, + bzlmod = False): """Declares workspace repositories for all externals needed by drake (other than those built into Bazel, of course). This is intended to be loaded and called from a WORKSPACE file. @@ -119,7 +133,11 @@ def add_default_repositories(excludes = [], mirrors = DEFAULT_MIRRORS): excludes: list of string names of repositories to exclude; this can be useful if a WORKSPACE file has already supplied its own external of a given name. + bzlmod: when True, skips repositories declared in our MODULE.bazel; + set this to True if you are using bzlmod. """ + if bzlmod: + excludes = excludes + REPOS_ALREADY_PROVIDED_BY_BAZEL_MODULES if "abseil_cpp_internal" not in excludes: abseil_cpp_internal_repository(name = "abseil_cpp_internal", mirrors = mirrors) # noqa if "bazelisk" not in excludes: @@ -282,6 +300,8 @@ def add_default_repositories(excludes = [], mirrors = DEFAULT_MIRRORS): rules_license_repository(name = "rules_license", mirrors = mirrors) if "rules_python" not in excludes: rules_python_repository(name = "rules_python", mirrors = mirrors) + else: + rules_python_repository(name = "rules_python", _constants_only = True) if "rules_rust" not in excludes: rules_rust_repository(name = "rules_rust", mirrors = mirrors) if "rules_rust_tinyjson" not in excludes: @@ -341,26 +361,36 @@ def add_default_repositories(excludes = [], mirrors = DEFAULT_MIRRORS): if "zlib" not in excludes: zlib_repository(name = "zlib") -def add_default_toolchains(excludes = []): +def add_default_toolchains( + excludes = [], + *, + bzlmod = False): """Register toolchains for each language (e.g., "py") not explicitly excluded and/or not using an automatically generated toolchain. Args: excludes: List of languages for which a toolchain should not be registered. + bzlmod: when True, skips toolchains declared in our MODULE.bazel; + set this to True if you are using bzlmod. """ if "py" not in excludes: native.register_toolchains( "//tools/py_toolchain:toolchain", ) + native.register_toolchains( + "//tools/py_toolchain:exec_tools_toolchain", + ) if "rust" not in excludes: register_rust_toolchains() def add_default_workspace( repository_excludes = [], toolchain_excludes = [], - mirrors = DEFAULT_MIRRORS): + mirrors = DEFAULT_MIRRORS, + *, + bzlmod = False): """Declare repositories in this WORKSPACE for each dependency of @drake (e.g., "eigen") that is not explicitly excluded, and register toolchains for each language (e.g., "py") not explicitly excluded and/or not using an @@ -374,7 +404,16 @@ def add_default_workspace( mirrors: Dictionary of mirrors from which to download repository files. See mirrors.bzl file in this directory for the file format and default values. + bzlmod: when True, skips repositories and toolchains declared in our + MODULE.bazel; set this to True if you are using bzlmod. """ - add_default_repositories(excludes = repository_excludes, mirrors = mirrors) - add_default_toolchains(excludes = toolchain_excludes) + add_default_repositories( + excludes = repository_excludes, + mirrors = mirrors, + bzlmod = bzlmod, + ) + add_default_toolchains( + excludes = toolchain_excludes, + bzlmod = bzlmod, + ) diff --git a/tools/workspace/rules_cc/BUILD.bazel b/tools/workspace/rules_cc/BUILD.bazel index 67914ea7e0a0..e9c64f198ee8 100644 --- a/tools/workspace/rules_cc/BUILD.bazel +++ b/tools/workspace/rules_cc/BUILD.bazel @@ -1,3 +1,9 @@ load("//tools/lint:lint.bzl", "add_lint_tests") +# Required for workspace_bzlmod_sync_test.py. +exports_files( + ["repository.bzl"], + visibility = ["//tools/workspace:__pkg__"], +) + add_lint_tests() diff --git a/tools/workspace/rules_cc/repository.bzl b/tools/workspace/rules_cc/repository.bzl index 81b2c528299b..e111fb92504b 100644 --- a/tools/workspace/rules_cc/repository.bzl +++ b/tools/workspace/rules_cc/repository.bzl @@ -10,6 +10,10 @@ def rules_cc_repository( github_archive( name = name, repository = "bazelbuild/rules_cc", # License: Apache-2.0, + upgrade_advice = """ + When updating, you must also manually propagate to the new version + number into the MODULE.bazel file (at the top level of Drake). + """, commit = "0.0.17", sha256 = "abc605dd850f813bb37004b77db20106a19311a96b2da1c92b789da529d28fe1", # noqa patches = [ diff --git a/tools/workspace/rules_license/BUILD.bazel b/tools/workspace/rules_license/BUILD.bazel index 67914ea7e0a0..e9c64f198ee8 100644 --- a/tools/workspace/rules_license/BUILD.bazel +++ b/tools/workspace/rules_license/BUILD.bazel @@ -1,3 +1,9 @@ load("//tools/lint:lint.bzl", "add_lint_tests") +# Required for workspace_bzlmod_sync_test.py. +exports_files( + ["repository.bzl"], + visibility = ["//tools/workspace:__pkg__"], +) + add_lint_tests() diff --git a/tools/workspace/rules_license/repository.bzl b/tools/workspace/rules_license/repository.bzl index 15131a87b978..17bc912351c7 100644 --- a/tools/workspace/rules_license/repository.bzl +++ b/tools/workspace/rules_license/repository.bzl @@ -10,6 +10,10 @@ def rules_license_repository( github_archive( name = name, repository = "bazelbuild/rules_license", # License: Apache-2.0, + upgrade_advice = """ + When updating, you must also manually propagate to the new version + number into the MODULE.bazel file (at the top level of Drake). + """, commit = "1.0.0", sha256 = "75759939aef3aeb726e801417a883deefadadb7fea49946a1f5bb74a5162e81e", # noqa mirrors = mirrors, diff --git a/tools/workspace/rules_python/repository.bzl b/tools/workspace/rules_python/repository.bzl index 26b67a40c194..708aef9d1002 100644 --- a/tools/workspace/rules_python/repository.bzl +++ b/tools/workspace/rules_python/repository.bzl @@ -25,11 +25,28 @@ _rules_python_drake_constants_repository = repository_rule( def rules_python_repository( name, - mirrors = None): - # For Bazel versions < 8, we pin our own particular copy of rules_python. - # For Bazel versions >= 8, we'll use Bazel's vendored copy of rules_python. - # Our minimum version (per WORKSPACE) is 7.1 so we can use a string match. - use_drake_rules_python_pin = native.bazel_version[0:2] == "7." + mirrors = None, + _constants_only = False): + """Declares the @rules_python repository (if necessary) as well as the + @rules_python_drake_constants repository (always). + + When `_constants_only` is true, the @rules_python repository will NOT be + declared; only @rules_python_drake_constants is declared. (In practice, + this happens when "rules_python" is listed in the `repository_excludes` + for `default.bzl`, e.g., when bzlmod is enabled.) + + Even when _constants_only is false, the @rules_python repository still + might not be declared according to the heuristic described below. + """ + + # For Bazel versions < 8, we pin our own particular copy of rules_python, + # because the bazel's default (vendored) version is somewhat stale. For + # Bazel versions >= 8, we'll use Bazel's vendored copy of rules_python when + # running in WORKSPACE mode. Our minimum version (per WORKSPACE) is 7.1 so + # we can use a string match. When running in MODULE (bzlmod) mode, the + # _constants_only will be True (so we will NOT pin). + use_drake_rules_python_pin = (native.bazel_version[0:2] == "7." and + not _constants_only) _rules_python_drake_constants_repository( name = name + "_drake_constants", constants_json = json.encode({ diff --git a/tools/workspace/workspace_bzlmod_sync_test.py b/tools/workspace/workspace_bzlmod_sync_test.py new file mode 100644 index 000000000000..5b168760a957 --- /dev/null +++ b/tools/workspace/workspace_bzlmod_sync_test.py @@ -0,0 +1,133 @@ +import unittest +from pathlib import Path + +from python import runfiles + + +class TestWorkspaceBzlmodSync(unittest.TestCase): + + def _read(self, respath): + """Returns the contents of the given resource path.""" + manifest = runfiles.Create() + path = Path(manifest.Rlocation(respath)) + return path.read_text(encoding="utf-8") + + def _parse_modules(self, content): + """Given the contents of MODULE.bazel, returns a dictionary mapping + from module_name to module_version. + """ + result = {} + for line in content.splitlines(): + # Only match bazel_dep lines. + if not line.startswith("bazel_dep"): + continue + # Grab what's inside the parens. + _, line = line.split("(") + line, _ = line.split(")") + # Parse out the kwargs. + kwargs = {} + for item in line.split(","): + name, value = item.split(" = ") + kwargs[name.strip()] = value.strip().replace('"', '') + result[kwargs["name"]] = kwargs["version"] + return result + + def _parse_repo_rule_version(self, content): + """Given the contents of a repository.bzl that calls 'github_archive', + returns the version number it pins to. + """ + assert "github_archive" in content, content + for line in content.splitlines(): + line = line.strip() + if not line.startswith("commit = "): + continue + _, version, _ = line.split('"') + return version + self.fail(f"No 'commit = ...' found in:\n{content}") + + def test_version_sync(self): + """Some external version are independently listed in both MODULE.bazel + and WORKSPACE. This test ensures that the versions pinned in each file + are correctly synchronized. + """ + modules = self._parse_modules(self._read(f"drake/MODULE.bazel")) + + # Don't check modules that are known to be module-only. + del modules["rules_java"] + + # Don't check module that are documented to purposefully skew versions. + del modules["rules_python"] + + # Check that the module version matches the workspace version. + self.assertTrue(modules) + for name, module_version in modules.items(): + workspace_version = self._parse_repo_rule_version( + self._read(f"drake/tools/workspace/{name}/repository.bzl")) + self.assertEqual(workspace_version, module_version) + + def _parse_workspace_already_provided(self, content): + """Given the contents of default.bzl, returns the list of + REPOS_ALREADY_PROVIDED_BY_BAZEL_MODULES. + """ + result = None + for line in content.splitlines(): + line = line.strip() + if line == "REPOS_ALREADY_PROVIDED_BY_BAZEL_MODULES = [": + result = list() + continue + if result is None: + # We haven't seen the REPOS_ALREADY_... line yet. + continue + if line == "]": + break + assert line.startswith('"'), line + assert line.endswith('",'), line + result.append(line[1:-2]) + assert result, content + return sorted(result) + + def test_default_exclude_sync(self): + """Our default.bzl needs to know the list of modules that are already + provided by MODULE.bazel. This test ensures that the list is correctly + synchronized. + """ + modules = self._parse_modules(self._read(f"drake/MODULE.bazel")) + module_names = sorted(modules.keys()) + self.assertEqual(module_names, self._parse_workspace_already_provided( + self._read("drake/tools/workspace/default.bzl"))) + + def _canonicalize_workspace(self, content): + """Given the contents of WORKSPACE or WORKSPACE.bzlmod, returns a + modified copy that: + - strips away comments and blank lines, and + - fuzzes out the `bzlmod = ...` attribute. + """ + needle1 = "add_default_workspace(bzlmod = False)" + needle2 = "add_default_workspace(bzlmod = True)" + replacement = "add_default_workspace(bzlmod = ...)" + result = [] + for line in content.splitlines(): + if "#" in line: + line, _ = line.split("#", maxsplit=1) + line = line.strip() + if not line: + continue + if line in (needle1, needle2): + line = replacement + result.append(line) + return "\n".join(result) + "\n" + + def test_workspace_copies(self): + """Checks that our WORKSPACE and WORKSPACE.bzlmod are identical, + modulo comments and the `bzlmod = ...` attribute. + """ + workspace1 = self._canonicalize_workspace( + self._read(f"drake/WORKSPACE")) + workspace2 = self._canonicalize_workspace( + self._read(f"drake/WORKSPACE.bzlmod")) + self.maxDiff = None + self.assertMultiLineEqual(workspace1, workspace2) + + +assert __name__ == '__main__' +unittest.main()