From 7f2c9b9e86ad9d56fd266d63f717a3ff134b8885 Mon Sep 17 00:00:00 2001 From: Tom Birdsong Date: Tue, 2 Jun 2026 10:45:01 -0400 Subject: [PATCH 1/4] Module: Add ".tar.gz" packaging for holoscan-gstreamer Goal: Support source archive packaging for Holoscan Modules adhering to an internal specification, focusing on "holoscan-gstreamer" - Add "holohub_configure_tgz.cmake" CPack helper to create CPack TGZ generator configurations. Adhere to internal naming specifications and support component filtering to drop Python components. Matches existing Deb generator support. - Add TGZ to CLI packaging handling - Tag Python module install with COMPONENT holohub-python and operator installs with COMPONENT holoscan-gstreamer to enable per-component archive filtering Assisted-by: Claude:claude-sonnet-4-6 Signed-off-by: Tom Birdsong --- CMakeLists.txt | 1 + cmake/modules/holohub_configure_tgz.cmake | 131 ++++++++++++++++++++++ modules/holoscan-gstreamer/CMakeLists.txt | 15 +++ operators/gstreamer/CMakeLists.txt | 12 +- utilities/cli/holohub.py | 34 +++++- 5 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 cmake/modules/holohub_configure_tgz.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index e5946664d1..03e3deecbb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,7 @@ if(HOLOHUB_BUILD_PYTHON) install( DIRECTORY "${HOLOHUB_PYTHON_MODULE_OUT_DIR}" DESTINATION "${_holohub_py_dest}" + COMPONENT holohub-python FILE_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE DIRECTORY_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE PATTERN "__pycache__" EXCLUDE diff --git a/cmake/modules/holohub_configure_tgz.cmake b/cmake/modules/holohub_configure_tgz.cmake new file mode 100644 index 0000000000..5da372004d --- /dev/null +++ b/cmake/modules/holohub_configure_tgz.cmake @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +# holohub_configure_tgz(NAME VERSION +# [EXPORT_NAME ] +# [COMPONENTS ...]) +# +# Generates a CPack TGZ configuration file at +# ${CMAKE_BINARY_DIR}/pkg/CPackConfig--TGZ.cmake +# +# The resulting tarball follows the KitMaker multi-variant naming convention: +# ---.tar.gz +# with archive root / and library variant subdirectory +# lib// (e.g. lib/13/) per the multi-variant layout. +# +# EXPORT_NAME: when provided, generates and installs cmake config/version files +# for the named export target, mirroring holohub_configure_deb behaviour. +# +# COMPONENTS: when provided, only those cmake install components (plus the +# cmake export component, if EXPORT_NAME is set) are included in the archive. +# When omitted, CMAKE_INSTALL_DEFAULT_COMPONENT_NAME is used instead. +function(holohub_configure_tgz) + set(requiredArgs NAME VERSION) + set(oneValueArgs NAME VERSION EXPORT_NAME) + set(multiValueArgs COMPONENTS) + cmake_parse_arguments(ARG "" "${oneValueArgs}" "${multiValueArgs}" ${ARGV}) + + foreach(arg ${requiredArgs}) + if(NOT ARG_${arg}) + list(APPEND _missing ${arg}) + endif() + endforeach() + if(_missing) + message(FATAL_ERROR "holohub_configure_tgz: missing required arguments: ${_missing}") + endif() + + if(ARG_EXPORT_NAME) + set(config_install_dir "lib/cmake/${ARG_NAME}") + set(export_component ${ARG_NAME}-cmake) + install( + EXPORT ${ARG_EXPORT_NAME} + DESTINATION ${config_install_dir} + NAMESPACE holoscan:: + COMPONENT ${export_component} + ) + include(CMakePackageConfigHelpers) + configure_package_config_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/Config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${ARG_NAME}Config.cmake" + INSTALL_DESTINATION ${config_install_dir} + NO_SET_AND_CHECK_MACRO + NO_CHECK_REQUIRED_COMPONENTS_MACRO + ) + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/${ARG_NAME}ConfigVersion.cmake" + VERSION "${ARG_VERSION}" + COMPATIBILITY AnyNewerVersion + ) + install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/${ARG_NAME}Config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/${ARG_NAME}ConfigVersion.cmake + DESTINATION ${config_install_dir} + COMPONENT ${export_component} + ) + endif() + + if(ARG_COMPONENTS) + set(_components "${ARG_COMPONENTS}") + else() + # Fall back to the cmake default component name. The caller is responsible + # for setting CMAKE_INSTALL_DEFAULT_COMPONENT_NAME before any install() + # rules so that unqualified installs land in the intended component. + set(_components "${CMAKE_INSTALL_DEFAULT_COMPONENT_NAME}") + endif() + if(ARG_EXPORT_NAME) + list(APPEND _components "${export_component}") + endif() + + # Filter to only the desired components by enumerating them in + # CPACK_INSTALL_CMAKE_PROJECTS. CPack.cmake only sets this variable when it + # is unset, so our value is preserved through include(CPack). This approach + # avoids CPACK_ARCHIVE_COMPONENT_INSTALL, which strips the root directory + # from the archive when used with the TGZ generator. + set(_install_projects "") + foreach(_comp IN LISTS _components) + list(APPEND _install_projects + "${CMAKE_BINARY_DIR}" "${PROJECT_NAME}" "${_comp}" "/") + endforeach() + set(CPACK_INSTALL_CMAKE_PROJECTS "${_install_projects}") + + # KitMaker requires underscores in the component name (no hyphens). + string(REPLACE "-" "_" _name_us "${ARG_NAME}") + + # KitMaker requires lowercase OS token. + string(TOLOWER "${CMAKE_SYSTEM_NAME}" _os) + + # Arch token comes from CMAKE_SYSTEM_PROCESSOR (x86_64, aarch64, sbsa, etc.). + set(_arch "${CMAKE_SYSTEM_PROCESSOR}") + + # KitMaker naming: --- + set(CPACK_PACKAGE_FILE_NAME "${_name_us}-${_os}-${_arch}-${ARG_VERSION}") + + # The archive root directory is the component name alone (KitMaker multi-variant + # convention). Inject it as the install prefix so files land at + # /lib/..., /include/..., etc. + # CPACK_INCLUDE_TOPLEVEL_DIRECTORY is disabled so CPack does not also prepend + # the full package-file-name stem, which would double-nest the root directory. + set(CPACK_PACKAGING_INSTALL_PREFIX "/${_name_us}") + set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY OFF) + + set(CPACK_GENERATOR "TGZ") + set(CPACK_STRIP_FILES TRUE) + + set(CPACK_OUTPUT_CONFIG_FILE + "${CMAKE_BINARY_DIR}/pkg/CPackConfig-${ARG_NAME}-TGZ.cmake") + set(CPACK_SOURCE_OUTPUT_CONFIG_FILE + "${CMAKE_BINARY_DIR}/pkg/CPackSourceConfig-${ARG_NAME}-TGZ.cmake") + + include(CPack) +endfunction() diff --git a/modules/holoscan-gstreamer/CMakeLists.txt b/modules/holoscan-gstreamer/CMakeLists.txt index ac0166482e..9ba019a882 100644 --- a/modules/holoscan-gstreamer/CMakeLists.txt +++ b/modules/holoscan-gstreamer/CMakeLists.txt @@ -14,6 +14,7 @@ # limitations under the License. include(holohub_configure_deb) +include(holohub_configure_tgz) # Debian packaging set(_deb_depends @@ -37,8 +38,22 @@ holohub_configure_deb( DEPENDS "${_deb_depends_str}" ) +holohub_configure_tgz( + NAME holoscan-gstreamer + VERSION 1.0.0 + COMPONENTS holoscan-gstreamer +) + +set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME gstreamer) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/NOTICE.md" DESTINATION "share/doc/holoscan-gstreamer" + COMPONENT holoscan-gstreamer +) + +install(FILES "${CMAKE_SOURCE_DIR}/LICENSE" + DESTINATION "." + COMPONENT holoscan-gstreamer ) # Wheel packaging: see pyproject.toml diff --git a/operators/gstreamer/CMakeLists.txt b/operators/gstreamer/CMakeLists.txt index c1708d1f45..61d1b05887 100644 --- a/operators/gstreamer/CMakeLists.txt +++ b/operators/gstreamer/CMakeLists.txt @@ -129,14 +129,14 @@ endif() # Install library install(TARGETS holoscan_gstreamer_bridge EXPORT holoscan-gstreamer-targets - DESTINATION ${CMAKE_INSTALL_LIBDIR} - COMPONENT gstreamer + DESTINATION ${CMAKE_INSTALL_LIBDIR}/${CUDAToolkit_VERSION_MAJOR} + COMPONENT holoscan-gstreamer ) # Write the holoscan-gstreamer-targets.cmake file install(EXPORT holoscan-gstreamer-targets NAMESPACE holoscan:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/holoscan-gstreamer - COMPONENT gstreamer + COMPONENT holoscan-gstreamer ) # Generate holoscan-gstreamer-config.cmake and version file so that downstream @@ -156,7 +156,7 @@ install(FILES "${CMAKE_CURRENT_BINARY_DIR}/cmake/holoscan-gstreamer-config.cmake" "${CMAKE_CURRENT_BINARY_DIR}/cmake/holoscan-gstreamer-config-version.cmake" DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/holoscan-gstreamer" - COMPONENT gstreamer + COMPONENT holoscan-gstreamer ) # Install headers. gst/ headers go into the gst/ subdestination so that the @@ -174,7 +174,7 @@ install(FILES gst_pipeline_bus_monitor.hpp gst_wait_group.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/holoscan/operators/gstreamer - COMPONENT gstreamer) + COMPONENT holoscan-gstreamer) install(FILES gst/allocator.hpp @@ -197,7 +197,7 @@ install(FILES gst/video_info.hpp gst/wrapper_base.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/holoscan/operators/gstreamer/gst - COMPONENT gstreamer) + COMPONENT holoscan-gstreamer) # CTest: install the gstreamer component into a temp prefix, then verify # find_package(holoscan-gstreamer) resolves correctly against the install tree. diff --git a/utilities/cli/holohub.py b/utilities/cli/holohub.py index c5c8d4fda5..0a48ea1149 100644 --- a/utilities/cli/holohub.py +++ b/utilities/cli/holohub.py @@ -483,7 +483,7 @@ def _create_parser(self) -> argparse.ArgumentParser: type=str, default="DEB", dest="pkg_generator", - help="Comma-separated package generators: DEB, WHEEL (default: DEB)", + help="Comma-separated package generators: DEB, TGZ, WHEEL (default: DEB)", ) package.add_argument("--language", choices=["cpp", "python"], default=None) package.add_argument("--verbose", action="store_true") @@ -2300,18 +2300,40 @@ def handle_package(self, args: argparse.Namespace) -> None: holohub_cli_util.run_command(build_cmd, dry_run=dryrun, env=build_env) pkg_config_dir = build_dir / "pkg" - cpack_configs = ( + all_cpack_configs = ( list(pkg_config_dir.glob("CPackConfig-*.cmake")) if pkg_config_dir.exists() else [] ) - if not cpack_configs and dryrun: + if not all_cpack_configs and dryrun: bare = project_name.replace("_", "-") if bare.startswith("holoscan-"): bare = bare[len("holoscan-") :] - cpack_configs = [pkg_config_dir / f"CPackConfig-holoscan-{bare}.cmake"] - for cpack_config in cpack_configs: - for gen in cpack_generators: + base_name = f"CPackConfig-holoscan-{bare}" + all_cpack_configs = [pkg_config_dir / f"{base_name}.cmake"] + # Also synthesize generator-specific configs so dry-run routing matches reality. + all_cpack_configs += [ + pkg_config_dir / f"{base_name}-{g}.cmake" for g in cpack_generators + ] + + # Separate generator-specific configs (e.g. CPackConfig-*-TGZ.cmake) + # from the base config so each generator uses the right one. + _KNOWN_GEN_SUFFIXES = ("TGZ", "DEB", "RPM", "ZIP") + gen_specific_configs: dict = {} + base_configs = [] + for c in all_cpack_configs: + stem_upper = c.stem.upper() + matched = next( + (g for g in _KNOWN_GEN_SUFFIXES if stem_upper.endswith(f"-{g}")), None + ) + if matched: + gen_specific_configs.setdefault(matched, []).append(c) + else: + base_configs.append(c) + + for gen in cpack_generators: + configs_for_gen = gen_specific_configs.get(gen) or base_configs + for cpack_config in configs_for_gen: holohub_cli_util.run_command( ["cpack", "--config", str(cpack_config), "-G", gen], dry_run=dryrun, From fded9c7ecfc9aa1aa2652db1e0e298ed6894e40d Mon Sep 17 00:00:00 2001 From: Tom Birdsong Date: Tue, 9 Jun 2026 09:24:54 -0400 Subject: [PATCH 2/4] Review: Address CodeRabbit feedback on holoscan-gstreamer TGZ packaging Addresses two findings from code review of MR !423. - Fix CTest install fixture to use --component holoscan-gstreamer (was --component gstreamer) so artifacts are installed when BUILD_TESTING is enabled - Add hard-fail in holohub.py when no CPack configs exist after a real build (non-dryrun path) - Add hard-fail when no config is found for a specific requested generator, listing available generator-specific configs in the error Assisted-by: Claude:claude-sonnet-4-6 Signed-off-by: Tom Birdsong --- operators/gstreamer/CMakeLists.txt | 4 ++-- utilities/cli/holohub.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/operators/gstreamer/CMakeLists.txt b/operators/gstreamer/CMakeLists.txt index 61d1b05887..5afbf96b1e 100644 --- a/operators/gstreamer/CMakeLists.txt +++ b/operators/gstreamer/CMakeLists.txt @@ -206,13 +206,13 @@ if(BUILD_TESTING) set(_gst_test_prefix "${CMAKE_CURRENT_BINARY_DIR}/tests/cmake/install_prefix") - # Fixture: cmake --install with --component gstreamer so only this operator's + # Fixture: cmake --install with --component holoscan-gstreamer so only this operator's # artifacts land in the temp prefix (safe in standalone and superbuild contexts). add_test( NAME holoscan_gstreamer_install COMMAND "${CMAKE_COMMAND}" --install "${CMAKE_BINARY_DIR}" - --component gstreamer + --component holoscan-gstreamer --prefix "${_gst_test_prefix}" ) set_tests_properties(holoscan_gstreamer_install PROPERTIES diff --git a/utilities/cli/holohub.py b/utilities/cli/holohub.py index 0a48ea1149..92e255e3cc 100644 --- a/utilities/cli/holohub.py +++ b/utilities/cli/holohub.py @@ -2315,6 +2315,11 @@ def handle_package(self, args: argparse.Namespace) -> None: all_cpack_configs += [ pkg_config_dir / f"{base_name}-{g}.cmake" for g in cpack_generators ] + elif not all_cpack_configs: + holohub_cli_util.fatal( + f"No CPack config files were generated in {pkg_config_dir}. " + "Check module packaging configuration." + ) # Separate generator-specific configs (e.g. CPackConfig-*-TGZ.cmake) # from the base config so each generator uses the right one. @@ -2333,6 +2338,12 @@ def handle_package(self, args: argparse.Namespace) -> None: for gen in cpack_generators: configs_for_gen = gen_specific_configs.get(gen) or base_configs + if not configs_for_gen: + available = ", ".join(sorted(gen_specific_configs.keys())) or "none" + holohub_cli_util.fatal( + f"No CPack config found for generator '{gen}' in {pkg_config_dir}. " + f"Available generator-specific configs: {available}." + ) for cpack_config in configs_for_gen: holohub_cli_util.run_command( ["cpack", "--config", str(cpack_config), "-G", gen], From d7538f6901d30f92bfbfebf400968f0c40db08ed Mon Sep 17 00:00:00 2001 From: Tom Birdsong Date: Tue, 9 Jun 2026 09:35:33 -0400 Subject: [PATCH 3/4] Test: Add unit tests for TGZ packaging in TestHandlePackage New coverage for holohub_configure_tgz / CLI packaging changes. - test_package_tgz_invokes_cpack_tgz: verifies -G TGZ is passed to cpack when --pkg-generator TGZ is requested - test_package_multi_generator_deb_tgz: verifies two separate cpack calls are made for DEB,TGZ (one per generator) - test_package_tgz_routes_to_generator_specific_config: verifies CPackConfig-*-TGZ.cmake is used for TGZ and the base config for DEB - test_package_missing_cpack_configs_fatal: verifies fatal() is called when the build dir contains no CPack configs (non-dryrun path) - test_package_missing_generator_config_fatal: verifies fatal() lists available generators when the requested one has no matching config Assisted-by: Claude:claude-sonnet-4-6 Signed-off-by: Tom Birdsong --- utilities/cli/tests/test_cli.py | 176 ++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/utilities/cli/tests/test_cli.py b/utilities/cli/tests/test_cli.py index 5ad846b80d..8521e6f773 100644 --- a/utilities/cli/tests/test_cli.py +++ b/utilities/cli/tests/test_cli.py @@ -1687,6 +1687,182 @@ def test_package_container_passes_cuda_version( extra_scripts=None, ) + @patch("utilities.cli.holohub.write_external_operators_manifest") + @patch("utilities.cli.holohub.HoloHubCLI.find_project") + @patch("utilities.cli.util.run_command") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.glob") + @patch("pathlib.Path.exists") + def test_package_tgz_invokes_cpack_tgz( + self, + mock_exists, + mock_glob, + mock_mkdir, + mock_run_command, + mock_find_project, + mock_write_manifest, + ): + """TGZ path calls cpack with -G TGZ using a generator-specific config.""" + mock_find_project.return_value = self.mock_module_data + mock_run_command.return_value = MagicMock() + mock_exists.return_value = True + cpack_cfg = Path( + "/build/test_module_fixture/package/pkg/CPackConfig-test-module-fixture-TGZ.cmake" + ) + mock_glob.return_value = [cpack_cfg] + + args = self.cli.parser.parse_args( + ["package", "test-module-fixture", "--local", "--pkg-generator", "TGZ"] + ) + args.func(args) + + self.assertEqual(mock_run_command.call_count, 3) # cmake configure, cmake build, cpack + cpack_args = mock_run_command.call_args_list[2][0][0] + self.assertEqual(cpack_args[0], "cpack") + self.assertIn("-G", cpack_args) + self.assertIn("TGZ", cpack_args) + + @patch("utilities.cli.holohub.write_external_operators_manifest") + @patch("utilities.cli.holohub.HoloHubCLI.find_project") + @patch("utilities.cli.util.run_command") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.glob") + @patch("pathlib.Path.exists") + def test_package_multi_generator_deb_tgz( + self, + mock_exists, + mock_glob, + mock_mkdir, + mock_run_command, + mock_find_project, + mock_write_manifest, + ): + """DEB,TGZ produces two cpack calls, one per generator.""" + mock_find_project.return_value = self.mock_module_data + mock_run_command.return_value = MagicMock() + mock_exists.return_value = True + base_cfg = Path("/build/pkg/CPackConfig-test-module-fixture.cmake") + tgz_cfg = Path("/build/pkg/CPackConfig-test-module-fixture-TGZ.cmake") + mock_glob.return_value = [base_cfg, tgz_cfg] + + args = self.cli.parser.parse_args( + ["package", "test-module-fixture", "--local", "--pkg-generator", "DEB,TGZ"] + ) + args.func(args) + + # cmake configure, cmake build, cpack DEB, cpack TGZ + self.assertEqual(mock_run_command.call_count, 4) + deb_cpack_args = mock_run_command.call_args_list[2][0][0] + self.assertIn("DEB", deb_cpack_args) + tgz_cpack_args = mock_run_command.call_args_list[3][0][0] + self.assertIn("TGZ", tgz_cpack_args) + + @patch("utilities.cli.holohub.write_external_operators_manifest") + @patch("utilities.cli.holohub.HoloHubCLI.find_project") + @patch("utilities.cli.util.run_command") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.glob") + @patch("pathlib.Path.exists") + def test_package_tgz_routes_to_generator_specific_config( + self, + mock_exists, + mock_glob, + mock_mkdir, + mock_run_command, + mock_find_project, + mock_write_manifest, + ): + """Generator-specific config (CPackConfig-*-TGZ.cmake) is used for TGZ; base config for DEB.""" + mock_find_project.return_value = self.mock_module_data + mock_run_command.return_value = MagicMock() + mock_exists.return_value = True + base_cfg = Path("/build/pkg/CPackConfig-test-module-fixture.cmake") + tgz_cfg = Path("/build/pkg/CPackConfig-test-module-fixture-TGZ.cmake") + mock_glob.return_value = [base_cfg, tgz_cfg] + + args = self.cli.parser.parse_args( + ["package", "test-module-fixture", "--local", "--pkg-generator", "DEB,TGZ"] + ) + args.func(args) + + deb_args_str = " ".join(str(a) for a in mock_run_command.call_args_list[2][0][0]) + self.assertIn(str(base_cfg), deb_args_str) + self.assertNotIn(str(tgz_cfg), deb_args_str) + + tgz_args_str = " ".join(str(a) for a in mock_run_command.call_args_list[3][0][0]) + self.assertIn(str(tgz_cfg), tgz_args_str) + self.assertNotIn(str(base_cfg), tgz_args_str) + + @patch("utilities.cli.holohub.write_external_operators_manifest") + @patch("utilities.cli.holohub.HoloHubCLI.find_project") + @patch("utilities.cli.util.run_command") + @patch("utilities.cli.util.fatal") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.glob") + @patch("pathlib.Path.exists") + def test_package_missing_cpack_configs_fatal( + self, + mock_exists, + mock_glob, + mock_mkdir, + mock_fatal, + mock_run_command, + mock_find_project, + mock_write_manifest, + ): + """When the build produces no CPack configs, fatal is called with a clear message.""" + mock_find_project.return_value = self.mock_module_data + mock_run_command.return_value = MagicMock() + mock_exists.return_value = True + mock_glob.return_value = [] + mock_fatal.side_effect = SystemExit(1) + + args = self.cli.parser.parse_args( + ["package", "test-module-fixture", "--local", "--pkg-generator", "TGZ"] + ) + with self.assertRaises(SystemExit): + args.func(args) + + mock_fatal.assert_called_once() + self.assertIn("No CPack config files", mock_fatal.call_args[0][0]) + + @patch("utilities.cli.holohub.write_external_operators_manifest") + @patch("utilities.cli.holohub.HoloHubCLI.find_project") + @patch("utilities.cli.util.run_command") + @patch("utilities.cli.util.fatal") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.glob") + @patch("pathlib.Path.exists") + def test_package_missing_generator_config_fatal( + self, + mock_exists, + mock_glob, + mock_mkdir, + mock_fatal, + mock_run_command, + mock_find_project, + mock_write_manifest, + ): + """When no config exists for the requested generator, fatal lists available generators.""" + mock_find_project.return_value = self.mock_module_data + mock_run_command.return_value = MagicMock() + mock_exists.return_value = True + # Only a DEB-specific config exists; TGZ is requested + deb_cfg = Path("/build/pkg/CPackConfig-test-module-fixture-DEB.cmake") + mock_glob.return_value = [deb_cfg] + mock_fatal.side_effect = SystemExit(1) + + args = self.cli.parser.parse_args( + ["package", "test-module-fixture", "--local", "--pkg-generator", "TGZ"] + ) + with self.assertRaises(SystemExit): + args.func(args) + + mock_fatal.assert_called_once() + error_msg = mock_fatal.call_args[0][0] + self.assertIn("TGZ", error_msg) + self.assertIn("DEB", error_msg) # available generator listed in error + class TestRunCommand(unittest.TestCase): """Test the run_command function with explicit shell parameter""" From eaeb3424cabd9bd7476a22ae656fba6223e3a5f4 Mon Sep 17 00:00:00 2001 From: Tom Birdsong Date: Tue, 9 Jun 2026 10:41:03 -0400 Subject: [PATCH 4/4] Module: Fix wheel rpath and scope KitMaker lib/ path to TGZ builds Address divergent path requirements: - wheel and Deb format expect "lib/holoscan_gstreamer_bridge.so" - TGZ format expects "lib//holoscan_gstreamer_bridge.so" Update TGZ handling as follows: - Revert the operator library install to DESTINATION ${CMAKE_INSTALL_LIBDIR}; holohub_configure_tgz now overrides CMAKE_INSTALL_LIBDIR to lib/ via the cmake cache when HOLOHUB_PKG_TGZ=ON, exploiting HoloHub's modules-before-operators build order so operator install() rules pick it up - CLI passes -DHOLOHUB_PKG_TGZ=ON to cmake configure only when TGZ is in the requested generators, leaving DEB, WHEEL, and standalone builds unaffected - Update CMAKE_INSTALL_DEFAULT_COMPONENT_NAME to holoscan-gstreamer (was gstreamer) and add unit tests asserting the flag is present for TGZ builds and absent for DEB-only builds Assisted-by: Claude:claude-sonnet-4-6 Signed-off-by: Tom Birdsong --- cmake/modules/holohub_configure_tgz.cmake | 13 +++++++++++++ modules/holoscan-gstreamer/CMakeLists.txt | 2 +- operators/gstreamer/CMakeLists.txt | 2 +- utilities/cli/holohub.py | 2 ++ utilities/cli/tests/test_cli.py | 5 +++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/cmake/modules/holohub_configure_tgz.cmake b/cmake/modules/holohub_configure_tgz.cmake index 5da372004d..cc1fdb7bdf 100644 --- a/cmake/modules/holohub_configure_tgz.cmake +++ b/cmake/modules/holohub_configure_tgz.cmake @@ -37,6 +37,19 @@ function(holohub_configure_tgz) set(multiValueArgs COMPONENTS) cmake_parse_arguments(ARG "" "${oneValueArgs}" "${multiValueArgs}" ${ARGV}) + # KitMaker multi-variant layout requires libraries under lib//. + # Override CMAKE_INSTALL_LIBDIR in the cache so operator install() rules — + # processed after modules in HoloHub's build order — use the versioned path. + # Only active when the CLI sets HOLOHUB_PKG_TGZ=ON (i.e. --pkg-generator TGZ); + # all other builds (normal installs, DEB, wheel) keep the default lib/ path. + if(HOLOHUB_PKG_TGZ) + find_package(CUDAToolkit QUIET) + if(CUDAToolkit_FOUND) + set(CMAKE_INSTALL_LIBDIR "lib/${CUDAToolkit_VERSION_MAJOR}" + CACHE STRING "Library install directory (KitMaker multi-variant layout)" FORCE) + endif() + endif() + foreach(arg ${requiredArgs}) if(NOT ARG_${arg}) list(APPEND _missing ${arg}) diff --git a/modules/holoscan-gstreamer/CMakeLists.txt b/modules/holoscan-gstreamer/CMakeLists.txt index 9ba019a882..fcd141e43a 100644 --- a/modules/holoscan-gstreamer/CMakeLists.txt +++ b/modules/holoscan-gstreamer/CMakeLists.txt @@ -44,7 +44,7 @@ holohub_configure_tgz( COMPONENTS holoscan-gstreamer ) -set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME gstreamer) +set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME holoscan-gstreamer) install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/NOTICE.md" DESTINATION "share/doc/holoscan-gstreamer" diff --git a/operators/gstreamer/CMakeLists.txt b/operators/gstreamer/CMakeLists.txt index 5afbf96b1e..57b6ee58c6 100644 --- a/operators/gstreamer/CMakeLists.txt +++ b/operators/gstreamer/CMakeLists.txt @@ -129,7 +129,7 @@ endif() # Install library install(TARGETS holoscan_gstreamer_bridge EXPORT holoscan-gstreamer-targets - DESTINATION ${CMAKE_INSTALL_LIBDIR}/${CUDAToolkit_VERSION_MAJOR} + DESTINATION ${CMAKE_INSTALL_LIBDIR} COMPONENT holoscan-gstreamer ) # Write the holoscan-gstreamer-targets.cmake file diff --git a/utilities/cli/holohub.py b/utilities/cli/holohub.py index 92e255e3cc..c9d5b0e790 100644 --- a/utilities/cli/holohub.py +++ b/utilities/cli/holohub.py @@ -2284,6 +2284,8 @@ def handle_package(self, args: argparse.Namespace) -> None: f"-DMODULE_{pkg_slug}=ON", f"-DPKG_{pkg_slug}=ON", ] + if "TGZ" in cpack_generators: + cmake_args.append("-DHOLOHUB_PKG_TGZ=ON") if shutil.which("ninja"): cmake_args.extend(["-G", "Ninja"]) holohub_cli_util.run_command(cmake_args, dry_run=dryrun, env=build_env) diff --git a/utilities/cli/tests/test_cli.py b/utilities/cli/tests/test_cli.py index 8521e6f773..d161cf2239 100644 --- a/utilities/cli/tests/test_cli.py +++ b/utilities/cli/tests/test_cli.py @@ -1570,6 +1570,7 @@ def test_package_deb_emits_module_cmake_flag( self.assertIn("-DPKG_test_module_fixture=ON", cmake_args_str) self.assertIn("-DBUILD_ALL=OFF", cmake_args_str) self.assertIn(str(HoloHubCLI.HOLOHUB_ROOT), cmake_args_str) + self.assertNotIn("-DHOLOHUB_PKG_TGZ=ON", cmake_args_str) cpack_args = mock_run_command.call_args_list[2][0][0] self.assertEqual(cpack_args[0], "cpack") @@ -1717,6 +1718,8 @@ def test_package_tgz_invokes_cpack_tgz( args.func(args) self.assertEqual(mock_run_command.call_count, 3) # cmake configure, cmake build, cpack + cmake_args_str = " ".join(mock_run_command.call_args_list[0][0][0]) + self.assertIn("-DHOLOHUB_PKG_TGZ=ON", cmake_args_str) cpack_args = mock_run_command.call_args_list[2][0][0] self.assertEqual(cpack_args[0], "cpack") self.assertIn("-G", cpack_args) @@ -1752,6 +1755,8 @@ def test_package_multi_generator_deb_tgz( # cmake configure, cmake build, cpack DEB, cpack TGZ self.assertEqual(mock_run_command.call_count, 4) + cmake_args_str = " ".join(mock_run_command.call_args_list[0][0][0]) + self.assertIn("-DHOLOHUB_PKG_TGZ=ON", cmake_args_str) deb_cpack_args = mock_run_command.call_args_list[2][0][0] self.assertIn("DEB", deb_cpack_args) tgz_cpack_args = mock_run_command.call_args_list[3][0][0]