diff --git a/CMakeLists.txt b/CMakeLists.txt index d1e4b4ccfc72..024a470acd98 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -594,6 +594,7 @@ add_custom_target(drake_version ALL add_custom_target(drake_cxx_python ALL COMMAND "${Bazel_EXECUTABLE}" build ${BAZEL_INSTALL_TARGET} + DEPENDS "${PROJECT_BINARY_DIR}/VERSION.TXT" WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/drake_build_cwd" USES_TERMINAL ) diff --git a/cmake/bazel.rc.in b/cmake/bazel.rc.in index dd671e17bd20..02fbf2364199 100644 --- a/cmake/bazel.rc.in +++ b/cmake/bazel.rc.in @@ -48,5 +48,10 @@ try-import @PROJECT_SOURCE_DIR@/user.bazelrc # Use build-specific bazel options (if present). try-import @PROJECT_BINARY_DIR@/drake.bazelrc +# Provide the contents of VERSION.TXT in Bazel's stable-status.txt file for use +# by the stamping rules in tools/install/libdrake/BUILD.bazel. +build --stamp +build --workspace_status_command="sed 's/^/STABLE_VERSION /' '@PROJECT_BINARY_DIR@/VERSION.TXT'" + # TODO(jwnimmer-tri) Pass along CMAKE_C_FLAGS and CMAKE_CXX_FLAGS also, and # specifically make sure the user's -std=c++NN is respected. diff --git a/tools/install/libdrake/BUILD.bazel b/tools/install/libdrake/BUILD.bazel index b536a01283cd..00a9a4eaf798 100644 --- a/tools/install/libdrake/BUILD.bazel +++ b/tools/install/libdrake/BUILD.bazel @@ -1,4 +1,9 @@ load("@bazel_skylib//lib:selects.bzl", "selects") +load("@bazel_skylib//rules:run_binary.bzl", "run_binary") +load( + "@drake//tools/workspace:cmake_configure_file.bzl", + "cmake_configure_file", +) load( "@python//:version.bzl", "PYTHON_SITE_PACKAGES_RELPATH", @@ -20,6 +25,7 @@ load( ) load( "//tools/skylark:drake_py.bzl", + "drake_py_binary", "drake_py_unittest", ) load(":build_components.bzl", "LIBDRAKE_COMPONENTS") @@ -28,14 +34,38 @@ load(":header_lint.bzl", "cc_check_allowed_headers") package(default_visibility = ["//visibility:private"]) genrule( + name = "stamp_version", + outs = ["stamp_version.txt"], + cmd_bash = "sed -n '/^STABLE_VERSION/p' bazel-out/stable-status.txt > $@", + stamp = 1, +) + +drake_py_binary( + name = "parse_version", + srcs = ["parse_version.py"], +) + +run_binary( + name = "drake_parse_version_stamp", + srcs = ["stamp_version.txt"], + outs = ["stamp_version.cmake"], + args = [ + "$(location stamp_version.txt)", + "$(location stamp_version.cmake)", + ], + tool = "parse_version", +) + +cmake_configure_file( name = "drake_config_cmake_expand", - srcs = ["drake-config.cmake.in"], - outs = ["drake-config.cmake"], - cmd = "sed '" + - "s!@PYTHON_SITE_PACKAGES_RELPATH@!" + - PYTHON_SITE_PACKAGES_RELPATH + "!g;" + - "s!@PYTHON_VERSION@!" + PYTHON_VERSION + "!g;" + - "' $< > $@", + src = "drake-config.cmake.in", + out = "drake-config.cmake", + atonly = True, + cmakelists = ["stamp_version.cmake"], + defines = [ + "PYTHON_SITE_PACKAGES_RELPATH=" + PYTHON_SITE_PACKAGES_RELPATH, + "PYTHON_VERSION=" + PYTHON_VERSION, + ], ) install_cmake_config( diff --git a/tools/install/libdrake/drake-config.cmake.in b/tools/install/libdrake/drake-config.cmake.in index 4b0fb79337c8..dc41d47449d5 100644 --- a/tools/install/libdrake/drake-config.cmake.in +++ b/tools/install/libdrake/drake-config.cmake.in @@ -87,12 +87,19 @@ set_target_properties(drake::drake-marker PROPERTIES unset(_apple_soname_prologue) -set(drake_LIBRARIES "drake::drake") -set(drake_INCLUDE_DIRS "") +set(${CMAKE_FIND_PACKAGE_NAME}_LIBRARIES "drake::drake") +set(${CMAKE_FIND_PACKAGE_NAME}_INCLUDE_DIRS "") -set(drake_PYTHON_DIR "${${CMAKE_FIND_PACKAGE_NAME}_IMPORT_PREFIX}/@PYTHON_SITE_PACKAGES_RELPATH@") +set(${CMAKE_FIND_PACKAGE_NAME}_VERSION "@DRAKE_VERSION@") +set(${CMAKE_FIND_PACKAGE_NAME}_VERSION_MAJOR "@DRAKE_VERSION_MAJOR@") +set(${CMAKE_FIND_PACKAGE_NAME}_VERSION_MINOR "@DRAKE_VERSION_MINOR@") +set(${CMAKE_FIND_PACKAGE_NAME}_VERSION_PATCH "@DRAKE_VERSION_PATCH@") +set(${CMAKE_FIND_PACKAGE_NAME}_VERSION_TWEAK "@DRAKE_VERSION_TWEAK@") + + +set(${CMAKE_FIND_PACKAGE_NAME}_PYTHON_DIR "${${CMAKE_FIND_PACKAGE_NAME}_IMPORT_PREFIX}/@PYTHON_SITE_PACKAGES_RELPATH@") # Allow users to easily check Drake's expected CPython version. -set(drake_PYTHON_VERSION "@PYTHON_VERSION@") +set(${CMAKE_FIND_PACKAGE_NAME}_PYTHON_VERSION "@PYTHON_VERSION@") unset(${CMAKE_FIND_PACKAGE_NAME}_IMPORT_PREFIX) unset(CMAKE_IMPORT_FILE_VERSION) diff --git a/tools/install/libdrake/parse_version.py b/tools/install/libdrake/parse_version.py new file mode 100644 index 000000000000..4164a5d3ee39 --- /dev/null +++ b/tools/install/libdrake/parse_version.py @@ -0,0 +1,96 @@ +"""Parse the version stamp file and produce a CMake cache-style script file +which specifies the variable substitutions needed for drake-config.cmake.""" + +import argparse +import re + +VERSION_TAG = 'STABLE_VERSION' + + +# Check if a version string conforms to PEP 440. +def _check_version(version): + return re.match( + r'^([1-9][0-9]*!)?(0|[1-9][0-9]*)' + r'(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?' + r'(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?' + r'([+][a-z0-9]+([-_\.][a-z0-9]+)*)?$', + version) is not None + + +# Extract full version and version parts from version stamp file. +# +# If a version is specified, the input file should contain a line starting with +# 'STABLE_VERSION', which should be three space-separated words; the tag, the +# full version, and the git SHA. +# +# This extracts the (full) version identifier, as well as the individual +# numeric parts (separated by '.') of the version. Any pre-release, 'dev', +# 'post', and/or local identifier (i.e. portion following a '+') is discarded +# when extracting the version parts. If version information is not found, +# this returns (None, None). +def _parse_stamp(stamp_file): + # Read input. + for line in stamp_file: + if line.startswith(VERSION_TAG): + tag, version_full, git_sha = line.strip().split() + assert tag == VERSION_TAG + + # Check version format and extract numerical components. + if not _check_version(version_full): + raise ValueError(f'Version {version_full} is not valid') + if re.match(r'^[1-9][0-9]*!', version_full): + raise ValueError(f'Version {version_full} contains an epoch,' + ' which is not supported at this time') + + m = re.match(r'^[0-9.]+', version_full) + assert m + + # Check for sufficient version parts (note: user and continuous + # builds may have more than three parts) and pad to ensure we + # always have four. + version_parts = m.group(0).split('.') + if len(version_parts) < 4: + if len(version_parts) == 3: + version_parts.append(0) + else: + raise ValueError(f'Version {version_full}' + ' does not have enough parts') + + return version_full, tuple(map(int, version_parts)) + + return None, None + + +# Write version information to CMake cache-style script. +def _write_version_info(out, version_full, version_parts): + if version_full is None: + out.write('set(DRAKE_VERSION "unknown")\n') + out.write('set(DRAKE_VERSION_MAJOR "unknown")\n') + out.write('set(DRAKE_VERSION_MINOR "unknown")\n') + out.write('set(DRAKE_VERSION_PATCH "unknown")\n') + out.write('set(DRAKE_VERSION_TWEAK "unknown")\n') + else: + out.write(f'set(DRAKE_VERSION "{version_full}")\n') + out.write(f'set(DRAKE_VERSION_MAJOR "{version_parts[0]}")\n') + out.write(f'set(DRAKE_VERSION_MINOR "{version_parts[1]}")\n') + out.write(f'set(DRAKE_VERSION_PATCH "{version_parts[2]}")\n') + out.write(f'set(DRAKE_VERSION_TWEAK "{version_parts[3]}")\n') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + 'input', type=argparse.FileType('r'), + help='Path to file optionally containing stamp version.') + parser.add_argument( + 'output', type=argparse.FileType('w'), + help='Path to output file.') + args = parser.parse_args() + + _write_version_info(args.output, *_parse_stamp(args.input)) + + return 0 + + +if __name__ == '__main__': + main() diff --git a/tools/workspace/cmake_configure_file.bzl b/tools/workspace/cmake_configure_file.bzl index 342cc7e328c5..3d4add5b88e9 100644 --- a/tools/workspace/cmake_configure_file.bzl +++ b/tools/workspace/cmake_configure_file.bzl @@ -24,6 +24,8 @@ def _cmake_configure_file_impl(ctx): arguments += ["--autoconf"] if ctx.attr.strict: arguments += ["--strict"] + if ctx.attr.atonly: + arguments += ["--atonly"] ctx.actions.run( inputs = ctx.files.srcs + ctx.files.cmakelists, outputs = ctx.outputs.outs, @@ -43,6 +45,7 @@ _cmake_configure_file_gen = rule( "cmakelists": attr.label_list(allow_files = True), "autoconf": attr.bool(default = False), "strict": attr.bool(default = False), + "atonly": attr.bool(default = False), "cmake_configure_file_py": attr.label( cfg = "host", executable = True, @@ -65,6 +68,7 @@ def cmake_configure_file( undefines = None, cmakelists = None, strict = None, + atonly = None, **kwargs): """Creates a rule to generate an out= file from a src= file, using CMake's configure_file substitution semantics. This implementation is incomplete, @@ -84,6 +88,9 @@ def cmake_configure_file( either defines, undefines, or cmakelists is an error. When False, anything not mentioned is silently presumed to be undefined. + When atonly is True, only substitutions like '@var@' will be made; '${var}' + will be left as-is. When False, both types of substitutions will be made. + See cmake_configure_file.py for our implementation of the configure_file substitution rules. @@ -99,6 +106,7 @@ def cmake_configure_file( undefines = undefines, cmakelists = cmakelists, strict = strict, + atonly = atonly, env = hermetic_python_env(), **kwargs ) @@ -111,6 +119,7 @@ def cmake_configure_files( undefines = None, cmakelists = None, strict = None, + atonly = None, **kwargs): """Like cmake_configure_file(), but with itemwise pairs of srcs => outs, instead of just one pair of src => out. @@ -126,6 +135,7 @@ def cmake_configure_files( undefines = undefines, cmakelists = cmakelists, strict = strict, + atonly = atonly, env = hermetic_python_env(), **kwargs ) diff --git a/tools/workspace/cmake_configure_file.py b/tools/workspace/cmake_configure_file.py index da35414924af..3b6a779f5c07 100644 --- a/tools/workspace/cmake_configure_file.py +++ b/tools/workspace/cmake_configure_file.py @@ -17,8 +17,31 @@ # Looks like "#cmakedefine VAR ..." or "#cmakedefine01 VAR". _cmakedefine = re.compile(r'^(\s*)#cmakedefine(01)? ([^ \r\n]+)(.*?)([\r\n]+)') -# Looks like "@VAR@" or "${VAR}". -_varsubst = re.compile(r'^(.*?)(@[^ ]+?@|\$\{[^ ]+?\})(.*)([\r\n]*)') +# Looks like "${VAR}". +_varsubst = re.compile(r'^(.*)\$\{([^} ]+?)\}(.*)([\r\n]*)') + +# Looks like "@VAR@". +_atvarsubst = re.compile(r'^(.*)@([^@ ]+?)@(.*)([\r\n]*)') + + +# Transform substitutions in a source line according to a specified pattern. +# +# The 'definitions' provides values for CMake variables. The dict's keys are +# the variable names to substitute, and the dict's values are the values to +# substitute. (The values can be None, for known-but-undefined variable keys.) +# +# This is used to transform exactly ONE of '@VAR@' or '${VAR}', depending on +# which pattern is specified. +def _transform_substitions(*, line, definitions, used_vars, pattern): + while (match := pattern.match(line)) is not None: + before, var, after, newline = match.groups() + assert len(var) > 0 + + value = definitions[var] or '' + line = before + value + after + newline + used_vars.add(var) + + return line, used_vars # Transform a source code line per CMake's configure_file semantics. @@ -43,7 +66,7 @@ # substitution token with the value in 'definitions' dict for that VAR, or # else the empty string if the value is None. It is an error if there is no # such key in the dict. -def _transform_cmake(*, line, definitions, strict): +def _transform_cmake(*, line, definitions, strict, atonly): used_vars = set() # Replace define statements. @@ -67,29 +90,18 @@ def _transform_cmake(*, line, definitions, strict): return line, used_vars # Replace variable substitutions. - while True: - match = _varsubst.match(line) - if not match: - break - before, xvarx, after, newline = match.groups() - if xvarx[0] == '$': - assert len(xvarx) >= 4 - assert xvarx[1] == '{' - assert xvarx[-1] == '}' - var = xvarx[2:-1] - elif xvarx[0] == '@': - assert len(xvarx) >= 3 - assert xvarx[-1] == '@' - var = xvarx[1:-1] - assert len(var) > 0 - - if var not in definitions: - raise KeyError(var) - used_vars.add(var) - value = definitions.get(var) - if value is None: - value = '' - line = before + value + after + newline + if not atonly: + line, used_vars = _transform_substitions( + line=line, + definitions=definitions, + used_vars=used_vars, + pattern=_varsubst) + + line, used_vars = _transform_substitions( + line=line, + definitions=definitions, + used_vars=used_vars, + pattern=_atvarsubst) return line, used_vars @@ -100,7 +112,11 @@ def _transform_cmake(*, line, definitions, strict): # Transform a source code line using autoconf format. # The 'definitions' provides variable values, just like _transform_cmake above. -def _transform_autoconf(*, line, definitions, strict): +def _transform_autoconf(*, line, definitions, strict, atonly): + # 'atonly' isn't meaningful to _transform_autoconf, but the argument is + # needed in order to have the same signature as _transform_cmake. + assert not atonly + used_vars = set() match = _autoconf_undef.match(line) if match: @@ -134,7 +150,8 @@ def _extract_definition(line, prior_definitions): value, _ = _transform_cmake( line=value, definitions=prior_definitions, - strict=False) + strict=False, + atonly=False) except KeyError: return dict() if value.startswith('"'): @@ -180,13 +197,16 @@ def main(): '-D', metavar='NAME', dest='defines', action='append', default=[]) parser.add_argument( '-U', metavar='NAME', dest='undefines', action='append', default=[]) - parser.add_argument( - '--autoconf', action='store_true', - help='The input file is in autoconf format, not cmake format.') parser.add_argument( '--cmakelists', action='append', default=[]) parser.add_argument( '--strict', action='store_true') + modifiers = parser.add_mutually_exclusive_group() + modifiers.add_argument( + '--autoconf', action='store_true', + help='The input file is in autoconf format, not cmake format.') + modifiers.add_argument( + '--atonly', action='store_true') args = parser.parse_args() if len(args.input) == 0: parser.error("There must be at least one --input") @@ -205,7 +225,8 @@ def main(): output_line, used_vars = transformer( line=input_line, definitions=definitions, - strict=args.strict) + strict=args.strict, + atonly=args.atonly) output_file.write(output_line) total_used_vars |= used_vars except KeyError as e: