diff --git a/.github/CI.md b/.github/CI.md index 3eff3108..3fe70223 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -262,13 +262,15 @@ each pattern in two lists: * **required** — files that must be present in the wheel: * `holoscan_cli/logging.json` * `holoscan_cli/py.typed` + * `holoscan_cli/cmake/` (support copied into generated standalone Modules) * `holoscan_cli/metadata/*.schema.json` * `holoscan_cli/setup_scripts/*` + * `holoscan_cli/templates/module/` * `holoscan_cli/testing/` * **forbidden** — paths that must NOT be present (regressions from past cleanups): - * `holoscan_cli/cmake/` (moved to HoloHub in commit `6aeb611`) * `holoscan_cli/testing/test_all_applications/` (decoupled in `2d2f44a`) + * `holoscan_cli/templates/module/*/holohub` (standalone Modules ship no wrapper) The same script runs in both pipelines so a wheel that passes `main.yaml` will pass `release.yaml`. diff --git a/.github/scripts/assert_wheel_contents.sh b/.github/scripts/assert_wheel_contents.sh index 7623d13a..d2fe43fb 100755 --- a/.github/scripts/assert_wheel_contents.sh +++ b/.github/scripts/assert_wheel_contents.sh @@ -22,12 +22,21 @@ listing=$(unzip -l "$wheel") required=( 'holoscan_cli/logging\.json$' 'holoscan_cli/py\.typed$' + 'holoscan_cli/cmake/Config\.cmake\.in$' + 'holoscan_cli/cmake/HoloHubConfigHelpers\.cmake$' + 'holoscan_cli/cmake/holohub_configure_deb\.cmake$' + 'holoscan_cli/cmake/pybind11_add_holohub_module\.cmake$' + 'holoscan_cli/cmake/pybind11/__init__\.py\.in$' + 'holoscan_cli/cmake/pydoc/macros\.hpp$' 'holoscan_cli/metadata/.+\.schema\.json$' 'holoscan_cli/setup_scripts/.+' 'holoscan_cli/setup_scripts/requirements\.template\.txt$' 'holoscan_cli/templates/module/cookiecutter\.json$' + 'holoscan_cli/templates/module/hooks/pre_gen_project\.py$' 'holoscan_cli/templates/module/hooks/post_gen_project\.py$' - 'holoscan_cli/templates/module/.+/holohub$' + 'holoscan_cli/templates/module/.+/requirements-cli\.txt$' + 'holoscan_cli/templates/module/.+/\.dockerignore$' + 'holoscan_cli/templates/module/.+/\.github/workflows/scripts/check_copyright\.py$' 'holoscan_cli/testing/' ) for pattern in "${required[@]}"; do @@ -38,8 +47,8 @@ for pattern in "${required[@]}"; do done forbidden=( - 'holoscan_cli/cmake/' 'holoscan_cli/testing/test_all_applications/' + 'holoscan_cli/templates/module/.+/holohub(/|$)' ) for pattern in "${forbidden[@]}"; do if echo "$listing" | grep -qE "$pattern"; then diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index c2969f86..fbc7abe5 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -261,6 +261,48 @@ jobs: /tmp/holoscan-cli-smoke/bin/python -c \ 'import cookiecutter, jsonschema, referencing' + create_root=$(mktemp -d) + installed_version=$(/tmp/holoscan-cli-smoke/bin/python -c \ + 'from importlib.metadata import version; print(version("holoscan-cli"))') + for language in cpp python; do + project_name="Artifact ${language}" + module_root="${create_root}/holoscan-artifact-${language}" + operator_extension=cpp + if [[ "${language}" == "python" ]]; then + operator_extension=py + fi + /tmp/holoscan-cli-smoke/bin/holoscan create "${project_name}" \ + --interactive false \ + --language "${language}" \ + --directory "${create_root}" + + test -f "${module_root}/metadata.json" + /tmp/holoscan-cli-smoke/bin/python -c \ + 'import json,sys; data=json.load(open(sys.argv[1])); assert "module" in data' \ + "${module_root}/metadata.json" + grep -Fx "holoscan-cli==${installed_version}" \ + "${module_root}/requirements-cli.txt" + grep -F "\"holoscan-cli==${installed_version}\"" "${module_root}/pyproject.toml" + ! grep -F "holoscan-cli[create]" "${module_root}/pyproject.toml" + test ! -e "${module_root}/holohub" + test ! -e "${module_root}/holoscan" + test -f "${module_root}/CMakeLists.txt" + test -f \ + "${module_root}/applications/artifact_${language}_pipeline/python/metadata.json" + test -f \ + "${module_root}/operators/artifact_${language}_op/artifact_${language}_op.${operator_extension}" + ( + cd "${module_root}" + /tmp/holoscan-cli-smoke/bin/holoscan version --json | \ + /tmp/holoscan-cli-smoke/bin/python -c \ + 'import json,sys; data=json.load(sys.stdin); assert data["version"] == sys.argv[1]' \ + "${installed_version}" + /tmp/holoscan-cli-smoke/bin/holoscan list --json | \ + /tmp/holoscan-cli-smoke/bin/python -c \ + 'import json,sys; data=json.load(sys.stdin); assert any(p["project_type"] == "module" for p in data["projects"])' + ) + done + - name: Install sdist in clean venv run: | python -m venv /tmp/holoscan-cli-sdist-smoke diff --git a/README.md b/README.md index b7eed299..a37d99a6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,15 @@ Per-repo wrappers install this package and delegate to `holoscan`, layering on t | [HoloHub](https://github.com/nvidia-holoscan/holohub) | `./holohub` | source-project metadata search paths, container/workspace names | | [I4H Workflows](https://github.com/isaac-for-healthcare/i4h-workflows) | `./i4h` | RTI DDS license auto-download + mount, TTY serial device passthrough | -Common env vars: `HOLOSCAN_CLI_ROOT` (repo root), `HOLOSCAN_CLI_SEARCH_PATH` (subdirs to scan for `metadata.json`), `HOLOSCAN_CLI_PATH_PREFIX` (placeholder prefix in metadata templates), `HOLOSCAN_CLI_REPO_PREFIX` (container image name prefix). The legacy `HOLOHUB_*` spelling is no longer honored since holoscan v4.3.0 — set the `HOLOSCAN_CLI_*` names directly. `holoscan env-info` lists every env var the CLI reads in the current shell. +Common env vars: + +- `HOLOSCAN_CLI_ROOT` — repo root +- `HOLOSCAN_CLI_SEARCH_PATH` — subdirs to scan for `metadata.json` +- `HOLOSCAN_CLI_PATH_PREFIX` — placeholder prefix in metadata templates +- `HOLOSCAN_CLI_REPO_PREFIX` — container image name prefix +- `HOLOSCAN_CLI_CREATE_TEMPLATE` — default template for `holoscan create` + +`holoscan env-info` lists every env var the CLI reads in the current shell. ## JSON output @@ -45,11 +53,13 @@ src/holoscan_cli/ cli.py top-level argparse + dispatch (HoloscanCLI) commands/ one file per subcommand + a central registry container/ HoloscanContainer + docker arg helpers + parser builders + cmake/ packaged CMake support copied into standalone Modules utils/ io.py, text.py, sdk.py, docker.py, host_setup.py, env_info.py, holohub.py setup_scripts/ bundled bash scripts backing `setup --scripts` and `build-container --extra-scripts` metadata/ project metadata JSON schemas + templates/module/ standalone Module cookiecutter testing/ CTest helpers shipped in the wheel ``` @@ -80,6 +90,23 @@ uvx --from holoscan-cli holoscan --help pipx run --spec holoscan-cli holoscan --help ``` +Creating a standalone Module needs the optional creation dependencies. NVIDIA's +index is included so release candidates are available too. This command requires +`uv` 0.4.23 or later for `uvx --index` support: + +```bash +uvx --index https://pypi.nvidia.com \ + --from 'holoscan-cli[create]' holoscan create my-sensor +``` + +To run any command against a project outside the current directory, pass the +global `--project-root PATH` before the subcommand (equivalent to setting +`HOLOSCAN_CLI_ROOT`): + +```bash +holoscan --project-root ~/holoscan-my-sensor list +``` + ## Versioning `holoscan-cli` release versions are aligned with Holoscan SDK GA release diff --git a/pyproject.toml b/pyproject.toml index e3e8969e..bf9db099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ Documentation = "https://docs.nvidia.com/holoscan/sdk-user-guide/index.html" requires-poetry = ">=2.0" packages = [{ include = "holoscan_cli", from = "src" }] include = [ + { path = "src/holoscan_cli/cmake/**/*", format = ["sdist", "wheel"] }, { path = "src/holoscan_cli/metadata/*.schema.json", format = ["sdist", "wheel"] }, { path = "src/holoscan_cli/setup_scripts/*", format = ["sdist", "wheel"] }, { path = "src/holoscan_cli/templates/**/*", format = ["sdist", "wheel"] }, @@ -100,6 +101,7 @@ tomli = { version = "^2.4", markers = "python_version < '3.11'" } # schema validator (``test_metadata_validator.py``) and the smoke # fixture (``test_smoke_fixture.py``) can import them without # requiring callers to ``pip install 'holoscan-cli[create]'`` first. +cookiecutter = ">=2.7.1" jsonschema = ">=4.26.0,<5.0" referencing = ">=0.37.0" diff --git a/src/holoscan_cli/__main__.py b/src/holoscan_cli/__main__.py index ff535977..37926f7f 100644 --- a/src/holoscan_cli/__main__.py +++ b/src/holoscan_cli/__main__.py @@ -22,6 +22,11 @@ from typing import Optional, Union from .commands.registry import project_command_help +from .project_context import ( + ProjectContextError, + activate_project_context, + discover_project_context, +) logging.getLogger("docker.api.build").setLevel(logging.WARNING) logging.getLogger("docker.auth").setLevel(logging.WARNING) @@ -59,6 +64,19 @@ ) +class DispatchUsageError(ValueError): + """A top-level option is invalid or misplaced.""" + + +# Top-level options consumed before the subcommand, mapped to the value each one +# expects. Used for both parsing and the "requires a ..." usage errors. +TOP_LEVEL_OPTIONS = { + "-l": "logging level", + "--log-level": "logging level", + "--project-root": "directory path", +} + + def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: if argv is None: argv = sys.argv @@ -97,6 +115,11 @@ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: dest="show_version", help="display the holoscan-cli package version", ) + parser.add_argument( + "--project-root", + metavar="PATH", + help="use PATH as the source-project root (must appear before the subcommand)", + ) subparser = parser.add_subparsers(dest="command") @@ -158,39 +181,57 @@ def _program_name(argv: list[str]) -> str: return "holoscan" if command_name == "__main__.py" else command_name -def _project_dispatch_argv(argv: list[str]) -> tuple[Optional[str], list[str], Optional[str]]: - """Return command, argv with top-level options removed, and requested log level.""" +def _project_dispatch_argv( + argv: list[str], +) -> tuple[Optional[str], list[str], Optional[str], Optional[str]]: + """Return command, stripped argv, log level, and explicit project root.""" project_argv = [argv[0]] log_level = None + project_root = None index = 1 while index < len(argv): - arg = argv[index] - if arg in {"-l", "--log-level"} and index + 1 < len(argv): - log_level = argv[index + 1].upper() - index += 2 - continue - if arg.startswith("--log-level="): - log_level = arg.split("=", 1)[1].upper() - index += 1 - continue - - project_argv.extend(argv[index:]) - break + name, equals, inline_value = argv[index].partition("=") + if name not in TOP_LEVEL_OPTIONS: + project_argv.extend(argv[index:]) + break + if equals: + value, index = inline_value, index + 1 + elif index + 1 < len(argv): + value, index = argv[index + 1], index + 2 + else: + raise DispatchUsageError(f"{name} requires a {TOP_LEVEL_OPTIONS[name]}.") + + if name == "--project-root": + if project_root is not None: + raise DispatchUsageError("--project-root may be specified only once.") + # A bare subcommand or another option here means the path was omitted. + if not value or value.startswith("-") or value in {*PROJECT_COMMANDS, "version"}: + raise DispatchUsageError("--project-root requires a non-empty directory path.") + project_root = value + else: + # argparse never sees this prefix form, so apply its choices here. + log_level = value.upper() + if log_level not in LOG_LEVELS: + raise DispatchUsageError( + f"{name} must be one of {', '.join(LOG_LEVELS)}; got {value!r}." + ) command = project_argv[1] if len(project_argv) > 1 else None - return command, project_argv, log_level - - -def _exit_if_removed_command(argv: list[str]) -> None: - """Print a removal note and exit 2 if argv's first non-flag token names a - removed subcommand. Runs before any parser so users typing the old name - see why it's gone instead of argparse's bare "invalid choice". - """ - command, _, _ = _project_dispatch_argv(argv) - if command is None or command not in REMOVED_COMMANDS: + for arg in project_argv[2:]: + if arg == "--project-root" or arg.startswith("--project-root="): + program = _program_name(argv) + raise DispatchUsageError( + f"--project-root is a global option; place it before {command!r}, for example: " + f"{program} --project-root PATH {command}" + ) + return command, project_argv, log_level, project_root + + +def _exit_if_removed_command(program: str, command: Optional[str]) -> None: + """Explain a removed subcommand before argparse reports an invalid choice.""" + if command not in REMOVED_COMMANDS: return - program = _program_name(argv) print( f"Error: '{program} {command}' was removed since holoscan v4.3.0 — " f"{REMOVED_COMMANDS[command]} is no longer shipped.\n" @@ -200,12 +241,24 @@ def _exit_if_removed_command(argv: list[str]) -> None: sys.exit(2) -def _dispatch_project_cli(argv: list[str]) -> bool: +def _dispatch_project_cli( + command: Optional[str], + project_argv: list[str], + log_level: Optional[str], + project_root: Optional[str], +) -> bool: """Forward source-project commands to the ported project CLI.""" - command, project_argv, log_level = _project_dispatch_argv(argv) if command not in PROJECT_COMMANDS: return False + # Creation must not inherit an enclosing project's defaults unless the + # caller explicitly selects that project. + if command != "create" or project_root is not None: + context = discover_project_context(explicit_root=project_root) + for warning in context.warnings: + print(f"Warning: {warning}", file=sys.stderr) + activate_project_context(context) + set_up_logging(log_level) from .cli import main as project_main @@ -219,12 +272,17 @@ def _dispatch(argv: Optional[list[str]]) -> None: argv = sys.argv argv = list(argv) - _exit_if_removed_command(argv) + command, native_argv, log_level, project_root = _project_dispatch_argv(argv) + + _exit_if_removed_command(_program_name(argv), command) - if _dispatch_project_cli(argv): + if _dispatch_project_cli(command, native_argv, log_level, project_root): return - args = parse_args(argv) + args = parse_args(native_argv) + if log_level is not None: + args.log_level = log_level + args.project_root = project_root set_up_logging(args.log_level) @@ -237,6 +295,9 @@ def _dispatch(argv: Optional[list[str]]) -> None: def main(argv: Optional[list[str]] = None): try: _dispatch(argv) + except (DispatchUsageError, ProjectContextError) as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(2) from None except KeyboardInterrupt: # The CLI owns pre-launch work. After launch, exec removes this frame # and the application retains control of its signal handling and status. diff --git a/src/holoscan_cli/cmake/Config.cmake.in b/src/holoscan_cli/cmake/Config.cmake.in new file mode 100644 index 00000000..5dfbdb10 --- /dev/null +++ b/src/holoscan_cli/cmake/Config.cmake.in @@ -0,0 +1,7 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(holoscan REQUIRED COMPONENTS core) + +include("${CMAKE_CURRENT_LIST_DIR}/@ARG_EXPORT_NAME@.cmake") +check_required_components(@ARG_NAME@) diff --git a/src/holoscan_cli/cmake/HoloHubConfigHelpers.cmake b/src/holoscan_cli/cmake/HoloHubConfigHelpers.cmake new file mode 100644 index 00000000..7c0d60db --- /dev/null +++ b/src/holoscan_cli/cmake/HoloHubConfigHelpers.cmake @@ -0,0 +1,329 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-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 Configuration Helpers +# ============================= +# +# This file provides CMake helper functions for building HoloHub packages, applications, +# operators, and extensions. These functions simplify the build configuration process +# and handle dependency management automatically. +# +# Available Functions: +# - add_holohub_package(): Build packages with dependencies +# - add_holohub_application(): Build applications with operator/extension dependencies +# - add_holohub_operator(): Build operators with extension dependencies +# - add_holohub_extension(): Build extensions +# +# Holoscan Modules (in-tree and external): +# - add_holohub_module(): Enable an in-tree Holoscan Module subproject (MODULE_ option) +# - holohub_declare_external_module(): Declare an external Holoscan Module fetched via +# FetchContent and register its operators with HoloHub's lazy-fetch post-step +# +# Global Variables: +# - BUILD_ALL: Global flag to enable/disable all components (default: OFF) +# - HOLOHUB_BUILD_OPERATORS: List of operators to build when optional dependencies are specified +# +# Usage Examples: +# add_holohub_package(my_package EXTENSIONS gxf_core OPERATORS my_op APPLICATIONS my_app) +# add_holohub_application(my_app DEPENDS EXTENSIONS gxf_core OPERATORS my_op) +# add_holohub_operator(my_op DEPENDS EXTENSIONS gxf_core) +# add_holohub_extension(my_ext) + +# ===================================================== +# Helper function to build packages +# ===================================================== +# Builds a package and automatically enables its dependencies. +# +# Parameters: +# NAME: The name of the package to build +# +# Keyword Arguments: +# EXTENSIONS: List of GXF extensions that this package depends on +# OPERATORS: List of Holoscan operators that this package depends on +# APPLICATIONS: List of applications that this package depends on +# +# Creates: +# PKG_${NAME}: CMake option to enable/disable this package +# +# Example: +# add_holohub_package(my_package +# EXTENSIONS gxf_core gxf_serialization +# OPERATORS my_operator +# APPLICATIONS my_application +# ) +function(add_holohub_package NAME) + # Normalize hyphens to underscores for the CMake cache variable so that + # -DPKG_holoscan_gstreamer=ON (CLI convention) matches the option regardless + # of whether the caller spelled the name with hyphens or underscores. + string(REPLACE "-" "_" _pkg_slug "${NAME}") + set(pkgname "PKG_${_pkg_slug}") + option(${pkgname} "Build the ${NAME} package" ${BUILD_ALL}) + + message(DEBUG "${pkgname} = ${${pkgname}}") + + # Configure the package if enabled + if(NOT ${pkgname}) + return() + endif() + add_subdirectory(${NAME}) + + # If we have dependencies make sure they are built + cmake_parse_arguments(DEPS "" "" "EXTENSIONS;OPERATORS;APPLICATIONS" ${ARGN}) + message(DEBUG "${pkgname} exts = ${DEPS_EXTENSIONS}") + message(DEBUG "${pkgname} ops = ${DEPS_OPERATORS}") + message(DEBUG "${pkgname} apps = ${DEPS_APPLICATIONS}") + foreach(dep IN LISTS DEPS_EXTENSIONS) + set("EXT_${dep}" ON CACHE BOOL "Build the ${dep} GXF extension" FORCE) + endforeach() + foreach(dep IN LISTS DEPS_OPERATORS) + set("OP_${dep}" ON CACHE BOOL "Build the ${dep} holoscan operator" FORCE) + endforeach() + foreach(dep IN LISTS DEPS_APPLICATIONS) + set("APP_${dep}" ON CACHE BOOL "Build the ${dep} application" FORCE) + endforeach() +endfunction() + +# ===================================================== +# Helper function to enable an in-tree Holoscan Module +# ===================================================== +# Enables a Holoscan Module subproject and force-enables its operator/application +# dependencies. The module's own CMakeLists.txt is responsible for its configuration +# behavior (packaging, data downloads, external module declarations, cache variables, etc.). +# +# Parameters: +# NAME: Module name — hyphens are normalized to underscores for the cache variable, +# but the original name is used for add_subdirectory to match the directory. +# +# Keyword Arguments: +# OPERATORS: Holoscan operators this module depends on +# APPLICATIONS: Applications this module depends on +# EXTENSIONS: GXF extensions this module depends on +# +# Creates: +# MODULE_${NAME}: CMake option to enable/disable this module (default: ${BUILD_ALL}) +# +# Example: +# add_holohub_module(holoscan-gstreamer OPERATORS gstreamer) +# +function(add_holohub_module NAME) + string(REPLACE "-" "_" _mod_slug "${NAME}") + set(modname "MODULE_${_mod_slug}") + option(${modname} "Enable the ${NAME} Holoscan Module" ${BUILD_ALL}) + + message(DEBUG "${modname} = ${${modname}}") + + if(NOT ${modname}) + return() + endif() + add_subdirectory(${NAME}) + + cmake_parse_arguments(DEPS "" "" "EXTENSIONS;OPERATORS;APPLICATIONS" ${ARGN}) + foreach(dep IN LISTS DEPS_EXTENSIONS) + set("EXT_${dep}" ON CACHE BOOL "Build the ${dep} GXF extension" FORCE) + endforeach() + foreach(dep IN LISTS DEPS_OPERATORS) + set("OP_${dep}" ON CACHE BOOL "Build the ${dep} holoscan operator" FORCE) + endforeach() + foreach(dep IN LISTS DEPS_APPLICATIONS) + set("APP_${dep}" ON CACHE BOOL "Build the ${dep} application" FORCE) + endforeach() +endfunction() + +# ===================================================== +# Helper function to build application and dependencies +# ===================================================== +# Builds an application and automatically enables its required dependencies. +# Supports optional operator dependencies based on HOLOHUB_BUILD_OPERATORS. +# +# Parameters: +# NAME: The name of the application to build +# +# Keyword Arguments: +# DEPENDS: Dependency specification with sub-arguments: +# EXTENSIONS: List of GXF extensions that this application depends on +# OPERATORS: List of Holoscan operators that this application depends on +# Use "OPTIONAL" keyword to make subsequent operators optional +# +# Creates: +# APP_${NAME}: CMake option to enable/disable this application +# +# Example: +# add_holohub_application(my_app +# DEPENDS +# EXTENSIONS gxf_core gxf_serialization +# OPERATORS required_op OPTIONAL optional_op1 optional_op2 +# ) +function(add_holohub_application NAME) + + cmake_parse_arguments(APP "" "" "DEPENDS" ${ARGN}) + + set(appname "APP_${NAME}") + option(${appname} "Build the ${NAME} application" ${BUILD_ALL}) + + if(${appname}) + add_subdirectory(${NAME}) + + # If we have dependencies make sure they are built + if(APP_DEPENDS) + cmake_parse_arguments(DEPS "" "" "EXTENSIONS;OPERATORS" ${APP_DEPENDS}) + + foreach(dependency IN LISTS DEPS_EXTENSIONS) + set("EXT_${dependency}" ON CACHE BOOL "Build the ${dependency}" FORCE) + endforeach() + + unset(op_optional) + foreach(dependency IN LISTS DEPS_OPERATORS) + + # Handle optional operator dependencies + if(dependency STREQUAL "OPTIONAL") + set(op_optional 1) + continue() + endif() + + if(op_optional) + string(REPLACE "\"" "" holohub_build_operators "${HOLOHUB_BUILD_OPERATORS}") + if(${dependency} IN_LIST holohub_build_operators) + set("OP_${dependency}" ON CACHE BOOL "Build the ${dependency}" FORCE) + endif() + else() + set("OP_${dependency}" ON CACHE BOOL "Build the ${dependency}" FORCE) + endif() + endforeach() + endif() + + endif() + +endfunction() + +# ===================================================== +# Helper function to build operators +# ===================================================== +# Builds a Holoscan operator and automatically enables its extension and operator dependencies. +# +# Parameters: +# NAME: The name of the operator to build +# +# Keyword Arguments: +# DEPENDS: Dependency specification with sub-arguments: +# EXTENSIONS: List of GXF extensions that this operator depends on +# OPERATORS: List of Holoscan operators that this operator depends on +# +# Creates: +# OP_${NAME}: CMake option to enable/disable this operator +# +# Example: +# add_holohub_operator(my_op +# DEPENDS EXTENSIONS gxf_core gxf_serialization +# ) +function(add_holohub_operator NAME) + + cmake_parse_arguments(OP "" "" "DEPENDS" ${ARGN}) + + set(opname "OP_${NAME}") + option(${opname} "Build the ${NAME} operator" ${BUILD_ALL}) + + if(${opname}) + add_subdirectory(${NAME}) + + # If we have dependencies make sure they are built + if(OP_DEPENDS) + cmake_parse_arguments(DEPS "" "" "EXTENSIONS;OPERATORS" ${OP_DEPENDS}) + + foreach(dependency IN LISTS DEPS_EXTENSIONS) + set("EXT_${dependency}" ON CACHE BOOL "Build the ${dependency}" FORCE) + endforeach() + + foreach(dependency IN LISTS DEPS_OPERATORS) + set("OP_${dependency}" ON CACHE BOOL "Build the ${dependency} operator" FORCE) + endforeach() + + endif() + + endif() +endfunction() + +# ===================================================== +# Helper function to build extensions +# ===================================================== +# Builds a GXF extension. This is the simplest helper function with no dependencies. +# +# Parameters: +# NAME: The name of the extension to build +# +# Creates: +# EXT_${NAME}: CMake option to enable/disable this extension +# +# Example: +# add_holohub_extension(my_extension) +function(add_holohub_extension NAME) + set(extname "EXT_${NAME}") + option(${extname} "Build the ${NAME} extension" ${BUILD_ALL}) + + if(${extname}) + add_subdirectory(${NAME}) + endif() +endfunction() + +# ===================================================== +# Helper function to declare external Holoscan Modules +# ===================================================== +# Declares an external Holoscan Module dependency and registers its operators with +# HoloHub's lazy-fetch post-step. Equivalent to calling FetchContent_Declare followed +# by setting HOLOHUB_EXT_OP__PROVIDER for each advertised operator. +# +# The HoloHub CLI generates calls to this function automatically from a consumer's +# metadata.json into ${CMAKE_BINARY_DIR}/external_operators_manifest.cmake. Use this +# function directly when bypassing the CLI. +# +# Parameters: +# PROVIDER: CMake-safe identifier for the module (used as the FetchContent name and +# in ${PROVIDER}_SOURCE_DIR etc.). Prefer underscores over hyphens. +# +# Keyword Arguments: +# PROVIDES_OPERATORS: Operators this module supplies. The root CMakeLists.txt +# post-step calls FetchContent_MakeAvailable for this module +# only when at least one of these operators is OP_=ON. +# : All remaining arguments are forwarded verbatim to +# FetchContent_Declare(PROVIDER ...). Any option accepted by +# FetchContent_Declare (GIT_REPOSITORY, GIT_TAG, SOURCE_DIR, +# GIT_SHALLOW, etc.) is valid here. +# +# HOLOHUB_EXT_OP__PROVIDER variables are set as NORMAL (non-cache) variables. +# They must be set fresh each configure run; a cached entry whose FetchContent_Declare +# was not registered in the current run would cause FetchContent_MakeAvailable to fail +# with "No content details recorded for ". +# +# Example: +# holohub_declare_external_module(holoscan_deltacast +# GIT_REPOSITORY https://github.com/nvidia/holoscan-deltacast +# GIT_TAG 2dac97236a8b3689ab08b5bc0b5a319e0558c807 +# PROVIDES_OPERATORS deltacast_videomaster +# ) +# +# For local development, set FETCHCONTENT_SOURCE_DIR_ before calling +# this function to redirect FetchContent at a local working copy: +# set(FETCHCONTENT_SOURCE_DIR_HOLOSCAN_DELTACAST "/path/to/local" CACHE PATH "" FORCE) +# holohub_declare_external_module(holoscan_deltacast +# SOURCE_DIR "/path/to/local" +# PROVIDES_OPERATORS deltacast_videomaster +# ) +function(holohub_declare_external_module PROVIDER) + cmake_parse_arguments(ARG "" "" "PROVIDES_OPERATORS" ${ARGN}) + include(FetchContent) + FetchContent_Declare(${PROVIDER} ${ARG_UNPARSED_ARGUMENTS}) + foreach(_op IN LISTS ARG_PROVIDES_OPERATORS) + set("HOLOHUB_EXT_OP_${_op}_PROVIDER" "${PROVIDER}" PARENT_SCOPE) + endforeach() +endfunction() diff --git a/src/holoscan_cli/cmake/holohub_configure_deb.cmake b/src/holoscan_cli/cmake/holohub_configure_deb.cmake new file mode 100644 index 00000000..eeb65f43 --- /dev/null +++ b/src/holoscan_cli/cmake/holohub_configure_deb.cmake @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-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. + +function(holohub_configure_deb) + # parse args + set(options) + set(requiredArgs NAME DESCRIPTION VERSION VENDOR CONTACT DEPENDS) + list(APPEND oneValueArgs ${requiredArgs} SECTION PRIORITY RECOMMENDS SUGGESTS EXPORT_NAME) + set(multiValueArgs COMPONENTS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGV}) + + # validate required args + set(missingArgs "") + foreach(arg ${requiredArgs}) + if(NOT ARG_${arg}) + list(APPEND missingArgs ${arg}) + endif() + endforeach() + if(missingArgs) + message(FATAL_ERROR "Missing required arguments: ${missingArgs}") + endif() + + if(NOT ARG_SECTION) + set(ARG_SECTION "devel") + endif() + if(NOT ARG_PRIORITY) + set(ARG_PRIORITY "optional") + endif() + + # set configurable properties + set(CPACK_PACKAGE_NAME "${ARG_NAME}") + set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${ARG_DESCRIPTION}") + set(CPACK_PACKAGE_VERSION "${ARG_VERSION}") + set(CPACK_PACKAGE_VENDOR "${ARG_VENDOR}") + set(CPACK_PACKAGE_CONTACT "${ARG_CONTACT}") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "${ARG_DEPENDS}") + set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "${ARG_RECOMMENDS}") + set(CPACK_DEBIAN_PACKAGE_SUGGESTS "${ARG_SUGGESTS}") + set(CPACK_DEBIAN_PACKAGE_SECTION "${ARG_SECTION}") + set(CPACK_DEBIAN_PACKAGE_PRIORITY "${ARG_PRIORITY}") + + if(ARG_EXPORT_NAME) + set(config_install_dir "lib/cmake/${ARG_NAME}") + set(export_component ${ARG_NAME}-cmake) + # Install export files + install( + EXPORT ${ARG_EXPORT_NAME} + DESTINATION ${config_install_dir} + NAMESPACE holoscan:: + COMPONENT ${export_component} + ) + # Generate the config files that include the exports + 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 + ) + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/${ARG_NAME}ConfigVersion.cmake" + VERSION "${ARG_VERSION}" + COMPATIBILITY AnyNewerVersion + ) + # Install the config files + 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) + # only packages installed components, in a single package + set(CPACK_DEB_COMPONENT_INSTALL 1) + set(CPACK_ARCHIVE_COMPONENT_INSTALL 1) + set(CPACK_COMPONENTS_ALL "${ARG_COMPONENTS}") + if(export_component) + list(APPEND CPACK_COMPONENTS_ALL "${export_component}") + endif() + set(CPACK_COMPONENTS_GROUPING ALL_COMPONENTS_IN_ONE) + else() + # package all installed targets + set(CPACK_DEB_COMPONENT_INSTALL 0) + set(CPACK_ARCHIVE_COMPONENT_INSTALL 0) + endif() + + # standard configurations + set(CPACK_PACKAGING_INSTALL_PREFIX "/opt/nvidia/holoscan") + set(CPACK_STRIP_FILES TRUE) + set(CPACK_GENERATOR DEB) # default, can be overridden with cpack -G + set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT) + set(CPACK_ARCHIVE_FILE_NAME "${CPACK_PACKAGE_NAME}_${CPACK_PACKAGE_VERSION}_${CMAKE_SYSTEM_PROCESSOR}") + # Note: CPACK_ARCHIVE_FILE_NAME above does not work if there is no components: + # https://gitlab.kitware.com/cmake/cmake/-/issues/20419 + # Fixed in CMake 4.0: https://gitlab.kitware.com/cmake/cmake/-/blob/master/Help/release/4.0.rst + + # generate package specific CPack configs to allow for multi packages + set(CPACK_OUTPUT_CONFIG_FILE "${CMAKE_BINARY_DIR}/pkg/CPackConfig-${ARG_NAME}.cmake") + set(CPACK_SOURCE_OUTPUT_CONFIG_FILE "${CMAKE_BINARY_DIR}/pkg/CPackSourceConfig-${ARG_NAME}.cmake") + + # control scripts + set(control_scripts "") + foreach(script IN ITEMS preinst postinst) + set(script_path "${CMAKE_CURRENT_SOURCE_DIR}/${script}") + if(EXISTS "${script_path}") + list(APPEND control_scripts "${script_path}") + endif() + endforeach() + set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA ${control_scripts}) + + include(CPack) +endfunction() diff --git a/src/holoscan_cli/cmake/pybind11/__init__.py.in b/src/holoscan_cli/cmake/pybind11/__init__.py.in new file mode 100644 index 00000000..eb859b3d --- /dev/null +++ b/src/holoscan_cli/cmake/pybind11/__init__.py.in @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022-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. + + +# Import the holoscan modules we'll depend on +import holoscan.core +import holoscan.gxf + +# Load the Python binding and register any custom emitter/receiver types. +from . import _@MODULE_NAME@ as _bindings + +@MODULE_CLASS_NAME@ = _bindings.@MODULE_CLASS_NAME@ +_register_types = getattr(_bindings, "register_types", None) +if _register_types is not None: + from holoscan.core import io_type_registry + + _register_types(io_type_registry) + +del _bindings, _register_types diff --git a/src/holoscan_cli/cmake/pybind11_add_holohub_module.cmake b/src/holoscan_cli/cmake/pybind11_add_holohub_module.cmake new file mode 100644 index 00000000..f2821c57 --- /dev/null +++ b/src/holoscan_cli/cmake/pybind11_add_holohub_module.cmake @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-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. + +# Find pybind11 +find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) + +# We fetch pybind11 since we need the same version as the Holoscan SDK +# and it's not necessarily available on all the platforms +include(FetchContent) +FetchContent_Declare(pybind11 + GIT_REPOSITORY https://github.com/pybind/pybind11 + GIT_TAG v2.13.6 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(pybind11) + +# Helper function to generate pybind11 operator modules +function(pybind11_add_holohub_module) + cmake_parse_arguments(MODULE # PREFIX + "" # OPTIONS + "CPP_CMAKE_TARGET;CLASS_NAME;PYTHON_MODULE_NAME;PYTHON_NAMESPACE" # ONEVAL + "SOURCES" # MULTIVAL + ${ARGN} + ) + + # PYTHON_MODULE_NAME overrides CPP_CMAKE_TARGET as the Python subpackage + # name (directory under the package root, OUTPUT_NAME prefix, and + # @MODULE_NAME@ in __init__.py). Use it when the desired Python import + # name differs from the C++ library target name. + if(MODULE_PYTHON_MODULE_NAME) + set(MODULE_NAME ${MODULE_PYTHON_MODULE_NAME}) + else() + set(MODULE_NAME ${MODULE_CPP_CMAKE_TARGET}) + endif() + + # PYTHON_NAMESPACE selects the top-level Python namespace (e.g. "holoscan" + # instead of the default "holohub"). When specified the module files are + # placed under a namespace-specific directory rather than + # HOLOHUB_PYTHON_MODULE_OUT_DIR, and a dedicated install() rule is added + # so the namespace root lands on the right Python path in both wheel and + # in-tree builds. The top-level CMakeLists.txt install() only covers + # HOLOHUB_PYTHON_MODULE_OUT_DIR (the holohub/ tree); modules that declare + # their own namespace are responsible for their own install here. + if(MODULE_PYTHON_NAMESPACE) + if(NOT CMAKE_INSTALL_LIBDIR) + set(CMAKE_INSTALL_LIBDIR lib) + endif() + if(DEFINED SKBUILD) + # Wheel build: flat layout — namespace dir sits directly under the + # wheel root, which pip installs straight into site-packages. + set(_module_base_dir ${CMAKE_BINARY_DIR}/${MODULE_PYTHON_NAMESPACE}) + set(_ns_install_dest ".") + else() + # In-tree HoloHub build: mirror the standard python/lib/ tree so + # the module is importable when that tree is on PYTHONPATH. + set(_module_base_dir + ${CMAKE_BINARY_DIR}/python/${CMAKE_INSTALL_LIBDIR}/${MODULE_PYTHON_NAMESPACE}) + set(_ns_install_dest "python/lib") + endif() + install( + DIRECTORY "${_module_base_dir}" + DESTINATION "${_ns_install_dest}" + 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 + ) + else() + set(_module_base_dir ${HOLOHUB_PYTHON_MODULE_OUT_DIR}) + endif() + set(CMAKE_SUBMODULE_OUT_DIR ${_module_base_dir}/${MODULE_NAME}) + + set(target_name ${MODULE_NAME}_python) + pybind11_add_module(${target_name} MODULE ${MODULE_SOURCES}) + + target_include_directories(${target_name} + PUBLIC ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pydoc + ) + + target_link_libraries(${target_name} + PRIVATE + holoscan::core + holoscan::pybind11 + ${MODULE_CPP_CMAKE_TARGET} + ) + + # Sets the rpath of the module. PROJECT_SOURCE_DIR (not CMAKE_SOURCE_DIR) + # so the path is correct when this helper is invoked from a Holoscan + # Module that's been add_subdirectory()'d into another project (HoloHub + # consuming an external module, etc.) — CMAKE_SOURCE_DIR would point at + # the parent project's root, which is wrong for our rpath calculation. + if(NOT HOLOSCAN_INSTALL_LIB_DIR) + set(HOLOSCAN_INSTALL_LIB_DIR lib) + endif() + if(IS_ABSOLUTE "${HOLOSCAN_INSTALL_LIB_DIR}") + set(_install_lib_dir "${HOLOSCAN_INSTALL_LIB_DIR}") + set(_build_lib_dir "${HOLOSCAN_INSTALL_LIB_DIR}") + else() + set(_install_lib_dir "${PROJECT_SOURCE_DIR}/${HOLOSCAN_INSTALL_LIB_DIR}") + set(_build_lib_dir "${CMAKE_BINARY_DIR}/${HOLOSCAN_INSTALL_LIB_DIR}") + endif() + file(RELATIVE_PATH install_lib_relative_path + "${CMAKE_CURRENT_LIST_DIR}" + "${_install_lib_dir}" + ) + file(RELATIVE_PATH build_lib_relative_path + "${CMAKE_SUBMODULE_OUT_DIR}" + "${_build_lib_dir}" + ) + set(_rpath "") + list(APPEND _rpath + "\$ORIGIN/${build_lib_relative_path}" # in the build tree + "\$ORIGIN/${install_lib_relative_path}" # in our install tree (same layout as src) + "\$ORIGIN/../../lib" # in our python wheel (module at //_mod.so → lib/ is two levels up) + "\$ORIGIN/../lib" # legacy fallback for one-level-deep layouts + ) + list(JOIN _rpath ":" _rpath) + set_property(TARGET ${target_name} + APPEND PROPERTY BUILD_RPATH ${_rpath} + ) + unset(_rpath) + + # make submodule folder + file(MAKE_DIRECTORY ${_module_base_dir}/${MODULE_NAME}) + + # custom target to ensure the module's __init__.py file is copied + configure_file( + ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pybind11/__init__.py.in + ${_module_base_dir}/${MODULE_NAME}/__init__.py + ) + + # Note: OUTPUT_NAME filename (_${MODULE_NAME}) must match the module name in the PYBIND11_MODULE macro + set_target_properties(${target_name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SUBMODULE_OUT_DIR} + OUTPUT_NAME _${MODULE_NAME} + ) + +endfunction() diff --git a/src/holoscan_cli/cmake/pydoc/macros.hpp b/src/holoscan_cli/cmake/pydoc/macros.hpp new file mode 100644 index 00000000..e945d384 --- /dev/null +++ b/src/holoscan_cli/cmake/pydoc/macros.hpp @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-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. + */ + +#ifndef PYHOLOSCAN_MACRO_HPP +#define PYHOLOSCAN_MACRO_HPP + +#include + +constexpr const char* remove_leading_spaces(const char* str) { + return *str == '\0' ? str + : ((*str == ' ' || *str == '\n') ? remove_leading_spaces(str + 1) : str); +} + +#define PYDOC(method, doc) static constexpr const char* doc_##method = remove_leading_spaces(doc); + +#endif // PYHOLOSCAN_MACRO_HPP diff --git a/src/holoscan_cli/commands/create.py b/src/holoscan_cli/commands/create.py index 62e170fe..a791c081 100644 --- a/src/holoscan_cli/commands/create.py +++ b/src/holoscan_cli/commands/create.py @@ -17,29 +17,53 @@ import argparse import datetime +import importlib +import importlib.resources import json +import os +import shutil +import subprocess +import tempfile +from contextlib import AbstractContextManager, nullcontext from pathlib import Path -from typing import Optional +from typing import Optional, Union +from holoscan_cli import __version__ from holoscan_cli.commands.registry import help_for from holoscan_cli.container import HoloscanContainer from holoscan_cli.metadata.utils import get_schema_path +from holoscan_cli.utils.filesystem import ( + DirectoryMaterializationError, + inspect_directory, + materialize_tree, +) from holoscan_cli.utils.io import Color, fatal +from holoscan_cli.utils.text import parse_key_value_pairs, to_snake_case + +LEGACY_MODULE_TEMPLATE = Path("modules/template") +CREATE_TEMPLATE_ENV = "HOLOSCAN_CLI_CREATE_TEMPLATE" +LOCAL_SOURCE_VERSION = "0.0.0+local" +RESERVED_CONTEXT_KEYS = {"_holoscan_cli_version"} +PRESERVED_DESTINATION_ENTRIES = {".git"} def register_create_parser(cli, subparsers) -> argparse.ArgumentParser: """Register the ``create`` subcommand. - The ``--template`` and ``--directory`` defaults are derived from - ``cli.HOLOHUB_ROOT`` so wrapper scripts that override the project root - (via ``HOLOSCAN_CLI_ROOT`` env var) automatically pick up the right paths. + Direct ``holoscan create`` uses the packaged Module template. Source-project + wrappers can select their own default with ``HOLOSCAN_CLI_CREATE_TEMPLATE``; + an explicit ``--template`` always wins. """ parser = subparsers.add_parser("create", help=help_for("create")) parser.add_argument("project", help="Name of the project to create") parser.add_argument( "--template", - default=str(cli.HOLOHUB_ROOT / "applications" / "template"), - help="Path to the template directory to use", + default=None, + help=( + "Path to the template directory to use " + "(default: HOLOSCAN_CLI_CREATE_TEMPLATE when set, otherwise the packaged " + "Module template)" + ), ) parser.add_argument( "--language", @@ -56,16 +80,17 @@ def register_create_parser(cli, subparsers) -> argparse.ArgumentParser: default=None, help=( "Output directory for the generated project " - "(default: applications/ for application templates; " - "required for module templates — prompted interactively if omitted)" + "(default: current directory for Module templates; " + "applications/ for application templates)" ), ) parser.add_argument( "--context", action="append", - help='Additional context variables for cookiecutter in format key=value. \ - Example: --context description=\'My project desc\' \ - --context tags=[\\"tag1\\", \\"tag2\\"]', + help=( + "Additional cookiecutter context as key=value. Values are strings; repeat for " + "multiple keys, for example --context description='My project description'." + ), ) parser.add_argument( "-i", @@ -84,6 +109,68 @@ def register_create_parser(cli, subparsers) -> argparse.ArgumentParser: # ---- private helpers --------------------------------------------------------- +def _initialize_module_git(project_dir: Path) -> bool: + """Initialize a fresh standalone Module without touching existing Git state.""" + try: + worktree = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=project_dir, + check=False, + capture_output=True, + ) + if worktree.returncode == 0: + return False + subprocess.run(["git", "init", "."], cwd=project_dir, check=True, capture_output=True) + subprocess.run( + ["git", "symbolic-ref", "HEAD", "refs/heads/main"], + cwd=project_dir, + check=True, + capture_output=True, + ) + subprocess.run(["git", "add", "."], cwd=project_dir, check=True, capture_output=True) + except (OSError, subprocess.CalledProcessError): + return False + return True + + +def _run_cookiecutter( + cli, + template_dir: Path, + *, + interactive: bool, + context: dict, + output_dir: Path, +) -> str: + """Generate a project while keeping creation dependencies optional.""" + try: + cookiecutter_config = importlib.import_module("cookiecutter.config") + cookiecutter_main = importlib.import_module("cookiecutter.main") + except ImportError: + template_setup_cmd = f"{cli.script_name} setup --scripts template" + fatal( + "cookiecutter is required to create new projects. " + "Install it with `pip install 'holoscan-cli[create]'`, " + f"or run `{template_setup_cmd}` for the HoloHub bash setup flow." + ) + + try: + runtime_config = dict(cookiecutter_config.get_user_config()) + # Cookiecutter always records a replay file. Keep that implementation + # detail inside the staging directory so creation works with a + # read-only home and does not retain scaffold context after success. + runtime_config["replay_dir"] = str(output_dir / ".cookiecutter-replay") + return cookiecutter_main.cookiecutter( + str(template_dir), + no_input=not interactive, + extra_context=context, + output_dir=str(output_dir), + default_config=runtime_config, + ) + except Exception as exc: + fatal(f"Failed to create project from template {template_dir} in {output_dir}: {exc}") + raise AssertionError("fatal() returned unexpectedly") # pragma: no cover + + def _add_to_cmakelists(cli, project_name: str) -> None: """Add a new application to applications/CMakeLists.txt if it doesn't exist""" cmakelists_path = cli.HOLOHUB_ROOT / "applications" / "CMakeLists.txt" @@ -102,7 +189,9 @@ def _add_to_cmakelists(cli, project_name: str) -> None: print(Color.red("Please add the application manually to applications/CMakeLists.txt")) -def validate_generated_metadata(cli, metadata_path: Path, schema_root: Optional[Path]) -> None: +def validate_generated_metadata( + cli, metadata_path: Path, schema_root: Optional[Union[str, Path]] +) -> None: """Validate metadata.json for the newly created project.""" try: from holoscan_cli.metadata import metadata_validator @@ -133,140 +222,269 @@ def validate_generated_metadata(cli, metadata_path: Path, schema_root: Optional[ print(Color.green(f"Validated metadata.json against {schema_file}")) -# ---- handler ----------------------------------------------------------------- +def copy_cmake_support(project_dir: Path) -> None: + """Vendor the CLI's packaged CMake support into a generated project.""" + resource = importlib.resources.files("holoscan_cli").joinpath("cmake") + with importlib.resources.as_file(resource) as source: + shutil.copytree(source, project_dir / "cmake", dirs_exist_ok=True) -def handle_create(cli, args: argparse.Namespace) -> None: - """Handle create command""" - # Ensure template directory exists - template_dir = cli.HOLOHUB_ROOT / args.template - if not template_dir.exists() and not args.dryrun: - fatal(f"Template directory {template_dir} does not exist") - - # Detect template type: module vs application. - # Check path parts so a path like /home/user/my_modules/template doesn't - # falsely match — only paths whose first component is literally "modules" qualify. - is_module_template = "modules" in Path(args.template).parts - - # Resolve output directory. - # Application templates default to applications/. Module templates require the - # user to specify a path — there is no sensible default (the module lives outside - # the source-project tree), so we prompt interactively when --directory is not - # supplied. - if args.directory is None: - if is_module_template: - raw = input("Output directory for the new module: ").strip() - if not raw: - fatal("Output directory is required for module templates.") - args.directory = Path(raw).expanduser().resolve() - else: - args.directory = cli.HOLOHUB_ROOT / "applications" - - if not args.directory.exists() and not args.dryrun: - fatal(f"Project output directory {args.directory} does not exist") - - # Define minimal context with required fields - project_slug = args.project.lower().replace(" ", "_") - context = { - "project_name": args.project, - "project_slug": project_slug, - "language": args.language.lower() if args.language else None, # Only set if provided - "year": datetime.datetime.now().year, - } - if HoloscanContainer.BASE_SDK_VERSION: - context["holoscan_version"] = HoloscanContainer.BASE_SDK_VERSION - - # For module templates the generated folder is the kebab module_repo_name - # (holoscan-) rather than the snake_case slug. - output_folder = ( - f"holoscan-{project_slug.replace('_', '-')}" if is_module_template else project_slug - ) +def _packaged_module_template() -> AbstractContextManager[Path]: + """Extract the wheel's Module template to a real directory for the duration.""" + resource = importlib.resources.files("holoscan_cli.templates").joinpath("module") + return importlib.resources.as_file(resource) - # Add any additional context variables from command line - if args.context: - for ctx_var in args.context: - try: - key, value = ctx_var.split("=", 1) - context[key] = value - except ValueError: - fatal(f"Invalid context variable format: {ctx_var}. Expected key=value") - - # Print summary if dryrun - if args.dryrun: - print(Color.green("Would create project folder with these parameters (dryrun):")) - print(f"Directory: {args.directory / output_folder}") - for key, value in context.items(): - print(f" {key}: {value}") - if args.directory == cli.HOLOHUB_ROOT / "applications": - print(Color.green("Would modify `applications/CMakeLists.txt`: ")) - print(f" add_holohub_application({project_slug})") - return - try: - import cookiecutter.main - except ImportError: - template_setup_cmd = f"{cli.script_name} setup --scripts template" +def _select_template(cli, template: Optional[str]) -> tuple[AbstractContextManager[Path], bool]: + """Resolve the template directory and whether it is the packaged Module one. + + An explicit ``--template`` wins, then ``HOLOSCAN_CLI_CREATE_TEMPLATE``, then + the packaged Module template. Relative paths resolve against the active + source-project root so wrappers can set a project-relative default. + """ + if template is not None and not str(template).strip(): + fatal("--template requires a non-empty directory path.") + + selected = template or os.environ.get(CREATE_TEMPLATE_ENV) or None + if selected is None: + return _packaged_module_template(), True + + resolved = Path(selected).expanduser() + if not resolved.is_absolute(): + resolved = Path(cli.HOLOHUB_ROOT) / resolved + resolved = resolved.resolve() + # The in-tree HoloHub Module template now ships in the wheel; keep the old + # path working for callers that still pass it. + if Path(selected) == LEGACY_MODULE_TEMPLATE and not resolved.exists(): + return _packaged_module_template(), True + if not resolved.is_dir(): fatal( - "cookiecutter is required to create new projects. " - f"Install it with `pip install 'holoscan-cli[create]'`, " - f"or run `{template_setup_cmd}` for the HoloHub bash setup flow." + f"Template directory {resolved} does not exist or is not a directory. " + "Choose an existing --template path and retry." ) + return nullcontext(resolved), False - intended_dir = args.directory / output_folder - if intended_dir.exists(): - fatal(f"Project directory {intended_dir} already exists") - try: - # Let cookiecutter handle all file generation - generated_path = cookiecutter.main.cookiecutter( - str(template_dir), - no_input=not args.interactive, - extra_context=context, - output_dir=str(args.directory), - ) - except Exception as e: - fatal(f"Failed to create project: {str(e)}") - - # Add to CMakeLists.txt if in applications directory - project_dir = Path(generated_path) - actual_slug = project_dir.name - - if args.directory == cli.HOLOHUB_ROOT / "applications": - _add_to_cmakelists(cli, actual_slug) - - # Get the actual project directory after cookiecutter runs - metadata_path = project_dir / "metadata.json" - - if is_module_template: - main_file = None - schema_root = None - else: - src_dir = project_dir / "src" - main_file = next(src_dir.glob(f"{actual_slug}.*"), None) - schema_path = get_schema_path("applications") - schema_root = "applications" if schema_path.exists() else None - validate_generated_metadata(cli, metadata_path, schema_root) - - msg_next = "" - if is_module_template: - msg_next = ( - f"Possible next steps:\n" - f"- Implement your operator in {project_dir}/operators/\n" - f"- Update metadata.json: {metadata_path}\n" - f"- Update project README\n" - f"- Build and test with the Holoscan CLI\n" - ) - elif "applications" in args.template: - msg_next = ( - f"Possible next steps:\n" - f"- Add operators to {main_file}\n" - f"- Update project metadata in {metadata_path}\n" - f"- Review source code license files and headers (e.g. {project_dir / 'LICENSE'})\n" - f"- Build and run the application:\n" - f" {cli.script_name} run {actual_slug}" - ) +# ---- handler ----------------------------------------------------------------- - print( - Color.green(f"Successfully created new project: {args.project}"), - f"\nDirectory: {project_dir}\n\n{msg_next}", - ) + +def handle_create(cli, args: argparse.Namespace) -> None: + """Scaffold a project from the packaged or caller-selected template.""" + template_manager, use_packaged_template = _select_template(cli, args.template) + + with template_manager as template_dir: + template_dir = Path(template_dir) + context_path = template_dir / "cookiecutter.json" + try: + template_defaults = json.loads(context_path.read_text(encoding="utf-8")) + except FileNotFoundError: + fatal(f"Template directory {template_dir} is missing cookiecutter.json") + except json.JSONDecodeError as exc: + fatal(f"Template context {context_path} is not valid JSON: {exc}") + except OSError as exc: + fatal(f"Could not read template context {context_path}: {exc}") + if not isinstance(template_defaults, dict): + fatal(f"Template context {context_path} must contain a JSON object") + + is_module = {"module_slug", "module_repo_name"}.issubset(template_defaults) + if is_module and __version__ == LOCAL_SOURCE_VERSION: + fatal( + "holoscan create cannot generate an installable Module contract from an " + "uninstalled source tree (version 0.0.0+local). Build and install a wheel, or " + "install the checkout into an isolated environment with distribution metadata, " + "then retry." + ) + project_slug = to_snake_case(args.project) + context = { + "project_name": args.project, + "project_slug": project_slug, + "language": args.language.lower() if args.language else None, + "year": datetime.datetime.now().year, + "_holoscan_cli_version": __version__, + } + if HoloscanContainer.BASE_SDK_VERSION: + context["holoscan_version"] = HoloscanContainer.BASE_SDK_VERSION + try: + extra_context = parse_key_value_pairs(args.context) + except ValueError as exc: + fatal(f"Invalid context variable format: {exc}") + for key in extra_context: + if key in RESERVED_CONTEXT_KEYS: + fatal( + f"Cookiecutter context {key!r} is managed by holoscan create and cannot " + "be overridden." + ) + context.update(extra_context) + + applications_dir = (Path(cli.HOLOHUB_ROOT) / "applications").resolve() + if args.directory is None: + output_dir = Path.cwd().resolve() if is_module else applications_dir + else: + output_dir = Path(args.directory).expanduser().resolve() + # Applications registered in the source project's CMakeLists must be + # added there; Modules and out-of-tree destinations never are. + registers_application = not is_module and output_dir == applications_dir + + if not is_module: + output_folder = str(context.get("project_slug") or project_slug) + elif context.get("module_repo_name"): + output_folder = str(context["module_repo_name"]) + else: + module_slug = str( + context.get("module_slug") + or to_snake_case(str(context.get("project_name") or args.project)) + ) + output_folder = f"holoscan-{module_slug.replace('_', '-')}" + + folder = Path(output_folder) + if ( + not output_folder + or folder.is_absolute() + or len(folder.parts) != 1 + or folder.name in {".", ".."} + ): + fatal( + f"Invalid generated project directory name {output_folder!r}. " + "Use a project name or context value that produces one directory name." + ) + intended_dir = output_dir / folder + try: + target_state = inspect_directory( + intended_dir, allowed_entries=PRESERVED_DESTINATION_ENTRIES + ) + except DirectoryMaterializationError as exc: + fatal( + f"{exc} Choose a missing or empty destination, or one containing only a real .git " + "file or directory." + ) + + if args.dryrun: + print(Color.green("Would create project folder with these parameters (dryrun):")) + template_label = "packaged Module template" if use_packaged_template else template_dir + print(f"Template: {template_label}") + print(f"Directory: {intended_dir}") + if target_state.kind == "missing": + print("Destination: would create a new project directory") + else: + target_kind = ".git-only" if target_state.entries else target_state.kind + print(f"Destination: would populate an existing {target_kind} directory") + for key, value in context.items(): + print(f" {key}: {value}") + if registers_application: + print(Color.green("Would modify `applications/CMakeLists.txt`: ")) + print(f" add_holohub_application({project_slug})") + return + + try: + output_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + fatal( + f"Could not create project output directory {output_dir}: {exc}. " + "Choose a writable --directory or fix the blocking path and retry." + ) + if not output_dir.is_dir(): + fatal( + f"Project output path {output_dir} is not a directory. " + "Choose another --directory or remove the blocking file and retry." + ) + + main_file_relative: Optional[Path] = None + with tempfile.TemporaryDirectory( + prefix=f".{output_folder}.holoscan-create-", dir=output_dir + ) as staging_dir: + staging_root = Path(staging_dir).resolve() + generated_path = _run_cookiecutter( + cli, + template_dir, + interactive=args.interactive, + context=context, + output_dir=staging_root, + ) + + staged_project = Path(generated_path).resolve() + actual_slug = staged_project.name + if staged_project.parent != staging_root: + fatal( + f"Template generated a project outside its staging directory: " + f"{staged_project} (expected a direct child of {staging_root})" + ) + if actual_slug != output_folder: + # Interactive prompts may rename the project, so honor the + # template's answer and re-check the destination it implies. + intended_dir = output_dir / actual_slug + try: + target_state = inspect_directory( + intended_dir, allowed_entries=PRESERVED_DESTINATION_ENTRIES + ) + except DirectoryMaterializationError as exc: + fatal( + f"{exc} Choose a missing or empty destination, or one containing only a " + "real .git file or directory." + ) + + if is_module: + try: + copy_cmake_support(staged_project) + except OSError as exc: + fatal(f"Could not copy packaged CMake support into the Module: {exc}") + + staged_metadata = staged_project / "metadata.json" + if is_module: + schema_root: Optional[Union[str, Path]] = "modules" + else: + staged_source = staged_project / "src" + staged_main = next(staged_source.glob(f"{actual_slug}.*"), None) + if staged_main is not None: + main_file_relative = staged_main.relative_to(staged_project) + schema_path = get_schema_path("applications") + schema_root = "applications" if schema_path.exists() else None + validate_generated_metadata(cli, staged_metadata, schema_root) + + try: + materialize_tree( + staged_project, + intended_dir, + target_state, + allowed_entries=PRESERVED_DESTINATION_ENTRIES, + ) + except DirectoryMaterializationError as exc: + fatal(str(exc)) + + project_dir = intended_dir + metadata_path = project_dir / "metadata.json" + main_file = project_dir / main_file_relative if main_file_relative is not None else None + + if registers_application: + _add_to_cmakelists(cli, actual_slug) + + git_initialized = False + if is_module and not target_state.entries: + git_initialized = _initialize_module_git(project_dir) + + if is_module: + msg_next = ( + f"Possible next steps:\n" + f"- Implement your operator in {project_dir}/operators/\n" + f"- Update metadata.json: {metadata_path}\n" + f"- Update project README\n" + f"- Follow the Quick Start in {project_dir / 'README.md'}\n" + ) + else: + msg_next = ( + f"Possible next steps:\n" + f"- Add operators to {main_file}\n" + f"- Update project metadata in {metadata_path}\n" + f"- Review source code license files and headers " + f"(e.g. {project_dir / 'LICENSE'})\n" + f"- Build and run the application:\n" + f" {cli.script_name} run {actual_slug}" + ) + + print( + Color.green(f"Successfully created new project: {args.project}"), + f"\nDirectory: {project_dir}\n\n{msg_next}", + ) + if git_initialized: + print( + Color.green("Initialized a Git repository on branch main and staged the scaffold.") + ) diff --git a/src/holoscan_cli/commands/test_cmd.py b/src/holoscan_cli/commands/test_cmd.py index 45396cc0..1c65a846 100644 --- a/src/holoscan_cli/commands/test_cmd.py +++ b/src/holoscan_cli/commands/test_cmd.py @@ -61,7 +61,11 @@ def register_test_parser(cli, subparsers, *, container_build) -> argparse.Argume help="CTest options, " "example: --ctest-options='-DGPU_TYPE=rtx4090' --ctest-options='-DDEBUG_MODE=ON'", ) - parser.add_argument("--no-xvfb", action="store_true", help="Do not use xvfb") + parser.add_argument( + "--no-xvfb", + action="store_true", + help="Skip Xvfb detection and run tests directly", + ) parser.add_argument("--ctest-script", help="CTest script") parser.add_argument( "--local-sdk-root", @@ -158,8 +162,6 @@ def handle_test(cli, args: argparse.Namespace) -> None: if hasattr(args, "cuda") and args.cuda is not None: container.cuda_version = args.cuda - xvfb = "" if args.no_xvfb else "xvfb-run -a" - # TAG is used in CTest scripts by default if getattr(args, "build_name_suffix", None): tag = args.build_name_suffix @@ -173,7 +175,15 @@ def handle_test(cli, args: argparse.Namespace) -> None: ) tag = image_name.split(":")[-1] - ctest_cmd = f"{xvfb} ctest " + xvfb = "" + if not args.no_xvfb: + xvfb = ( + "if command -v xvfb-run >/dev/null 2>&1; then xvfb_cmd='xvfb-run -a'; " + "else echo 'WARNING: xvfb-run is unavailable; running tests without a virtual " + "display. Install Xvfb, or rebuild with --extra-scripts xvfb if these tests need " + "one.' >&2; xvfb_cmd=''; fi; ${xvfb_cmd} " + ) + ctest_cmd = f'{xvfb}ctest -DCTEST_SOURCE_DIRECTORY="$PWD" ' if args.project: project_metadata = container.project_metadata or {} project_name = project_metadata.get("project_name", args.project) diff --git a/src/holoscan_cli/metadata/utils.py b/src/holoscan_cli/metadata/utils.py index 07256e0d..57cca8fd 100644 --- a/src/holoscan_cli/metadata/utils.py +++ b/src/holoscan_cli/metadata/utils.py @@ -124,10 +124,17 @@ def _matches_segment(path: str, patterns: Sequence[str]) -> bool: ) for repo_path in repo_paths: - for root, _, files in os.walk(repo_path): - if "metadata.json" not in files: - continue - file_path = os.path.join(root, "metadata.json") + path = Path(repo_path) + if path.is_file(): + candidates = [str(path)] if path.name == "metadata.json" else [] + else: + candidates = [ + os.path.join(root, "metadata.json") + for root, _, files in os.walk(path) + if "metadata.json" in files + ] + + for file_path in candidates: if excludes and _matches_segment(file_path, excludes): continue diff --git a/src/holoscan_cli/project_context.py b/src/holoscan_cli/project_context.py new file mode 100644 index 00000000..c64e2334 --- /dev/null +++ b/src/holoscan_cli/project_context.py @@ -0,0 +1,190 @@ +# 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. + +"""Discover and activate a Holoscan-based source-project root before CLI imports.""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +MODULE_METADATA_FILENAME = "metadata.json" +METADATA_DIRS = ( + "applications", + "benchmarks", + "gxf_extensions", + "modules", + "operators", + "pkg", + "subgraphs", + "tutorials", +) +SEARCH_DIRS = tuple(name for name in METADATA_DIRS if name != "subgraphs") + +_PYTHON_SEGMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +class ProjectContextError(ValueError): + """The selected project root or its Module metadata is invalid.""" + + +@dataclass(frozen=True) +class ProjectContext: + """Values needed to activate one source project before importing the CLI.""" + + root: Path + repo_prefix: str | None = None + base_sdk_version: str | None = None + warnings: tuple[str, ...] = () + + +def _is_source_root(path: Path) -> bool: + if (path / "src" / "holoscan_cli").is_dir() and (path / "pyproject.toml").is_file(): + return True + return any( + (path / directory / MODULE_METADATA_FILENAME).is_file() + or any((path / directory).glob(f"*/{MODULE_METADATA_FILENAME}")) + for directory in METADATA_DIRS + ) + + +def _read_module(root: Path) -> dict | None: + path = root / MODULE_METADATA_FILENAME + if not path.is_file(): + return None + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ProjectContextError(f"Invalid Module metadata at {path}: {exc}") from exc + + module = document.get("module") if isinstance(document, dict) else None + if module is None: + return None + if not isinstance(module, dict): + raise ProjectContextError(f"Module metadata at {path} must contain an object.") + return module + + +def _module_defaults(module: dict, path: Path) -> tuple[str, str | None]: + name = module.get("name") + if not isinstance(name, str) or not name.strip(): + raise ProjectContextError(f"Module metadata at {path} has no valid module.name.") + + namespace = module.get("namespace") or {} + if not isinstance(namespace, dict): + raise ProjectContextError(f"Module metadata at {path} has an invalid module.namespace.") + python_namespace = namespace.get("python") + if python_namespace is not None: + if not isinstance(python_namespace, str) or not all( + _PYTHON_SEGMENT_RE.fullmatch(part) for part in python_namespace.split(".") + ): + raise ProjectContextError( + f"Module metadata at {path} has an invalid Python namespace: {python_namespace!r}." + ) + prefix = python_namespace.rsplit(".", 1)[-1] + else: + normalized = name.strip().lower().removeprefix("holoscan-") + prefix = re.sub(r"[^a-z0-9]+", "_", normalized).strip("_") + if not prefix: + raise ProjectContextError( + f"Module metadata at {path} cannot derive an identity from module.name={name!r}." + ) + + sdk = module.get("holoscan_sdk") or {} + if not isinstance(sdk, dict): + raise ProjectContextError(f"Module metadata at {path} has invalid module.holoscan_sdk.") + version = sdk.get("minimum_required_version") + if version is not None and (not isinstance(version, str) or not version.strip()): + raise ProjectContextError(f"Module metadata at {path} has an invalid SDK version.") + return prefix, version.strip() if version else None + + +def _context(root: Path, warnings: tuple[str, ...] = ()) -> ProjectContext: + """Build a context without making metadata validity a root-discovery requirement.""" + try: + module = _read_module(root) + if module is None: + return ProjectContext(root=root, warnings=warnings) + prefix, sdk_version = _module_defaults(module, root / MODULE_METADATA_FILENAME) + except ProjectContextError as exc: + # Metadata validation belongs to `holoscan lint`. Keep the selected + # root usable so lint and other recovery commands can report/fix it. + return ProjectContext(root=root, warnings=(*warnings, str(exc))) + + return ProjectContext(root, prefix, sdk_version, warnings) + + +def _resolve_root(value: str | os.PathLike[str], cwd: Path) -> Path: + path = Path(value).expanduser() + return (path if path.is_absolute() else cwd / path).resolve() + + +def discover_project_context( + *, + cwd: Path | None = None, + explicit_root: str | os.PathLike[str] | None = None, + environ: Mapping[str, str] | None = None, +) -> ProjectContext: + """Select a root using CLI, environment, then ancestor precedence.""" + cwd = (cwd or Path.cwd()).resolve() + env = os.environ if environ is None else environ + warnings: tuple[str, ...] = () + + if explicit_root is not None: + root = _resolve_root(explicit_root, cwd) + if not root.is_dir(): + raise ProjectContextError(f"--project-root {root} does not name an existing directory.") + return _context(root) + + if env_root := env.get("HOLOSCAN_CLI_ROOT"): + root = _resolve_root(env_root, cwd) + if root.is_dir(): + return _context(root) + warnings = (f"Ignoring invalid HOLOSCAN_CLI_ROOT={env_root!r}; discovering from cwd.",) + + module_fallback = None + for candidate in (cwd, *cwd.parents): + if _is_source_root(candidate): + return _context(candidate, warnings) + if module_fallback is None and (candidate / MODULE_METADATA_FILENAME).is_file(): + module_fallback = candidate + + if module_fallback is not None: + return _context(module_fallback, warnings) + return ProjectContext(cwd, warnings=warnings) + + +def activate_project_context(context: ProjectContext) -> None: + """Publish discovered defaults before CLI classes read their environment.""" + os.environ["HOLOSCAN_CLI_ROOT"] = str(context.root) + prefix = context.repo_prefix + if prefix is None: + return + + defaults = { + "HOLOSCAN_CLI_BUILD_PARENT_DIR": str(context.root / "build"), + "HOLOSCAN_CLI_DATA_DIR": str(context.root / "data"), + "HOLOSCAN_CLI_SEARCH_PATH": ",".join((MODULE_METADATA_FILENAME, *SEARCH_DIRS)), + "HOLOSCAN_CLI_REPO_PREFIX": prefix, + "HOLOSCAN_CLI_CONTAINER_PREFIX": prefix.replace("_", "-"), + "HOLOSCAN_CLI_BASE_SDK_VERSION": context.base_sdk_version, + } + for name, value in defaults.items(): + if value: + os.environ.setdefault(name, value) diff --git a/src/holoscan_cli/setup_scripts/Dockerfile.util b/src/holoscan_cli/setup_scripts/Dockerfile.util index e180a7b4..ffc33269 100644 --- a/src/holoscan_cli/setup_scripts/Dockerfile.util +++ b/src/holoscan_cli/setup_scripts/Dockerfile.util @@ -18,8 +18,9 @@ # Lightweight Dockerfile to run a single setup script adding dependencies # on top of a pre-existing project development image. +# The CLI always supplies BASE_IMAGE. Keep a valid fallback for static checks. ARG BASE_IMAGE -FROM ${BASE_IMAGE} AS base +FROM ${BASE_IMAGE:-ubuntu:24.04} AS base # Setup scripts use `sudo` for system packages. Ensure it exists on top of the # (arbitrary) base image; as root `sudo` is a harmless passthrough. Kept in its diff --git a/src/holoscan_cli/templates/module/cookiecutter.json b/src/holoscan_cli/templates/module/cookiecutter.json index 95a54506..f12d5402 100644 --- a/src/holoscan_cli/templates/module/cookiecutter.json +++ b/src/holoscan_cli/templates/module/cookiecutter.json @@ -9,6 +9,7 @@ "version": "0.1.0", "holoscan_version": "4.5.0", "description": "A Holoscan Module extending the Holoscan SDK.", + "_holoscan_cli_version": "0", "_license": "Apache-2.0", "contact_email": "your.email@example.com", "__prompts__": { @@ -20,7 +21,7 @@ "affiliation": "Author organization", "language": "Implementation language: 'cpp' (C++ with pybind11 Python bindings) or 'python' (pure Python)", "version": "Initial version", - "holoscan_version": "Minimum Holoscan SDK version required", + "holoscan_version": "Minimum Holoscan SDK version required (4.2.0 or later)", "description": "Short module description", "contact_email": "Maintainer contact email (used in Debian package metadata)" } diff --git a/src/holoscan_cli/templates/module/hooks/post_gen_project.py b/src/holoscan_cli/templates/module/hooks/post_gen_project.py index 42181527..7138ff8f 100755 --- a/src/holoscan_cli/templates/module/hooks/post_gen_project.py +++ b/src/holoscan_cli/templates/module/hooks/post_gen_project.py @@ -14,17 +14,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Post-generation hook: clean up language-specific files and initialise git.""" +"""Post-generation hook: clean up language-specific files. + +Repository initialization belongs to ``holoscan create`` after the staged tree +has been safely materialized, so this hook never mutates caller-owned Git state. +""" import os -import pathlib import shutil -import subprocess -import warnings LANGUAGE = "{{ cookiecutter.language }}" MODULE_SLUG = "{{ cookiecutter.module_slug }}" -MODULE_REPO_NAME = "{{ cookiecutter.module_repo_name }}" OPERATOR_SLUG = "{{ cookiecutter.operator_slug }}" @@ -52,125 +52,9 @@ def remove_empty_dirs(root: str = ".") -> None: # Remove any directories that became empty (from Jinja2 conditional filenames). remove_empty_dirs() -# Locate the HoloHub clone so we can copy in companion CMake helpers. The -# cookiecutter runs this hook from a temp file, so __file__ is unreliable. -# holoscan-cli sets HOLOSCAN_CLI_ROOT to the HoloHub root before invoking -# cookiecutter (legacy HOLOHUB_ROOT is honored too); standalone cookiecutter -# use falls back to a sibling-directory search. -_holohub_root = None -_env_root = os.environ.get("HOLOSCAN_CLI_ROOT") or os.environ.get("HOLOHUB_ROOT") -_candidates = ( - pathlib.Path.cwd().parent / "holohub-internal", - pathlib.Path.cwd().parent.parent / "holohub-internal", - pathlib.Path.cwd().parent / "holohub", - pathlib.Path.cwd().parent.parent / "holohub", -) -if _env_root and (pathlib.Path(_env_root) / "utilities" / "metadata").is_dir(): - _holohub_root = pathlib.Path(_env_root) -else: - for candidate in _candidates: - if (candidate / "utilities" / "metadata").is_dir(): - _holohub_root = candidate.resolve() - break - -if _holohub_root is None: - _candidate_list = ", ".join(str(c) for c in _candidates) - warnings.warn( - f"HoloHub root not found. CMake helpers were not copied into cmake/.\n" - f" HOLOSCAN_CLI_ROOT env var: {os.environ.get('HOLOSCAN_CLI_ROOT')!r}\n" - f" HOLOHUB_ROOT env var: {os.environ.get('HOLOHUB_ROOT')!r}\n" - f" cwd: {pathlib.Path.cwd()}\n" - f" Candidates checked: {_candidate_list}\n" - f"To fix: set HOLOSCAN_CLI_ROOT=/path/to/holohub before running cookiecutter,\n" - f" or rename your HoloHub clone to 'holohub' or 'holohub-internal' as a\n" - f" sibling of the generated module directory.", - stacklevel=1, - ) - -# Copy CMake helpers from the HoloHub clone so the module builds standalone: -# - HoloHubConfigHelpers.cmake: add_holohub_application/operator/package gating -# - holohub_configure_deb.cmake: deb packaging helper used by the root -# CMakeLists. Brings a companion asset: -# - Config.cmake.in: package-config template the helper feeds to -# configure_package_config_file() when EXPORT_NAME is set, so -# downstream find_package() resolves the exported targets -# - pybind11_add_holohub_module.cmake: fetches pybind11 at the HSDK-pinned -# version + ABI-aligned target. Brings two companion assets: -# - pybind11/__init__.py: per-operator __init__ template configured by -# the helper (provides ABI error diagnostics) -# - pydoc/macros.hpp: docstring macros referenced by pybind sources -if _holohub_root: - _cmake_dst = pathlib.Path("cmake") - _cmake_dst.mkdir(exist_ok=True) - # Single-file helpers - for _rel in ( - ("cmake", "HoloHubConfigHelpers.cmake"), - ("cmake", "modules", "holohub_configure_deb.cmake"), - ("cmake", "modules", "Config.cmake.in"), - ("cmake", "pybind11_add_holohub_module.cmake"), - ): - _src = _holohub_root.joinpath(*_rel) - if _src.exists(): - shutil.copy2(_src, _cmake_dst / _src.name) - # Companion directories the pybind11 helper expects alongside itself - for _subdir in ("pybind11", "pydoc"): - _src_dir = _holohub_root / "cmake" / _subdir - if _src_dir.is_dir(): - _dst_dir = _cmake_dst / _subdir - if _dst_dir.exists(): - shutil.rmtree(_dst_dir) - shutil.copytree(_src_dir, _dst_dir) - -# Copy shared CI scripts from the HoloHub clone so the module's pre-commit -# hooks have access to check_copyright.py and its gitutils dependency without -# maintaining a fork. -if _holohub_root: - _scripts_src = _holohub_root / ".github" / "workflows" / "scripts" - _scripts_dst = pathlib.Path(".github") / "workflows" / "scripts" - _scripts_dst.mkdir(parents=True, exist_ok=True) - for _script in ("check_copyright.py", "gitutils.py"): - _src = _scripts_src / _script - if _src.exists(): - shutil.copy2(_src, _scripts_dst / _script) - else: - warnings.warn( - f"Expected script not found: {_src}\n" - f"The check-copyright pre-commit hook will fail until this file is present.\n" - f"Copy it manually from your HoloHub clone into .github/workflows/scripts/.", - stacklevel=1, - ) - -# Make the module CLI wrapper executable. -wrapper = "./holohub" -if os.path.isfile(wrapper): - os.chmod(wrapper, 0o755) - -# Initialise a git repository so the module is ready to push. Pin the -# initial branch to `main` independently of the user's global -# init.defaultBranch — the next-steps message below assumes `main`, -# and `git symbolic-ref` works on any git version (including pre-2.28 -# where `git init -b` isn't supported) by relabelling HEAD before any -# commit creates the branch ref. -git_ok = False -try: - subprocess.run(["git", "init", "."], check=True, capture_output=True) - subprocess.run( - ["git", "symbolic-ref", "HEAD", "refs/heads/main"], - check=True, - capture_output=True, - ) - subprocess.run(["git", "add", "."], check=True, capture_output=True) - git_ok = True -except (subprocess.CalledProcessError, FileNotFoundError): - # Git isn't required to use the scaffold — silently skip init when git - # is missing or refuses. The next-steps message below still prints the - # manual git commands the user can run. - pass - # ── Next-steps message ──────────────────────────────────────────────────────── op_parts = OPERATOR_SLUG.split("_") OPERATOR_CLASS = "".join(p.capitalize() for p in op_parts) -pipeline = f"{MODULE_SLUG}_pipeline" print(f"\n\033[32mHoloscan Module '{MODULE_SLUG}' created successfully!\033[0m\n") print(f"Implement your operator ({OPERATOR_CLASS}) in:") @@ -179,16 +63,6 @@ def remove_empty_dirs(root: str = ".") -> None: else: print(f" operators/{OPERATOR_SLUG}/{OPERATOR_SLUG}.py\n") -print("Build and run:") -print(" ./holohub run-container") -print(" # Inside the container:") -print(f" ./holohub build {pipeline}") -print(f" ./holohub run {pipeline} --language python\n") - -if git_ok: - print("Git repository initialised. Push to a remote when ready:") - print(" git remote add origin ") - print(" git push -u origin main\n") -else: - print("Note: could not run git init — initialise the repository manually.\n") +print("Next steps:") +print(" See README.md for environment setup, build, run, and test instructions.\n") print("Register your module at https://nvidia-holoscan.github.io/ when ready.") diff --git a/src/holoscan_cli/templates/module/hooks/pre_gen_project.py b/src/holoscan_cli/templates/module/hooks/pre_gen_project.py new file mode 100644 index 00000000..165e4c68 --- /dev/null +++ b/src/holoscan_cli/templates/module/hooks/pre_gen_project.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Reject Cookiecutter values that cannot form a valid Holoscan Module.""" + +import keyword +import re +import sys + +PROJECT_NAME = {{ cookiecutter.project_name | tojson }} +MODULE_SLUG = {{ cookiecutter.module_slug | tojson }} +MODULE_REPO_NAME = {{ cookiecutter.module_repo_name | tojson }} +OPERATOR_SLUG = {{ cookiecutter.operator_slug | tojson }} +LANGUAGE = {{ cookiecutter.language | tojson }} +LICENSE = {{ cookiecutter._license | tojson }} + +_PROJECT_NAME = re.compile(r"[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*") +_SNAKE_CASE = re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*") +_REPOSITORY_NAME = re.compile(r"holoscan-[a-z0-9]+(?:-[a-z0-9]+)*") + + +def reject(message: str) -> None: + print(f"Invalid Module template context: {message}", file=sys.stderr) + raise SystemExit(1) + + +if LANGUAGE not in {"cpp", "python"}: + reject("language must be 'cpp' or 'python'.") + +if not _PROJECT_NAME.fullmatch(PROJECT_NAME): + reject("project_name must contain alphanumeric words separated by spaces, '-' or '_'.") + +for name, value in (("module_slug", MODULE_SLUG), ("operator_slug", OPERATOR_SLUG)): + if not _SNAKE_CASE.fullmatch(value) or not value.isidentifier() or keyword.iskeyword(value): + reject(f"{name} must be a lowercase snake_case identifier beginning with a letter.") + +expected_repo_name = f"holoscan-{MODULE_SLUG.replace('_', '-')}" +if not _REPOSITORY_NAME.fullmatch(MODULE_REPO_NAME) or MODULE_REPO_NAME != expected_repo_name: + reject(f"module_repo_name must be {expected_repo_name!r} for module_slug {MODULE_SLUG!r}.") + +if LICENSE != "Apache-2.0": + reject("_license must be 'Apache-2.0' because the generated LICENSE contains Apache-2.0.") diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.dockerignore b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.dockerignore new file mode 100644 index 00000000..490b36b3 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.cache +.pytest_cache +.ruff_cache +.venv +__pycache__ +build +build-* +data +dist +htmlcov +tests/reports diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/ci.yml b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/ci.yml index c1eb7642..e677f41a 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/ci.yml +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/ci.yml @@ -22,8 +22,10 @@ jobs: - name: Python lint run: ruff check . - name: Validate metadata.json against the Holoscan CLI schema + env: + PIP_EXTRA_INDEX_URL: https://pypi.nvidia.com run: | - pip install holoscan-cli + pip install 'holoscan-cli[create]' -c requirements-cli.txt python .github/workflows/scripts/validate_metadata.py {% if cookiecutter.language == 'cpp' %} - name: Install clang-format @@ -65,12 +67,15 @@ jobs: runs-on: [self-hosted, linux, x86_64, gpu] container: - image: nvcr.io/nvidia/clara-holoscan/holoscan:v{{ cookiecutter.holoscan_version }}-cuda13-dgpu + image: nvcr.io/nvidia/clara-holoscan/holoscan:v{{ cookiecutter.holoscan_version }}-cuda13 options: --gpus all steps: - uses: actions/checkout@v4 + - name: Install Python test dependencies + run: python -m pip install 'pytest>=8.2' + - name: Configure run: | cmake -S . -B build \ diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/check_copyright.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/check_copyright.py new file mode 100644 index 00000000..e8f89e7f --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/check_copyright.py @@ -0,0 +1,332 @@ +""" +SPDX-FileCopyrightText: Copyright (c) 2022-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. +""" + +# This file is modified from the RAPIDS RAFT project which is under the +# Apache 2.0 license. +# (https://github.com/rapidsai/raft/blob/branch-22.08/ci/checks/copyright.py) + +import argparse +import datetime +import itertools +import os +import re +import sys + +import gitutils + +FilesToCheck = [ + re.compile(r"[.](cmake|cpp|cu|cuh|h|hpp|sh|pxd|py|pyx|yaml)$"), + re.compile(r"CMakeLists[.]txt$"), + re.compile(r"Dockerfile$"), + re.compile(r"[.]dockerfile$"), + re.compile(r"CMakeLists_standalone[.]txt$"), + re.compile(r"setup[.]cfg$"), + re.compile(r"[.]flake8[.]cython$"), + re.compile(r"meta[.]yaml$"), +] +ExemptFiles = [] + +CheckSPDXWithCopyright = re.compile( + r"(^|\s*[#*/*]\s*)?SPDX-FileCopyrightText: Copyright(?: \(c\))? *(\d{4})(?:-(\d{4}))?,? ([\w\s&.,'-]+)\.?", + re.MULTILINE | re.DOTALL | re.IGNORECASE, +) +CheckSPDXREUSE = re.compile( + r"(^|\s*[#*/*]\s*)?SPDX-FileCopyrightText: *(\d{4})(?:-(\d{4}))? ([\w\s&.,@<>'-]+)", + re.MULTILINE | re.DOTALL | re.IGNORECASE, +) +CheckNonSPDX = re.compile( + r"(^|\s*[#*/*]\s*)?Copyright(?: \(c\))? *(\d{4})(?:-(\d{4}))?,? ([\w\s&.,'-]+)\.?", + re.MULTILINE | re.DOTALL | re.IGNORECASE, +) + + +def check_this_file(f): + # This check covers things like symlinks which point to files that do not exist + if not (os.path.exists(f)): + return False + if gitutils and gitutils.is_file_empty(f): + return False + for exempt in ExemptFiles: + if exempt.search(f): + return False + return any(checker.search(f) for checker in FilesToCheck) + + +def get_copyright_years(line): + # Check all three patterns + for pattern in [CheckSPDXWithCopyright, CheckSPDXREUSE, CheckNonSPDX]: + res = pattern.search(line) + if res: + start_year = int(res.group(2)) + end_year = int(res.group(3)) if res.group(3) else start_year + return (start_year, end_year) + + return (None, None) + + +def replace_current_year(line, start, end): + def replace_spdx_with_copyright(match): + comment_prefix = match.group(1) or "" + affiliation = match.group(4) + return f"{comment_prefix}SPDX-FileCopyrightText: Copyright (c) {start}-{end} {affiliation}" + + def replace_spdx_reuse(match): + comment_prefix = match.group(1) or "" + affiliation = match.group(4) + return f"{comment_prefix}SPDX-FileCopyrightText: {start}-{end} {affiliation}" + + def replace_non_spdx(match): + comment_prefix = match.group(1) or "" + affiliation = match.group(4) + return f"{comment_prefix}Copyright (c) {start}-{end}, {affiliation}" + + # Apply each pattern's replacement + res = CheckSPDXWithCopyright.sub(replace_spdx_with_copyright, line) + res = CheckSPDXREUSE.sub(replace_spdx_reuse, res) + res = CheckNonSPDX.sub(replace_non_spdx, res) + + return res + + +def check_copyright(f, update_current_year, ignore_year_mismatch=False): + """ + Checks for copyright headers and their years + """ + errs = [] + this_year = datetime.datetime.now(datetime.timezone.utc).year + line_num = 0 + cr_found = False + year_matched = False + with open(f, encoding="utf-8") as fp: + lines = fp.readlines() + content = "".join(lines) + + # Check the entire file content for copyright headers + start, end = get_copyright_years(content) + if start is not None: + cr_found = True + if start > end: + e = [ + f, + 1, + "First year after second year in the copyright header (manual fix required)", + None, + ] + errs.append(e) + if not ignore_year_mismatch and (this_year < start or this_year > end): + e = [f, 1, "Current year not included in the copyright header", None] + if this_year < start: + e[-1] = replace_current_year(content, this_year, end) + if this_year > end: + e[-1] = replace_current_year(content, start, this_year) + errs.append(e) + else: + year_matched = True + fp.close() + # copyright header itself not found + if not cr_found: + e = [ + f, + 0, + "Copyright header missing or formatted incorrectly (manual fix required)", + None, + ] + errs.append(e) + # even if the year matches a copyright header, make the check pass + if year_matched: + errs = [] + + if update_current_year: + errs_update = [x for x in errs if x[-1] is not None] + if len(errs_update) > 0: + print( + "File: {}. Changing line(s) {}".format( + f, ", ".join(str(x[1]) for x in errs if x[-1] is not None) + ) + ) + # Check if we're updating the entire file content (line_num == 1 and replacement is entire content) + if len(errs_update) == 1 and errs_update[0][1] == 1 and "\n" in errs_update[0][3]: + # This is a full file content replacement + with open(f, "w", encoding="utf-8") as out_file: + out_file.write(errs_update[0][3]) + else: + # This is line-by-line replacement + for _, line_num, __, replacement in errs_update: + lines[line_num - 1] = replacement + with open(f, "w", encoding="utf-8") as out_file: + out_file.writelines(lines) + errs = [x for x in errs if x[-1] is None] + + return errs + + +def get_all_files_under_dir(root): + ret_list = [] + for dirpath, _, filenames in os.walk(root): + ret_list.extend([os.path.join(dirpath, fn) for fn in filenames]) + return ret_list + + +def check_copyright_main(): + """ + Checks for copyright headers in all the modified files. In case of local + repo, this script will just look for uncommitted files and in case of CI + it compares between branches "$PR_TARGET_BRANCH" and "current-pr-branch" + """ + ret_val = 0 + global ExemptFiles + + argparser = argparse.ArgumentParser( + "Checks for a consistent copyright header in git's modified files" + ) + argparser.add_argument( + "--update-current-year", + dest="update_current_year", + action="store_true", + required=False, + help="If set, update the current year if a header is already present and well formatted.", + ) + argparser.add_argument( + "--git-modified-only", + dest="git_modified_only", + action="store", + type=str, + nargs="?", + default=None, + const="no-target", + required=False, + help="If set, " + "only files seen as modified by git will be " + "processed. It will look for local modifications" + "(unstaged, untracked) if no git reference is provided.", + ) + argparser.add_argument( + "--exclude", + dest="exclude", + action="append", + required=False, + default=[], + help=("Exclude the paths specified (regexp). Can be specified multiple times."), + ) + argparser.add_argument( + "--exclude-config", + dest="exclude_config", + type=str, + required=False, + default=None, + help=( + "Path to a file containing exclude patterns (one per line). " + "Lines starting with # are treated as comments." + ), + ) + argparser.add_argument( + "--ignore-year-mismatch", + dest="ignore_year_mismatch", + action="store_true", + required=False, + help="If set, ignore year mismatches in copyright headers (when current year is not within the copyright year range).", + ) + + args, dirs = argparser.parse_known_args() + + # Read excludes from config file if specified + config_excludes = [] + if args.exclude_config: + config_path = args.exclude_config + if not os.path.isabs(config_path): + # If relative path, try current working directory first + if os.path.exists(config_path): + config_path = os.path.abspath(config_path) + else: + # If not found in current directory, try relative to script directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(script_dir, os.path.basename(config_path)) + + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if line and not line.startswith("#"): + config_excludes.append(line) + else: + print(f"Warning: Config file not found at {config_path}") + + try: + # Combine config file excludes with command line excludes + all_excludes = config_excludes + args.exclude + ExemptFiles = ExemptFiles + [pathName for pathName in all_excludes] + ExemptFiles = [re.compile(file) for file in ExemptFiles] + except re.error as reException: + print("Regular expression error:") + print(reException) + return 1 + + all_files = [] + if dirs: + for d in [os.path.abspath(d) for d in dirs]: + if not (os.path.isdir(d)): + raise ValueError(f"{d} is not a directory.") + all_files += get_all_files_under_dir(d) + + if args.git_modified_only: + target_branch = None + if args.git_modified_only != "no-target": + target_branch = args.git_modified_only + print(f"Checking copyright headers in modified files between {target_branch} and HEAD") + modified_files = gitutils.modified_files(target_branch, True) + all_files = list(set(all_files).intersection(modified_files)) if dirs else modified_files + + files = [f for f in all_files if check_this_file(f)] + + # Print progress information + print(f"Checking copyright headers in {len(files)} files out of {len(all_files)} total files") + if len(files) > 0: + print(f"Example files being checked: {', '.join(files[:3])}") + if len(files) > 3: + print(f"... and {len(files) - 3} more files") + + errors = tuple( + itertools.chain( + *[ + check_copyright(f, args.update_current_year, args.ignore_year_mismatch) + for f in files + ] + ) + ) + if errors: + print("Copyright headers incomplete in some of the files!") + for file_name, line_no, err_msg, _ in errors: + print(f" {file_name}:{line_no} Issue: {err_msg}") + print() + n_fixable = sum(1 for e in errors if e[-1] is not None) + if n_fixable > 0: + print( + f"You can run `python3 {' '.join(sys.argv)} --update-current-year` to fix " + f"{n_fixable} of these errors." + ) + ret_val = 1 + else: + print("Copyright check passed") + + return ret_val + + +if __name__ == "__main__": + import sys + + sys.exit(check_copyright_main()) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/gitutils.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/gitutils.py new file mode 100644 index 00000000..112cf2da --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/gitutils.py @@ -0,0 +1,162 @@ +""" +SPDX-FileCopyrightText: Copyright (c) 2022-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. +""" + +import os +import re +import subprocess + + +def is_file_empty(f): + return os.stat(f).st_size == 0 + + +def __git(*opts): + """Runs a git command and returns its output""" + ret = subprocess.check_output(["git", *opts], shell=False) + return ret.decode("UTF-8").rstrip("\n") + + +def __gitdiff(*opts): + """Runs a git diff command with no pager set""" + return __git("--no-pager", "diff", *opts) + + +def _resolve_commit(ref): + """Resolve a caller-provided revision without treating it as a Git option.""" + if not isinstance(ref, str) or not ref or ref.startswith("-") or "\0" in ref: + raise ValueError(f"Invalid Git revision: {ref!r}") + return __git("rev-parse", "--verify", "--end-of-options", f"{ref}^0") + + +def branch(): + """Returns the name of the current branch""" + name = __git("rev-parse", "--abbrev-ref", "HEAD") + name = name.rstrip() + return name + + +def dir_(): + """Returns the top level directory of the repository""" + git_dir = __git("rev-parse", "--show-toplevel") + git_dir = git_dir.rstrip() + return git_dir + + +def repo_version(): + """ + Determines the version of the repo by using `git describe` + + Returns + ------- + str + The full version of the repo in the format 'v#.#.#{a|b|rc}' + """ + return __git("describe", "--tags", "--abbrev=0") + + +def repo_version_major_minor(): + """ + Determines the version of the repo using `git describe` and returns only + the major and minor portion + + Returns + ------- + str + The partial version of the repo in the format '{major}.{minor}' + """ + + full_repo_version = repo_version() + + match = re.match(r"^v?(?P[0-9]+)(?:\.(?P[0-9]+))?", full_repo_version) + + if match is None: + print( + " [DEBUG] Could not determine repo major minor version. " + f"Full repo version: {full_repo_version}." + ) + return None + + out_version = match.group("major") + + if match.group("minor"): + out_version += "." + match.group("minor") + + return out_version + + +def uncommitted_files(): + """ + Returns a list of all changed files that are not yet committed. This + means both untracked/unstaged as well as uncommitted files too. + """ + entries = __git("status", "--porcelain=v1", "-z", "--untracked-files=all").split("\0") + ret = [] + index = 0 + while index < len(entries): + entry = entries[index] + index += 1 + if not entry: + continue + status, path = entry[:2], entry[3:] + ret.append(path) + if "R" in status or "C" in status: + index += 1 # porcelain -z adds the original rename/copy path next + return ret + + +def changed_files_between(base_ref, new_ref): + """ + Returns a list of files changed between base_ref and new_ref + """ + base_commit = _resolve_commit(base_ref) + new_commit = _resolve_commit(new_ref) + files = __gitdiff("--name-only", "--ignore-submodules", f"{base_commit}..{new_commit}") + return files.splitlines() + + +def changes_in_file_between(file, b1, b2, filter=None): + """Filters the changed lines to a file between the branches b1 and b2""" + b1_commit = _resolve_commit(b1) + b2_commit = _resolve_commit(b2) + path = os.fspath(file) + if "\0" in path: + raise ValueError("Git paths cannot contain NUL bytes") + diffs = __gitdiff( + "--ignore-submodules", + "-w", + "--minimal", + "-U0", + f"{b1_commit}...{b2_commit}", + "--", + f":(literal){path}", + ) + return [line for line in diffs.splitlines() if (filter is None or filter(line))] + + +def modified_files(target=None, absolute_path=False): + """ + If target is passed, then lists out all files modified between that git + reference and HEAD. If this fails, this function will list out all + the uncommitted files in the current branch. + """ + all_files = changed_files_between(target, "HEAD") if target else uncommitted_files() + + if absolute_path: + git_dir = dir_() + return [os.path.join(git_dir, fn) for fn in all_files] + else: + return all_files diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/validate_metadata.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/validate_metadata.py index 1292c90b..3ce0a5cd 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/validate_metadata.py +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/validate_metadata.py @@ -26,7 +26,18 @@ import sys from pathlib import Path -_EXCLUDE_DIRS = {"build", "install", ".local", ".cache", "_CPack_Packages"} +# .venv holds the installed holoscan-cli, whose wheel ships an unrendered +# template; its metadata.json files are not valid instances of any schema. +_EXCLUDE_DIRS = { + ".cache", + ".git", + ".local", + ".venv", + "_CPack_Packages", + "build", + "install", + "venv", +} def iter_metadata_files(repo_root: Path): diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.gitignore b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.gitignore index 82bcb210..846acb13 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.gitignore +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.gitignore @@ -2,8 +2,8 @@ build/ .cmake/ -# HoloHub CLI tools (auto-downloaded by the holohub wrapper on first run) -.holohub/ +# Caller-owned Python environments +.venv/ # Container HOME-mapped junk (dev container sets HOME=/workspace/{{ cookiecutter.module_slug }}) .local/ diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.pre-commit-config.yaml b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.pre-commit-config.yaml index cce34a02..4cc89ac7 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.pre-commit-config.yaml +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.pre-commit-config.yaml @@ -5,8 +5,8 @@ # Run `pre-commit run --all-files` to lint the whole tree. # Run `pre-commit autoupdate` to refresh to latest compatible versions. -# Build outputs, dev-container HOME junk, and the cmake/ helpers copied in from -# the HoloHub clone (vendored, not maintained here) are excluded from all hooks. +# Build outputs, dev-container HOME junk, and scaffolded third-party cmake/ +# helpers (vendored, not maintained here) are excluded from all hooks. exclude: '^(build[^/]*|install[^/]*|dist|\.cache|\.local|\.cupy|_CPack_Packages|cmake)/' repos: @@ -65,8 +65,11 @@ repos: hooks: - id: holoscan-metadata-validate name: validate metadata.json against the Holoscan CLI schema - entry: python3 .github/workflows/scripts/validate_metadata.py - language: system + # This hook uses pre-commit's isolated Python so its schema dependencies + # stay separate from the Module's lightweight CLI environment. + entry: python .github/workflows/scripts/validate_metadata.py + language: python + additional_dependencies: ["holoscan-cli[create]=={{ cookiecutter._holoscan_cli_version }}"] files: '(^|/)metadata\.json$' pass_filenames: false @@ -81,7 +84,9 @@ repos: - id: check-copyright name: check-copyright - entry: python3 .github/workflows/scripts/check_copyright.py + # `holoscan lint` exports HOLOSCAN_CLI_PYTHON_BIN so the hook runs on the + # same interpreter as the CLI; plain `pre-commit run` falls back to python3. + entry: bash -c 'exec "${HOLOSCAN_CLI_PYTHON_BIN:-python3}" .github/workflows/scripts/check_copyright.py "$@"' -- args: - . - --git-modified-only diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/CMakeLists.txt index 269eb0e8..13cc0434 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/CMakeLists.txt +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/CMakeLists.txt @@ -65,7 +65,7 @@ endif() # holoscan.{{ cookiecutter.module_slug }} alongside the installed Holoscan SDK package. # # The package lands under python/lib/ (not just python/) to match the layout -# the Holoscan CLI expects in default `./holohub run` mode — it resolves the +# the Holoscan CLI expects in default `holoscan run` mode — it resolves the # module from /python/lib, so `import holoscan.{{ cookiecutter.module_slug }}` fails if the # package is staged one directory higher. # --------------------------------------------------------------------------- @@ -90,8 +90,7 @@ endif() # --------------------------------------------------------------------------- # HoloHubConfigHelpers.cmake provides add_holohub_application/operator/package # which gate each subproject on its own option (APP_/OP_/PKG_). The -# helpers ship with the HoloHub CLI and are copied into this module's cmake/ -# directory by the cookiecutter post-gen hook so the project also builds +# helpers are bundled directly in this generated repository so it builds # without a HoloHub clone. list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") include(HoloHubConfigHelpers) @@ -118,10 +117,10 @@ endif() # --------------------------------------------------------------------------- # Stage dev-mode hook files in the build tree # --------------------------------------------------------------------------- -# `./holohub install --dev` copies these into the user's +# `holoscan install --dev` copies these into the user's # Python user-site so `import holoscan.{{ cookiecutter.module_slug }}` works in # any shell — without a wheel install. We only *stage* them here; the install -# itself is opt-in so a plain `./holohub build` never +# itself is opt-in so a plain `holoscan build` never # touches the host's Python environment. set(_DEV_HELPER_FILE "${CMAKE_BINARY_DIR}/holoscan_{{ cookiecutter.module_slug }}_dev.py") @@ -129,11 +128,11 @@ set(_DEV_PTH_FILE "${CMAKE_BINARY_DIR}/holoscan-{{ cookiecutter.module_slug.r set(_DEV_TARGET_PATH "${{ '{' }}{{ cookiecutter.module_slug | upper }}_PYTHON_ROOT}/holoscan") file(WRITE ${_DEV_HELPER_FILE} -"# Auto-generated by ./holohub build — do not edit. +"# Auto-generated by holoscan build — do not edit. # Extends holoscan.__path__ to include this module's build tree, so that # `import holoscan.{{ cookiecutter.module_slug }}` resolves to the live build # output without a wheel install. Installed to user-site by -# `./holohub install --dev`. +# `holoscan install --dev`. import os _BUILD_PATH = r\"${_DEV_TARGET_PATH}\" diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/DEVELOPER.md b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/DEVELOPER.md index 062a88f9..616cff8f 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/DEVELOPER.md +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/DEVELOPER.md @@ -11,7 +11,7 @@ distributing this Holoscan Module. ```text {{ cookiecutter.module_repo_name }}/ -├── holohub # CLI wrapper (delegates to holoscan-cli) +├── requirements-cli.txt # Tested holoscan-cli development version ├── Dockerfile # Development container image ├── CMakeLists.txt # Root CMake — orchestrates operators/applications/tests ├── pyproject.toml # Python packaging metadata (scikit-build-core) @@ -33,30 +33,46 @@ distributing this Holoscan Module. --- -## `holohub` wrapper commands +## Holoscan CLI environment and commands -The `holohub` script at the module root wraps the `holoscan-cli` package with module-specific -defaults. On a host, its first run creates a managed virtual environment and installs -`holoscan-cli` there (internet required); subsequent runs reuse that environment. Containers -reuse the package installed into the image during the Docker build. +Create the development environment with the exact CLI version committed by this Module: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install \ + --extra-index-url https://pypi.nvidia.com \ + -r requirements-cli.txt +``` + +The exact pin makes fresh host environments and development images reproducible. Lifecycle +commands remain usable with another installed CLI version when its behavior is compatible. | Command | What it does | | --- | --- | -| `./holohub run-container` | Build and start the development container | -| `./holohub build {{ cookiecutter.module_slug }}_pipeline` | CMake configure + build inside the container | -| `./holohub run {{ cookiecutter.module_slug }}_pipeline` | Run the example pipeline | -| `./holohub test` | Run CTest (C++ unit tests) and pytest | -| `./holohub install --dev` | Install a `.pth` hook so `import holoscan.{{ cookiecutter.module_slug }}` works in any shell | - -The wrapper file owns the holoscan-cli version: bump -`HOLOSCAN_CLI_PINNED_VERSION` in `./holohub` to upgrade the CLI everywhere; -the Dockerfile copies and runs the wrapper, so the image follows the same -pin. Set `HOLOSCAN_CLI_INSTALL_ARGS` to override the pip index arguments, or -`HOLOSCAN_CLI_SOURCE` to a local checkout for host-side CLI development. +| `holoscan run-container` | Build and start the development container | +| `holoscan build {{ cookiecutter.module_slug }}_pipeline` | CMake configure + build inside the container | +| `holoscan run {{ cookiecutter.module_slug }}_pipeline` | Run the example pipeline | +| `holoscan test` | Run CTest (C++ unit tests) and pytest | +| `holoscan install --dev` | Install a `.pth` hook so `import holoscan.{{ cookiecutter.module_slug }}` works in any shell | + +The scaffold intentionally ships no launcher wrapper. Projects that need custom environment or +bootstrap policy can add a thin wrapper as an advanced customization, keep it outside the Module's +build and package contract, and delegate to the installed `holoscan` command. HoloHub's +[`holohub` wrapper](https://github.com/nvidia-holoscan/holohub/blob/main/holohub) is one reference. + +If you use [uv](https://docs.astral.sh/uv/), the project config selects NVIDIA's package index for +`holoscan-cli` while leaving other dependencies on PyPI. Run `uv sync --only-dev`, then +`source .venv/bin/activate` and use the same commands above. This installs the development tools +without trying to build the Module on the host. + +To upgrade the tested development environment, update the CLI pin in both +`requirements-cli.txt` and `pyproject.toml`, reinstall the requirements (or rerun +`uv sync --only-dev`), and rebuild the image. --- -## Building without the wrapper +## Building without the Holoscan CLI ```bash cmake -S . -B build -DBUILD_ALL=ON -D{{ cookiecutter.module_slug | upper }}_BUILD_TESTING=ON @@ -89,7 +105,8 @@ pytest tests/python/ -v ## `pyproject.toml` `pyproject.toml` configures [scikit-build-core](https://scikit-build-core.readthedocs.io/) for -wheel packaging. Key fields to update before publishing: +wheel packaging and records an optional PEP 735 development dependency group. Key fields to update +before publishing: | Field | Purpose | | --- | --- | @@ -97,6 +114,8 @@ wheel packaging. Key fields to update before publishing: | `[project].version` | Sync with `metadata.json:module.version` | | `[project].description` | Short description shown on PyPI | | `[project].authors` | Your name / organisation | +| `[dependency-groups].dev` | Exact CLI convenience pin; keep synchronized with `requirements-cli.txt` | +| `[tool.uv]` | NVIDIA index selection for the pinned `holoscan-cli` development dependency | | `[tool.scikit-build].cmake.args` | Extra CMake flags passed during `pip install` | Build a wheel: diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/Dockerfile b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/Dockerfile index e152c7a3..f78c27b4 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/Dockerfile +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/Dockerfile @@ -10,21 +10,24 @@ # into the image; the project tree is bind-mounted by the holoscan-cli at # /workspace/{{ cookiecutter.module_slug }} when the container is launched. # -# Build & run via the holoscan-cli wrapper (recommended): -# ./holohub run-container +# Build & run via the installed Holoscan CLI: +# holoscan run-container # # Manual build (without the CLI): # docker build -t holoscan-{{ cookiecutter.module_slug.replace('_', '-') }} . -ARG BASE_IMAGE=nvcr.io/nvidia/clara-holoscan/holoscan:v{{ cookiecutter.holoscan_version }}-cuda13-dgpu +ARG BASE_IMAGE=nvcr.io/nvidia/clara-holoscan/holoscan:v{{ cookiecutter.holoscan_version }}-cuda13 FROM ${BASE_IMAGE} ARG DEBIAN_FRONTEND=noninteractive -# Install the wrapper-pinned holoscan-cli; copying only the wrapper keeps -# this layer cached until the pin changes. -COPY --chmod=755 holohub /tmp/scripts/ -RUN /tmp/scripts/holohub env-info +# Install the exact CLI pin. holoscan-cli has no runtime dependencies, so +# this adds nothing else to the image. +COPY requirements-cli.txt /tmp/requirements-cli.txt +RUN python3 -m pip install --no-cache-dir \ + --extra-index-url https://pypi.nvidia.com \ + -r /tmp/requirements-cli.txt \ + && holoscan version # TODO: add module-specific runtime / build dependencies below. {% if cookiecutter.language == "cpp" %}RUN apt-get update \ @@ -34,6 +37,7 @@ RUN /tmp/scripts/holohub env-info pybind11-dev \ && rm -rf /var/lib/apt/lists/* {% endif %} -# Python tooling: pytest-timeout for tests, build + scikit-build-core for -# `./holohub package --pkg-generator WHEEL` (drives `python -m build`). -RUN pip install --no-cache-dir pytest-timeout build scikit-build-core +# Test and wheel-build tooling. Must be in the same interpreter the CLI runs +# on: build and package pass it to CMake as Python3_EXECUTABLE. +RUN python3 -m pip install --no-cache-dir \ + 'pytest>=8.2' pytest-timeout build scikit-build-core diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/README.md b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/README.md index 1fa3c78d..411c979a 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/README.md +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/README.md @@ -12,15 +12,28 @@ A **Holoscan Module** — a self-contained, redistributable library that extends ## Quick Start +Create and activate a standard Python virtual environment, then install the exact CLI version +pinned by this Module: + ```bash -# 1. Run the Python demo application -./holohub run {{ cookiecutter.module_slug }}_pipeline --language python +python3 -m venv .venv +source .venv/bin/activate +python -m pip install \ + --extra-index-url https://pypi.nvidia.com \ + -r requirements-cli.txt + +# Run the Python demo application +holoscan run {{ cookiecutter.module_slug }}_pipeline --language python {% if cookiecutter.language == 'cpp' %} -# 2. Run the C++ demo application -./holohub run {{ cookiecutter.module_slug }}_pipeline +# Run the C++ demo application +holoscan run {{ cookiecutter.module_slug }}_pipeline {% endif %} ``` +If you use [uv](https://docs.astral.sh/uv/), replace the environment setup above with +`uv sync --only-dev`, then run `source .venv/bin/activate` and use the same `holoscan` commands. +This installs the development tools without trying to build the Module on the host. + --- ## Operators @@ -78,18 +91,17 @@ int main() { holoscan::make_application()->run(); } {% endif -%} --- -## Building from Source (without HoloHub CLI) +## Building from Source (without Holoscan CLI) | Requirement | Version | | --- | --- | | Holoscan SDK | ≥ {{ cookiecutter.holoscan_version }} | -| CUDA Toolkit | 13.x (matches the Holoscan SDK CUDA pin; the dev `Dockerfile` uses `cuda13-dgpu`) | +| CUDA Toolkit | 13.x (matches the Holoscan SDK CUDA pin; the dev `Dockerfile` uses `cuda13`) | | CMake | ≥ 3.24 | {%- if cookiecutter.language == 'cpp' %} -| C++ compiler | C++17 (GCC 11+) | -| pybind11 | ≥ 2.11 | +| C++ compiler | C++17-capable | {%- endif %} -| Python | 3.10–3.13 | +| Python | ≥ 3.10 | ```bash cmake -S . -B build -DBUILD_ALL=ON -D{{ cookiecutter.module_slug | upper }}_BUILD_TESTING=ON @@ -101,20 +113,18 @@ cmake --build build -j$(nproc) ## Testing ```bash -./holohub test +holoscan test ``` -Or, without the HoloHub CLI: - -{% if cookiecutter.language == 'cpp' %} +Or, without the Holoscan CLI: +{% if cookiecutter.language == 'cpp' -%} ```bash # C++ (GTest via CTest) ctest --test-dir build --output-on-failure -L unit ``` -{% endif %} - +{% endif -%} ```bash # Python (pytest) PYTHONPATH=build/python/lib${PYTHONPATH:+:$PYTHONPATH} {{ cookiecutter.module_slug | upper }}_BUILD_DIR=build pytest tests/python/ -v diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/CMakeLists.txt index fc767cad..86fa7f8f 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/CMakeLists.txt +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/CMakeLists.txt @@ -4,7 +4,8 @@ # The example pipeline ships a Python implementation for every module, plus a # C++ implementation for cpp-language modules. Each lives in its own # subdirectory with its own metadata.json so the Holoscan CLI can discover and -# run them per language (e.g. `./holohub run {{ cookiecutter.module_slug }}_pipeline --language python`). +# run them per language (e.g. `holoscan run {{ cookiecutter.module_slug }}_pipeline --language python`). add_subdirectory(python) -{% if cookiecutter.language == 'cpp' %}add_subdirectory(cpp) -{% endif %} +{%- if cookiecutter.language == 'cpp' %} +add_subdirectory(cpp) +{%- endif %} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/holohub b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/holohub deleted file mode 100755 index 5e8c8c2d..00000000 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/holohub +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. -# SPDX-License-Identifier: {{ cookiecutter._license }} -# -# {{ cookiecutter.project_name }} CLI wrapper. -# Delegates to the standalone `holoscan-cli` package with module-specific -# defaults baked in, defaulting to NVIDIA PyPI release-candidate-capable -# install args. -# -# Usage: -# ./holohub run-container -# ./holohub build {{ cookiecutter.module_slug }}_pipeline -# ./holohub run {{ cookiecutter.module_slug }}_pipeline --language python -# ./holohub --help -# -# Optional overrides (rarely needed): -# HOLOSCAN_CLI_SOURCE Local checkout — wins over the package install. -# HOLOSCAN_CLI_INSTALL_ARGS Pip install arguments. -# HOLOSCAN_CLI_PYTHON_BIN Caller-owned Python interpreter. -# HOLOSCAN_CLI_VENV Wrapper-managed venv location. - -set -euo pipefail - -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -# ============================================================================== -# Module identity — defaults consumed by holoscan-cli -# ============================================================================== - -# HOLOSCAN_CLI_ROOT: the CLI uses this as the project root for cmake -S and container paths. -export HOLOSCAN_CLI_ROOT="${HOLOSCAN_CLI_ROOT:-${SCRIPT_DIR}}" -# HOLOSCAN_CLI_CMD_NAME: shown in help output and error messages. -export HOLOSCAN_CLI_CMD_NAME="${HOLOSCAN_CLI_CMD_NAME:-${BASH_SOURCE[0]}}" -# HOLOSCAN_CLI_REPO_PREFIX: base for naming build dirs, images, etc. -export HOLOSCAN_CLI_REPO_PREFIX="${HOLOSCAN_CLI_REPO_PREFIX:-{{ cookiecutter.module_slug }}}" -# HOLOSCAN_CLI_CONTAINER_PREFIX: Docker image name prefix. -export HOLOSCAN_CLI_CONTAINER_PREFIX="${HOLOSCAN_CLI_CONTAINER_PREFIX:-{{ cookiecutter.module_slug.replace('_', '-') }}}" -# HOLOSCAN_CLI_WORKSPACE_NAME: directory name inside the container at /workspace/. -export HOLOSCAN_CLI_WORKSPACE_NAME="${HOLOSCAN_CLI_WORKSPACE_NAME:-{{ cookiecutter.module_slug }}}" -# HOLOSCAN_CLI_HOSTNAME_PREFIX: container hostname prefix. -export HOLOSCAN_CLI_HOSTNAME_PREFIX="${HOLOSCAN_CLI_HOSTNAME_PREFIX:-{{ cookiecutter.module_slug.replace('_', '-') }}}" - -# ============================================================================== -# Paths -# ============================================================================== - -export HOLOSCAN_CLI_DEFAULT_DOCKERFILE="${HOLOSCAN_CLI_DEFAULT_DOCKERFILE:-${SCRIPT_DIR}/Dockerfile}" -# Recursive walk from working directory — picks up the top-level metadata.json -# (project_type=module) along with operators//metadata.json and -# applications//metadata.json. The narrower default -# `applications,operators` skipped the module's own descriptor, so -# `./holohub list` showed no MODULES section and `./holohub package -# ` couldn't find the module by name. (holohub#1582) -export HOLOSCAN_CLI_SEARCH_PATH="${HOLOSCAN_CLI_SEARCH_PATH:-./}" -export HOLOSCAN_CLI_BUILD_PARENT_DIR="${HOLOSCAN_CLI_BUILD_PARENT_DIR:-${SCRIPT_DIR}/build}" -export HOLOSCAN_CLI_DATA_DIR="${HOLOSCAN_CLI_DATA_DIR:-${SCRIPT_DIR}/data}" - -# ============================================================================== -# Build defaults -# ============================================================================== - -export CMAKE_BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}" - -# ============================================================================== -# holoscan-cli bootstrap + exec -# ============================================================================== - -export HOLOSCAN_CLI_INSTALL_ARGS="${HOLOSCAN_CLI_INSTALL_ARGS:---pre --extra-index-url https://pypi.nvidia.com holoscan-cli>4.2.0}" -# Single bump point for the CLI version everywhere (host, images, runtime); -# keep in sync with the HoloHub repo wrapper. -HOLOSCAN_CLI_DEFAULT_PIN="4.5.0" -export HOLOSCAN_CLI_PINNED_VERSION="${HOLOSCAN_CLI_PINNED_VERSION-${HOLOSCAN_CLI_DEFAULT_PIN}}" -CLI_VENV_EXPLICIT="${HOLOSCAN_CLI_VENV:+1}" -if [[ -z "${HOLOSCAN_CLI_VENV:-}" && -n "${XDG_DATA_HOME:-}${HOME:-}" ]]; then - HOLOSCAN_CLI_VENV="${XDG_DATA_HOME:-${HOME}/.local/share}/holoscan-cli/venv" -fi -export HOLOSCAN_CLI_VENV="${HOLOSCAN_CLI_VENV:-}" - -# Default Holoscan SDK version for this module's container base image. -# holoscan-cli decoupled the base image from its own version (holoscan-cli#177) -# and no longer ships a built-in default, so it errors unless this is set (or -# --base-img is passed). Defaults to the module's holoscan_version. -export HOLOSCAN_CLI_BASE_SDK_VERSION="${HOLOSCAN_CLI_BASE_SDK_VERSION:-{{ cookiecutter.holoscan_version }}}" - -if [[ -n "${HOLOSCAN_CLI_SOURCE:-}" ]]; then - export PYTHONPATH="${HOLOSCAN_CLI_SOURCE%/}/src${PYTHONPATH:+:${PYTHONPATH}}" -fi - -_cli_ok() { - local python="$1" - if [[ -n "${HOLOSCAN_CLI_PINNED_VERSION}" && -z "${HOLOSCAN_CLI_SOURCE:-}" ]]; then - [[ "$("${python}" -c 'from importlib.metadata import version; print(version("holoscan-cli"))' 2>/dev/null)" \ - == "${HOLOSCAN_CLI_PINNED_VERSION}" ]] - else - "${python}" -m holoscan_cli --help 2>/dev/null | grep -qw build - fi -} -_is_container() { - [[ -f /.dockerenv || -f /run/.containerenv || -f /run/systemd/container ]] || - grep -Eq '(docker|containerd|kubepods|podman|buildkit)' /proc/1/cgroup 2>/dev/null -} -_managed_venv_ready() { - [[ -n "${HOLOSCAN_CLI_VENV}" && -x "${HOLOSCAN_CLI_VENV}/bin/python" ]] && - "${HOLOSCAN_CLI_VENV}/bin/python" -m pip --version >/dev/null 2>&1 -} -_use_managed_venv() { - if [[ -z "${HOLOSCAN_CLI_VENV}" ]]; then - echo "[{{ cookiecutter.module_slug }}] Set HOME, XDG_DATA_HOME, or HOLOSCAN_CLI_VENV." >&2 - exit 1 - fi - if ! python3 -m venv "${HOLOSCAN_CLI_VENV}"; then - echo "[{{ cookiecutter.module_slug }}] Could not create ${HOLOSCAN_CLI_VENV}; install python3-venv." >&2 - exit 1 - fi - PYTHON_BIN="${HOLOSCAN_CLI_VENV}/bin/python" -} - -PYTHON_BIN="${HOLOSCAN_CLI_PYTHON_BIN:-${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}}" -if [[ -z "${PYTHON_BIN}" ]]; then - unset PYTHONHOME -fi -if [[ -n "${PYTHON_BIN}" ]]; then - : -elif [[ "${EUID}" -eq 0 ]] && _is_container; then - PYTHON_BIN="python3" - export PIP_BREAK_SYSTEM_PACKAGES="${PIP_BREAK_SYSTEM_PACKAGES:-1}" -elif _managed_venv_ready; then - PYTHON_BIN="${HOLOSCAN_CLI_VENV}/bin/python" -elif [[ -n "${CLI_VENV_EXPLICIT}" ]]; then - _use_managed_venv -elif _is_container && [[ -z "${HOLOSCAN_CLI_SOURCE:-}" ]] && _cli_ok python3; then - PYTHON_BIN="python3" -elif _is_container; then - echo "[{{ cookiecutter.module_slug }}] This container lacks holoscan-cli" \ - "${HOLOSCAN_CLI_PINNED_VERSION:+==${HOLOSCAN_CLI_PINNED_VERSION}}; rebuild the image." >&2 - exit 1 -else - _use_managed_venv -fi - -if ! _cli_ok "${PYTHON_BIN}"; then - if ! "${PYTHON_BIN}" -m pip --version >/dev/null 2>&1; then - "${PYTHON_BIN}" -m ensurepip --upgrade >/dev/null 2>&1 || { - echo "[{{ cookiecutter.module_slug }}] ${PYTHON_BIN} has no pip and ensurepip is unavailable;" \ - "install pip or set HOLOSCAN_CLI_PYTHON_BIN to an interpreter that has it" \ - "and retry" >&2 - exit 1 - } - fi - if [[ -n "${HOLOSCAN_CLI_SOURCE:-}" ]]; then - echo "[{{ cookiecutter.module_slug }}] installing holoscan-cli from ${HOLOSCAN_CLI_SOURCE}" >&2 - "${PYTHON_BIN}" -m pip install --upgrade "${HOLOSCAN_CLI_SOURCE}" - else - echo "[{{ cookiecutter.module_slug }}] installing with args: ${HOLOSCAN_CLI_INSTALL_ARGS}" >&2 - "${PYTHON_BIN}" -m pip install ${HOLOSCAN_CLI_INSTALL_ARGS} \ - ${HOLOSCAN_CLI_PINNED_VERSION:+"holoscan-cli==${HOLOSCAN_CLI_PINNED_VERSION}"} || { - _is_container && echo "[{{ cookiecutter.module_slug }}] Install failed in this" \ - "container; rebuild the image (./holohub build-container)." >&2 - exit 1 - } - fi -fi - -# Containers re-enter through this mounted wrapper so the pinned CLI version -# is verified at runtime. -export HOLOSCAN_CLI_IN_CONTAINER_CMD="${HOLOSCAN_CLI_IN_CONTAINER_CMD:-./${BASH_SOURCE[0]##*/}}" - -# exec so signals reach the CLI directly; `python -m` so the same interpreter -# that bootstrapped the CLI above runs it (a `holoscan` console script on PATH -# may resolve elsewhere). -exec "${PYTHON_BIN}" -m holoscan_cli "$@" diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/metadata.json b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/metadata.json index eaeae573..16a279c7 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/metadata.json +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/metadata.json @@ -23,7 +23,7 @@ "platforms": ["x86_64", "aarch64"], "tags": ["TODO: add tags"], "source_repository": "https://github.com/TODO/holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}", - "operator_names": ["{{ cookiecutter.operator_slug }}"], + "operator_names": ["{{ cookiecutter.operator_slug.split('_')|map('capitalize')|join('') }}"], "dockerfile": "Dockerfile", "binary_packages": { "debian": "holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}", diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/CMakeLists.txt index a7b30e01..41e07e49 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/CMakeLists.txt +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/CMakeLists.txt @@ -8,7 +8,7 @@ target_include_directories({{ cookiecutter.operator_slug }} PUBLIC $ $) -target_link_libraries({{ cookiecutter.operator_slug }} PRIVATE holoscan::core) +target_link_libraries({{ cookiecutter.operator_slug }} PUBLIC holoscan::core) install(FILES {{ cookiecutter.operator_slug }}.hpp DESTINATION include/{{ cookiecutter.operator_slug }}) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} index 87f722bd..30ab66f6 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} @@ -6,9 +6,9 @@ # - Fetches pybind11 at the version pinned to Holoscan SDK (so the same # FetchContent_Declare entry is shared if this module is consumed as a # dependency in HoloHub or another module). -# - Auto-links holoscan::core and holoscan::pybind11 (ABI-aligned with HSDK 3.3+). +# - Links holoscan::core and holoscan::pybind11 for SDK ABI compatibility. # - Generates ${HOLOHUB_PYTHON_MODULE_OUT_DIR}/{{ cookiecutter.operator_slug }}/__init__.py -# with helpful import-error diagnostics for ABI mismatches. +# with optional custom type registration. include(pybind11_add_holohub_module) pybind11_add_holohub_module( CPP_CMAKE_TARGET {{ cookiecutter.operator_slug }} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/CMakeLists.txt index 29d49aa9..04ce86b5 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/CMakeLists.txt +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/CMakeLists.txt @@ -2,7 +2,7 @@ # SPDX-License-Identifier: {{ cookiecutter._license }} # add_holohub_package() handles the full cascade for both standalone -# packaging (`./holohub package {{ cookiecutter.module_repo_name }} --pkg-generator DEB`, which +# packaging (`holoscan package {{ cookiecutter.module_repo_name }} --pkg-generator DEB`, which # sets -DPKG_{{ cookiecutter.module_repo_name.replace('-', '_') }}=ON via the HoloHub CLI) and in-tree # HoloHub-monorepo packaging once this module is fetched as an external # dependency: diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/README.md b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/README.md index dc23b3e0..a0c1cc61 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/README.md +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/README.md @@ -5,7 +5,7 @@ This directory defines the Debian package for `{{ cookiecutter.module_repo_name ## Usage ```bash -./holohub package {{ cookiecutter.module_repo_name }} --pkg-generator DEB +holoscan package {{ cookiecutter.module_repo_name }} --pkg-generator DEB ``` ## metadata.json @@ -14,10 +14,10 @@ This directory defines the Debian package for `{{ cookiecutter.module_repo_name - **`package` key** — marks this directory as a HoloHub *package* project. The CLI discovers it via the recursive `HOLOSCAN_CLI_SEARCH_PATH` scan from the module root, - which makes it appear under the `PACKAGES` section of `./holohub list`. + which makes it appear under the `PACKAGES` section of `holoscan list`. - **`package.dockerfile`** — declares a Dockerfile path (relative to the module root) for this package-project record. When packaging this generated module - by name, `./holohub package` instead selects the root `module` record and its + by name, `holoscan package` instead selects the root `module` record and its `module.dockerfile`. Looking for Python packaging? Review the project [pyproject.toml](../../pyproject.toml) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pyproject.toml b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pyproject.toml index fd712bcb..39985b9c 100644 --- a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pyproject.toml +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pyproject.toml @@ -20,6 +20,20 @@ requires-python = ">=3.10" # wheel cannot guarantee binary compatibility across CUDA variants (cu12/cu13). # Document the install command in the README. +[dependency-groups] +dev = [ + "holoscan-cli=={{ cookiecutter._holoscan_cli_version }}", + "pytest>=8.2", +] + +[tool.uv.sources] +holoscan-cli = { index = "nvidia" } + +[[tool.uv.index]] +name = "nvidia" +url = "https://pypi.nvidia.com" +explicit = true + [tool.scikit-build] cmake.version = ">=3.24" # Build only what the wheel needs: turn off test and app subprojects, and @@ -37,16 +51,14 @@ wheel.packages = [] [tool.ruff] line-length = 100 target-version = "py310" -# The post-gen hook copies cmake/pybind11/ into this tree from the HoloHub -# clone; build*/, dist/ are CMake/wheel outputs; .local/, .cache/, .cupy/ -# are dev-container HOME junk. +# build*/, dist/ are CMake/wheel outputs; .local/, .cache/, .cupy/ are +# dev-container HOME junk. exclude = [ "build*", "dist", ".cache", ".local", ".cupy", - "cmake/pybind11", ] [tool.ruff.lint] diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/requirements-cli.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/requirements-cli.txt new file mode 100644 index 00000000..c7ee707b --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/requirements-cli.txt @@ -0,0 +1,3 @@ +# Generated by holoscan create; update deliberately and rebuild the development image. +# Install with: python -m pip install --extra-index-url https://pypi.nvidia.com -r requirements-cli.txt +holoscan-cli=={{ cookiecutter._holoscan_cli_version }} diff --git a/src/holoscan_cli/testing/container.ctest b/src/holoscan_cli/testing/container.ctest index 6470326f..f8ce3713 100644 --- a/src/holoscan_cli/testing/container.ctest +++ b/src/holoscan_cli/testing/container.ctest @@ -72,7 +72,9 @@ if(NOT TAG) set(TAG latest) endif() -get_filename_component(CTEST_SOURCE_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +if(NOT CTEST_SOURCE_DIRECTORY) + get_filename_component(CTEST_SOURCE_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +endif() set(CTEST_BINARY_DIRECTORY "${CTEST_SOURCE_DIRECTORY}/build-${PROJ_NAME}") if(NOT CTEST_SITE) diff --git a/src/holoscan_cli/utils/env_info.py b/src/holoscan_cli/utils/env_info.py index 6cc334fe..b06e7da3 100644 --- a/src/holoscan_cli/utils/env_info.py +++ b/src/holoscan_cli/utils/env_info.py @@ -218,6 +218,7 @@ def collect_cuda_gpu_info() -> None: "HOLOSCAN_CLI_DATA_DIR", "HOLOSCAN_CLI_DEFAULT_HSDK_DIR", "HOLOSCAN_CLI_CTEST_SCRIPT", + "HOLOSCAN_CLI_CREATE_TEMPLATE", "HOLOSCAN_CLI_REPO_PREFIX", "HOLOSCAN_CLI_CONTAINER_PREFIX", "HOLOSCAN_CLI_WORKSPACE_NAME", diff --git a/src/holoscan_cli/utils/filesystem.py b/src/holoscan_cli/utils/filesystem.py new file mode 100644 index 00000000..5467b8e9 --- /dev/null +++ b/src/holoscan_cli/utils/filesystem.py @@ -0,0 +1,133 @@ +# 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. + +"""Small, reusable filesystem operations that never overwrite existing data.""" + +import os +import shutil +from collections.abc import Collection +from dataclasses import dataclass +from pathlib import Path + + +class DirectoryMaterializationError(RuntimeError): + """A directory cannot be safely populated without replacing existing data.""" + + +@dataclass(frozen=True) +class DirectorySnapshot: + """Names found in a missing, empty, or explicitly preserved directory.""" + + exists: bool + entries: tuple[str, ...] = () + + @property + def kind(self) -> str: + if not self.exists: + return "missing" + return "preserved-only" if self.entries else "empty" + + +def inspect_directory(path: Path, *, allowed_entries: Collection[str] = ()) -> DirectorySnapshot: + """Snapshot a missing or empty directory, preserving only named entries.""" + if path.is_symlink(): + raise DirectoryMaterializationError(f"Destination {path} is a symlink.") + if not path.exists(): + return DirectorySnapshot(exists=False) + if not path.is_dir(): + raise DirectoryMaterializationError(f"Destination {path} is not a directory.") + + try: + entries = sorted(path.iterdir(), key=lambda entry: entry.name) + except OSError as exc: + raise DirectoryMaterializationError(f"Could not inspect destination {path}: {exc}") from exc + + unexpected = [entry.name for entry in entries if entry.name not in allowed_entries] + if unexpected: + names = ", ".join(repr(name) for name in unexpected) + raise DirectoryMaterializationError( + f"Destination {path} is not empty; existing entries: {names}." + ) + + for entry in entries: + if entry.is_symlink() or not (entry.is_dir() or entry.is_file()): + raise DirectoryMaterializationError( + f"Preserved destination entry {entry} must be a real file or directory." + ) + return DirectorySnapshot(exists=True, entries=tuple(entry.name for entry in entries)) + + +def materialize_tree( + source: Path, + destination: Path, + expected: DirectorySnapshot, + *, + allowed_entries: Collection[str] = (), +) -> None: + """Copy a tree with no-replace operations and roll back paths created on failure.""" + try: + current = inspect_directory(destination, allowed_entries=allowed_entries) + except DirectoryMaterializationError as exc: + raise DirectoryMaterializationError( + f"Destination {destination} changed during generation; nothing was overwritten." + ) from exc + if current != expected: + raise DirectoryMaterializationError( + f"Destination {destination} changed during generation; nothing was overwritten." + ) + + created: list[Path] = [] + + def copy_directory(source_dir: Path, destination_dir: Path) -> None: + for source_path in sorted(source_dir.iterdir(), key=lambda item: item.name): + destination_path = destination_dir / source_path.name + if source_path.is_symlink(): + destination_path.symlink_to(os.readlink(source_path)) + created.append(destination_path) + elif source_path.is_dir(): + destination_path.mkdir() + created.append(destination_path) + copy_directory(source_path, destination_path) + shutil.copystat(source_path, destination_path, follow_symlinks=False) + elif source_path.is_file(): + with ( + source_path.open("rb") as source_file, + destination_path.open("xb") as destination_file, + ): + created.append(destination_path) + shutil.copyfileobj(source_file, destination_file) + shutil.copystat(source_path, destination_path, follow_symlinks=False) + else: + raise DirectoryMaterializationError( + f"Source tree contains unsupported filesystem entry {source_path}." + ) + + try: + if not expected.exists: + destination.mkdir() + created.append(destination) + copy_directory(source, destination) + except BaseException as exc: + for path in reversed(created): + try: + path.unlink() if path.is_symlink() or path.is_file() else path.rmdir() + except OSError: + # Never recursively remove a directory that another process may have changed. + pass + if isinstance(exc, (DirectoryMaterializationError, KeyboardInterrupt, SystemExit)): + raise + raise DirectoryMaterializationError( + f"Could not populate {destination} without overwriting existing data: {exc}" + ) from exc diff --git a/src/holoscan_cli/utils/holohub.py b/src/holoscan_cli/utils/holohub.py index d0807260..6f122cca 100644 --- a/src/holoscan_cli/utils/holohub.py +++ b/src/holoscan_cli/utils/holohub.py @@ -33,8 +33,9 @@ from pathlib import Path from typing import Mapping, Optional, Tuple +from holoscan_cli.project_context import SEARCH_DIRS, discover_project_context from holoscan_cli.utils.io import format_cmd, info, run_info_command, warn -from holoscan_cli.utils.text import _slugify, get_env_bool, is_env_flag_true +from holoscan_cli.utils.text import get_env_bool, is_env_flag_true, slugify DEFAULT_GIT_REF = "latest" @@ -88,62 +89,23 @@ def _get_holohub_root() -> Path: site-packages, root discovery must come from the wrapper environment or from the current working directory. """ - env_root = os.environ.get("HOLOSCAN_CLI_ROOT") - if env_root: - env_path = Path(env_root).expanduser() - if env_path.exists() and env_path.is_dir(): - return env_path - warn( - f"Environment variable HOLOSCAN_CLI_ROOT='{env_root}' is invalid. " - f"Falling back to default path: {Path(__file__).parent.parent.parent}" - ) - cwd = Path.cwd().resolve() - sentinel_files = ("holohub", "isaac_os", "i4h", "CMakeLists.txt", "Dockerfile") - metadata_dirs = ( - "applications", - "benchmarks", - "gxf_extensions", - "modules", - "operators", - "pkg", - "subgraphs", - "tutorials", - ) - for candidate in (cwd, *cwd.parents): - if (candidate / "src" / "holoscan_cli").is_dir() and ( - candidate / "pyproject.toml" - ).exists(): - return candidate - if any((candidate / name).exists() for name in sentinel_files): - if any((candidate / name).is_dir() for name in metadata_dirs): - return candidate - if any((candidate / name / "metadata.json").exists() for name in metadata_dirs): - return candidate - return cwd - - -HOLOHUB_ROOT = _get_holohub_root() + context = discover_project_context() + for message in context.warnings: + warn(message) + return context.root +@functools.lru_cache(maxsize=1) def get_holohub_root() -> Path: - """Return the cached source-project repo root.""" - return HOLOHUB_ROOT + """Discover and cache the source-project repo root on first use.""" + return _get_holohub_root() def get_component_search_paths(base_dir: Optional[Path] = None) -> tuple[Path, ...]: """Return metadata search paths honoring HOLOSCAN_CLI_SEARCH_PATH overrides.""" - base_path = base_dir or HOLOHUB_ROOT + base_path = base_dir or get_holohub_root() tokens = os.environ.get("HOLOSCAN_CLI_SEARCH_PATH", "").split(",") - default_paths = ( - "applications", - "benchmarks", - "gxf_extensions", - "modules", - "operators", - "pkg", - "tutorials", - ) - paths = [token.strip() for token in tokens if token.strip()] or default_paths + paths = [token.strip() for token in tokens if token.strip()] or SEARCH_DIRS return tuple( (Path(token) if Path(token).is_absolute() else base_path / token) for token in paths ) @@ -166,7 +128,7 @@ def get_holohub_setup_scripts_dir() -> Path: if explicit: return Path(explicit).expanduser() - repo_dir = HOLOHUB_ROOT / "utilities" / "setup" + repo_dir = get_holohub_root() / "utilities" / "setup" if repo_dir.is_dir(): return repo_dir @@ -366,7 +328,7 @@ def get_git_short_sha(length: int = 12) -> str: """Return the short git SHA for the source-project repo, or DEFAULT_GIT_REF on failure.""" try: sha = run_info_command( - ["git", "rev-parse", f"--short={length}", "HEAD"], cwd=str(HOLOHUB_ROOT) + ["git", "rev-parse", f"--short={length}", "HEAD"], cwd=str(get_holohub_root()) ) return sha or DEFAULT_GIT_REF except Exception: @@ -382,11 +344,11 @@ def get_current_branch_slug() -> str: """ try: branch = run_info_command( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=str(HOLOHUB_ROOT) + ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=str(get_holohub_root()) ) if not branch or branch in ["HEAD", "(no branch)"] or branch.startswith("(HEAD detached"): return DEFAULT_GIT_REF - return _slugify(branch) or DEFAULT_GIT_REF + return slugify(branch) or DEFAULT_GIT_REF except Exception: warn(f"Failed to get current branch, defaulting to {DEFAULT_GIT_REF}") return DEFAULT_GIT_REF diff --git a/src/holoscan_cli/utils/text.py b/src/holoscan_cli/utils/text.py index 75fa09bf..621054b5 100644 --- a/src/holoscan_cli/utils/text.py +++ b/src/holoscan_cli/utils/text.py @@ -23,6 +23,7 @@ import os import re import time +from collections.abc import Iterable from pathlib import Path from typing import List, Optional, Tuple @@ -46,7 +47,7 @@ def parse_semantic_version(version: str) -> Tuple[int, int, int]: # ---- string helpers ---------------------------------------------------------- -def _slugify(text: str, max_len: int = 63) -> str: +def slugify(text: str, max_len: int = 63) -> str: """Make a branch slug: lowercase, non-alnum to '-', trim dashes, max length.""" lowered = text.lower() replaced = re.sub(r"[^a-z0-9]+", "-", lowered) @@ -54,6 +55,22 @@ def _slugify(text: str, max_len: int = 63) -> str: return trimmed[:max_len] +def to_snake_case(text: str) -> str: + """Convert spaces and hyphens to a lowercase underscore-separated name.""" + return re.sub(r"[ -]+", "_", text.lower()) + + +def parse_key_value_pairs(values: Optional[Iterable[str]]) -> dict[str, str]: + """Parse repeated ``key=value`` arguments without interpreting their values.""" + result: dict[str, str] = {} + for item in values or (): + key, separator, value = item.partition("=") + if not separator or not key: + raise ValueError(f"{item!r}; expected key=value with a non-empty key") + result[key] = value + return result + + def levenshtein_distance(s1: str, s2: str) -> int: """Calculate the Levenshtein distance between two strings.""" s1 = s1.lower() diff --git a/tests/unit/test_create_module.py b/tests/unit/test_create_module.py index 29c097f5..98b2fe2a 100644 --- a/tests/unit/test_create_module.py +++ b/tests/unit/test_create_module.py @@ -1,36 +1,14 @@ # 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. - -"""Behavior tests for ``holoscan create`` against module templates. - -Exercises the module-template branch added in ``commands/create.py`` -without invoking cookiecutter or shell subprocesses: - -* ``--template`` paths whose first component is ``modules`` are detected - as module templates. -* Module templates require an explicit output ``--directory`` (prompted - if omitted) and use a kebab ``holoscan-`` output folder. -* The dryrun branch reports the correct intended directory and skips - the CMakeLists update. -* The next-steps message diverges between application and module - templates. -""" + +"""Focused coverage for standalone Module creation.""" from __future__ import annotations import argparse +import json +import shutil +import subprocess from pathlib import Path from types import SimpleNamespace @@ -39,136 +17,238 @@ from holoscan_cli.commands import create -def _make_args(**overrides) -> argparse.Namespace: - """Build a ``--dryrun``-shaped Namespace with sensible defaults.""" - defaults = dict( - project="My Mod", - template="modules/template", - language="python", - dryrun=True, - directory=None, - context=None, - interactive=False, - ) - defaults.update(overrides) - return argparse.Namespace(**defaults) +def _args(**overrides) -> argparse.Namespace: + values = { + "project": "My Mod", + "template": None, + "language": "python", + "dryrun": True, + "directory": None, + "context": None, + "interactive": False, + } + values.update(overrides) + return argparse.Namespace(**values) @pytest.fixture() -def fake_cli(tmp_path): - """Stand-in for the real ``HoloscanCLI`` — handle_create only reads - ``HOLOHUB_ROOT`` and ``script_name``.""" +def cli(tmp_path): return SimpleNamespace(HOLOHUB_ROOT=tmp_path, script_name="holoscan") -# ---- dryrun smoke ------------------------------------------------------------ +@pytest.fixture(autouse=True) +def installed_version(monkeypatch): + if create.__version__ == create.LOCAL_SOURCE_VERSION: + monkeypatch.setattr(create, "__version__", "5.0.0a9") -def test_dryrun_module_template_uses_kebab_output_folder(fake_cli, tmp_path, capsys): - out_dir = tmp_path / "ext" - out_dir.mkdir() - args = _make_args(directory=out_dir) - create.handle_create(fake_cli, args) +def _write_template(root: Path, relative: str, *, module: bool) -> Path: + template = root / relative + template.mkdir(parents=True) + context = {"project_name": "Example", "project_slug": "example"} + if module: + context.update( + module_slug="{{ cookiecutter.project_slug }}", + module_repo_name="holoscan-{{ cookiecutter.module_slug }}", + ) + (template / "cookiecutter.json").write_text(json.dumps(context), encoding="utf-8") + return template - captured = capsys.readouterr().out - # holoscan-my_mod -> the slug is "my_mod"; kebab swap gives holoscan-my-mod - assert str(out_dir / "holoscan-my-mod") in captured - # Module templates must NOT trigger the applications/CMakeLists.txt path. - assert "applications/CMakeLists.txt" not in captured +def test_direct_create_uses_packaged_template_inside_a_source_project( + cli, tmp_path, monkeypatch, capsys +): + monkeypatch.chdir(tmp_path) -def test_dryrun_application_template_uses_slug_output_folder(fake_cli, tmp_path, capsys): - # Make the default output directory exist so the existence check passes. - (tmp_path / "applications").mkdir() - args = _make_args(template="applications/template", dryrun=True) - create.handle_create(fake_cli, args) + create.handle_create(cli, _args()) + output = capsys.readouterr().out + assert "Template: packaged Module template" in output + assert f"Directory: {tmp_path / 'holoscan-my-mod'}" in output - captured = capsys.readouterr().out - assert str(tmp_path / "applications" / "my_mod") in captured - # Applications scaffolded under HOLOHUB_ROOT/applications/ trigger the - # CMakeLists hint. - assert "applications/CMakeLists.txt" in captured + _write_template(tmp_path, "applications/template", module=False) + create.handle_create(cli, _args()) + output = capsys.readouterr().out + assert "Template: packaged Module template" in output + assert f"Directory: {tmp_path / 'holoscan-my-mod'}" in output + assert "applications/CMakeLists.txt" not in output -def test_dryrun_omits_holoscan_version_when_not_configured(fake_cli, tmp_path, capsys, monkeypatch): - monkeypatch.setattr(create.HoloscanContainer, "BASE_SDK_VERSION", None, raising=False) - (tmp_path / "applications").mkdir() - args = _make_args(template="applications/template", dryrun=True) - create.handle_create(fake_cli, args) +def test_wrapper_default_selects_application_template(cli, tmp_path, monkeypatch, capsys): + application_template = _write_template(tmp_path, "applications/template", module=False) + monkeypatch.setenv(create.CREATE_TEMPLATE_ENV, "applications/template") - captured = capsys.readouterr().out - assert "holoscan_version" not in captured + create.handle_create(cli, _args()) + output = capsys.readouterr().out + assert f"Template: {application_template}" in output + assert f"Directory: {tmp_path / 'applications/my_mod'}" in output + assert "applications/CMakeLists.txt" in output -def test_module_template_prompts_for_directory_when_omitted( - fake_cli, tmp_path, capsys, monkeypatch -): - """When ``--directory`` is omitted for a module template, ``handle_create`` - prompts via ``input()``. The path the user provides is honored.""" - out_dir = tmp_path / "user-typed" - out_dir.mkdir() - monkeypatch.setattr("builtins.input", lambda _prompt="": str(out_dir)) - args = _make_args(directory=None) - create.handle_create(fake_cli, args) +def test_explicit_template_beats_the_wrapper_default(cli, tmp_path, monkeypatch, capsys): + _write_template(tmp_path, "applications/template", module=False) + selected = _write_template(tmp_path, "custom/module", module=True) + monkeypatch.setenv(create.CREATE_TEMPLATE_ENV, "applications/template") + + create.handle_create( + cli, + _args(template="custom/module", directory=tmp_path / "output"), + ) + + output = capsys.readouterr().out + assert f"Template: {selected}" in output + assert f"Directory: {tmp_path / 'output/holoscan-my-mod'}" in output - captured = capsys.readouterr().out - assert str(out_dir / "holoscan-my-mod") in captured + +def test_create_rejects_an_unsafe_destination_name(cli, tmp_path, capsys): + with pytest.raises(SystemExit): + create.handle_create( + cli, + _args(directory=tmp_path / "output", context=["module_repo_name=../escaped"]), + ) + + assert "one directory name" in capsys.readouterr().err + assert not (tmp_path / "escaped").exists() + + +def test_custom_module_template_gets_packaged_cmake(cli, tmp_path, monkeypatch): + template = _write_template(tmp_path, "custom/module", module=True) + output = tmp_path / "output" + destination = output / "holoscan-my-mod" + git_config = destination / ".git/config" + git_config.parent.mkdir(parents=True) + git_config.write_text("keep\n", encoding="utf-8") + + def generate(_cli, _template, **kwargs): + project = kwargs["output_dir"] / destination.name + (project / "cmake").mkdir(parents=True) + (project / "cmake/custom.cmake").write_text("# custom\n", encoding="utf-8") + return str(project) + + monkeypatch.setattr(create, "_run_cookiecutter", generate) + monkeypatch.setattr(create, "validate_generated_metadata", lambda *_args: None) + + create.handle_create( + cli, + _args(template=str(template), dryrun=False, directory=output), + ) + + assert git_config.read_text(encoding="utf-8") == "keep\n" + assert (destination / "cmake/custom.cmake").is_file() + assert (destination / "cmake/HoloHubConfigHelpers.cmake").is_file() -def test_module_template_empty_prompt_input_is_fatal(fake_cli, monkeypatch): - """An empty response to the directory prompt aborts.""" - monkeypatch.setattr("builtins.input", lambda _prompt="": "") - args = _make_args(directory=None) +def test_create_never_overwrites_an_existing_project(cli, tmp_path, monkeypatch): + destination = tmp_path / "output/holoscan-my-mod" + destination.mkdir(parents=True) + marker = destination / "keep.txt" + marker.write_text("keep\n", encoding="utf-8") + monkeypatch.setattr( + create, + "_run_cookiecutter", + lambda *_args, **_kwargs: pytest.fail("generation must not start"), + ) + with pytest.raises(SystemExit): - create.handle_create(fake_cli, args) + create.handle_create(cli, _args(dryrun=False, directory=destination.parent)) + + assert marker.read_text(encoding="utf-8") == "keep\n" -# ---- detection edge cases ---------------------------------------------------- +@pytest.mark.skipif(shutil.which("git") is None, reason="git is required") +def test_initialize_module_git_only_initializes_standalone_directory(tmp_path): + standalone = tmp_path / "standalone" + standalone.mkdir() + assert create._initialize_module_git(standalone) + assert (standalone / ".git").is_dir() + + repository = tmp_path / "repository" + repository.mkdir() + subprocess.run( + ["git", "init", "."], + cwd=repository, + check=True, + capture_output=True, + ) + nested = repository / "packages/module" + nested.mkdir(parents=True) + + assert not create._initialize_module_git(nested) + assert not (nested / ".git").exists() + + +def test_missing_cookiecutter_points_to_the_create_extra(cli, tmp_path, monkeypatch, capsys): + monkeypatch.setattr( + create.importlib, + "import_module", + lambda _name: (_ for _ in ()).throw(ImportError), + ) + + with pytest.raises(SystemExit): + create._run_cookiecutter( + cli, + tmp_path, + interactive=False, + context={}, + output_dir=tmp_path, + ) + + assert "pip install 'holoscan-cli[create]'" in capsys.readouterr().err @pytest.mark.parametrize( - "template,is_module", + "language,generated_source", [ - ("modules/template", True), - ("modules/foo/bar", True), - ("applications/template", False), - # Substring "modules" inside another segment must NOT match — the - # detection keys on full path parts. - ("my_modules_collection/template", False), - ("workflows/some-modules-thing", False), + ("python", "operators/my_mod_op/my_mod_op.py"), + ("cpp", "operators/my_mod_op/my_mod_op.cpp"), ], ) -def test_module_template_detection_keys_on_path_parts( - fake_cli, tmp_path, template, is_module, capsys +def test_packaged_template_creates_a_standalone_module( + cli, tmp_path, monkeypatch, language, generated_source ): - """The module-template detection must key on whole path segments, - not substrings. Otherwise paths like ``my_modules_collection/`` would - wrongly hit the module branch.""" - out_dir = tmp_path / "out" - out_dir.mkdir() - args = _make_args(template=template, directory=out_dir) - create.handle_create(fake_cli, args) - captured = capsys.readouterr().out - - if is_module: - assert str(out_dir / "holoscan-my-mod") in captured - else: - assert str(out_dir / "my_mod") in captured - - -# ---- parser surface ---------------------------------------------------------- - - -def test_directory_argument_defaults_to_none(): - """Module templates need ``--directory`` to default to ``None`` so the - handler can decide whether to prompt or fall back to ``applications/``. - Pinning this prevents an accidental revert to the old behaviour where - ``--directory`` defaulted eagerly to ``applications/`` (which made the - module-template prompt unreachable).""" + pytest.importorskip("cookiecutter") + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + monkeypatch.setattr(create, "_initialize_module_git", lambda _path: False) + + create.handle_create( + cli, + _args(language=language, dryrun=False, directory=tmp_path / "output"), + ) + + project = tmp_path / "output/holoscan-my-mod" + requirement = (project / "requirements-cli.txt").read_text(encoding="utf-8") + pyproject = (project / "pyproject.toml").read_text(encoding="utf-8") + dockerfile = (project / "Dockerfile").read_text(encoding="utf-8") + readme = (project / "README.md").read_text(encoding="utf-8") + active_requirements = [ + line for line in requirement.splitlines() if line and not line.startswith("#") + ] + + assert (project / generated_source).is_file() + assert (project / "cmake/HoloHubConfigHelpers.cmake").is_file() + assert active_requirements == [f"holoscan-cli=={create.__version__}"] + assert "--extra-index-url https://pypi.nvidia.com" in requirement + assert 'holoscan-cli = { index = "nvidia" }' in pyproject + assert 'url = "https://pypi.nvidia.com"' in pyproject + assert "explicit = true" in pyproject + assert "--extra-index-url https://pypi.nvidia.com" in dockerfile + assert "python3 -m venv .venv" in readme + assert "uv sync --only-dev" in readme + assert "uv run holoscan" not in readme + assert not (project / "holohub").exists() + assert not (project / "holoscan").exists() + + +def test_parser_leaves_template_and_directory_contextual(): parser = argparse.ArgumentParser() - cli_stub = SimpleNamespace(HOLOHUB_ROOT=Path("/dev/null"), script_name="holoscan") - sub = parser.add_subparsers() - create.register_create_parser(cli_stub, sub) - ns = parser.parse_args(["create", "MyProj"]) - assert ns.directory is None + subparsers = parser.add_subparsers() + create.register_create_parser( + SimpleNamespace(HOLOHUB_ROOT=Path("/unused"), script_name="holoscan"), + subparsers, + ) + + args = parser.parse_args(["create", "My Project"]) + + assert args.template is None + assert args.directory is None diff --git a/tests/unit/test_lifecycle_commands.py b/tests/unit/test_lifecycle_commands.py index f8b9f9ab..9312f1f9 100644 --- a/tests/unit/test_lifecycle_commands.py +++ b/tests/unit/test_lifecycle_commands.py @@ -562,6 +562,7 @@ def test_handle_test_container_adds_coverage_build_args_and_ctest_options(tmp_pa build_call = cli.container.build_calls[0] assert "--build-arg COVERAGE=ON" in build_call["build_args"] assert "coverage" in build_call["extra_scripts"] + assert "xvfb" not in build_call["extra_scripts"] run_call = cli.container.run_calls[0] ctest_command = run_call["extra_args"][1] assert run_call["docker_opts"] == "--entrypoint=bash" @@ -578,6 +579,35 @@ def test_handle_test_container_adds_coverage_build_args_and_ctest_options(tmp_pa # `--ctest-options` must propagate verbatim into the ctest invocation # (pre-consolidation `test_holohub_test_ctest_options`). assert "-DCASE=smoke" in ctest_command + assert '-DCTEST_SOURCE_DIRECTORY="$PWD"' in ctest_command + assert "command -v xvfb-run" not in ctest_command + assert "xvfb-run" not in ctest_command + + +def test_handle_test_container_detects_optional_xvfb(tmp_path): + cli = RecordingCLI(tmp_path) + args = _container_args( + coverage=False, + clear_cache=False, + no_xvfb=False, + site_name=None, + cdash_url=None, + platform_name=None, + cmake_options=None, + ctest_options=None, + ctest_script=None, + build_name_suffix=None, + ) + + test_cmd.handle_test(cli, args) + + assert cli.container.build_calls[0]["extra_scripts"] == [] + command = cli.container.run_calls[0]["extra_args"][1] + assert "command -v xvfb-run" in command + assert "xvfb_cmd='xvfb-run -a'" in command + assert "running tests without a virtual display" in command + assert "${xvfb_cmd} ctest" in command + assert '-DCTEST_SOURCE_DIRECTORY="$PWD"' in command def test_handle_test_local_runs_ctest_in_repo_with_environment(tmp_path, monkeypatch): @@ -603,7 +633,9 @@ def test_handle_test_local_runs_ctest_in_repo_with_environment(tmp_path, monkeyp command, kwargs = calls[0] assert command[0:2] == ["bash", "-c"] - assert "xvfb-run -a ctest" in command[2] + assert "command -v xvfb-run" in command[2] + assert "${xvfb_cmd} ctest" in command[2] + assert '-DCTEST_SOURCE_DIRECTORY="$PWD"' in command[2] assert "-DTAG=manual" in command[2] assert "-S local.ctest" in command[2] assert kwargs["dry_run"] is True diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 150dec01..f4b5134f 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -273,6 +273,22 @@ def test_main_rejects_removed_commands(self, argv, command, capsys): assert "Removed HAP/MAP commands are not available since holoscan v4.3.0" in err assert "holoscan-cli<=4.2.0 and holoscan<=4.2.0" in err + @pytest.mark.parametrize( + "argv", + [ + ["holoscan", "-l", "BOGUS", "version"], + ["holoscan", "--log-level=bogus", "list"], + ], + ) + def test_main_rejects_invalid_top_level_log_level(self, argv, capsys): + """The dispatcher strips this prefix form, so it owns argparse's choices check.""" + with patch("holoscan_cli.cli.main") as mock_project_main: + with pytest.raises(SystemExit) as excinfo: + main(argv) + mock_project_main.assert_not_called() + assert excinfo.value.code == 2 + assert "must be one of" in capsys.readouterr().err + def test_main_with_log_level(self): mock_args = MagicMock() mock_args.command = "version" diff --git a/tests/unit/test_module_template.py b/tests/unit/test_module_template.py new file mode 100644 index 00000000..ebc11b4a --- /dev/null +++ b/tests/unit/test_module_template.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Small contract checks for static files copied into generated Modules.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] / "src/holoscan_cli" +TEMPLATE = ROOT / "templates/module" +CMAKE = ROOT / "cmake" + + +def _load_gitutils(): + path = TEMPLATE / "{{cookiecutter.module_repo_name}}/.github/workflows/scripts/gitutils.py" + spec = importlib.util.spec_from_file_location("generated_gitutils", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_cmake_support_keeps_export_and_pybind_contracts(): + operator = ( + TEMPLATE + / "{{cookiecutter.module_repo_name}}/operators" + / "{{cookiecutter.operator_slug}}/CMakeLists.txt" + ).read_text(encoding="utf-8") + config = (CMAKE / "Config.cmake.in").read_text(encoding="utf-8") + pybind = (CMAKE / "pybind11_add_holohub_module.cmake").read_text(encoding="utf-8") + deb = (CMAKE / "holohub_configure_deb.cmake").read_text(encoding="utf-8") + initializer = (CMAKE / "pybind11/__init__.py.in").read_text(encoding="utf-8") + + assert "PUBLIC holoscan::core" in operator + assert "find_dependency(holoscan REQUIRED COMPONENTS core)" in config + assert "holoscan::pybind11" in pybind + assert "${CMAKE_SUBMODULE_OUT_DIR}" in pybind + assert "${CMAKE_BINARY_DIR}/${HOLOSCAN_INSTALL_LIB_DIR}" in pybind + assert 'set(missingArgs "")' in deb + compile( + initializer.replace("@MODULE_NAME@", "example").replace("@MODULE_CLASS_NAME@", "ExampleOp"), + "cmake/pybind11/__init__.py", + "exec", + ) + + +def test_generated_gitutils_handles_untracked_files_and_safe_revisions(monkeypatch): + gitutils = _load_gitutils() + calls = [] + + def git(*args): + calls.append(args) + if args[0] == "status": + return " M tracked.py\0?? new file.py\0R renamed.py\0old.py\0" + if args[0] == "rev-parse": + return f"{args[-1]}-sha" + return "first.py\nsecond.py" + + monkeypatch.setattr(gitutils, "__git", git) + + assert gitutils.uncommitted_files() == ["tracked.py", "new file.py", "renamed.py"] + assert gitutils.changed_files_between("base", "head") == ["first.py", "second.py"] + assert any("--end-of-options" in call for call in calls) + with pytest.raises(ValueError, match="Invalid Git revision"): + gitutils.changed_files_between("--unsafe", "head") + + +def test_direct_cookiecutter_use_cannot_pin_an_arbitrary_cli_release(): + defaults = json.loads((TEMPLATE / "cookiecutter.json").read_text(encoding="utf-8")) + assert defaults["_holoscan_cli_version"] == "0" diff --git a/tests/unit/test_package_data.py b/tests/unit/test_package_data.py index ba48fdcf..d0374ad1 100644 --- a/tests/unit/test_package_data.py +++ b/tests/unit/test_package_data.py @@ -18,7 +18,8 @@ These prevent regressions where ``pyproject.toml`` accidentally drops the files an installed ``holoscan-cli`` wheel must ship: the ``py.typed`` marker, the project metadata JSON schemas under ``holoscan_cli.metadata``, -the logging configuration, and the CTest scripts under ``holoscan_cli.testing``. +the logging configuration, CMake support copied into generated Modules, and +the CTest scripts under ``holoscan_cli.testing``. The tests also pin the public ``holoscan`` console script entry point and the ``holoscan-cli`` package-name tool-runner alias. @@ -72,13 +73,25 @@ "requirements.template.txt", } +REQUIRED_CMAKE_FILES = { + "Config.cmake.in", + "HoloHubConfigHelpers.cmake", + "holohub_configure_deb.cmake", + "pybind11_add_holohub_module.cmake", + "pybind11/__init__.py.in", + "pydoc/macros.hpp", +} + REQUIRED_MODULE_TEMPLATE_FILES = { "cookiecutter.json", + "hooks/pre_gen_project.py", "hooks/post_gen_project.py", - "{{cookiecutter.module_repo_name}}/holohub", + "{{cookiecutter.module_repo_name}}/requirements-cli.txt", + "{{cookiecutter.module_repo_name}}/.dockerignore", "{{cookiecutter.module_repo_name}}/Dockerfile", "{{cookiecutter.module_repo_name}}/CMakeLists.txt", "{{cookiecutter.module_repo_name}}/metadata.json", + "{{cookiecutter.module_repo_name}}/.github/workflows/scripts/check_copyright.py", "{{cookiecutter.module_repo_name}}/.github/workflows/ci.yml", } @@ -147,10 +160,21 @@ def test_setup_scripts_are_packaged(): } missing = REQUIRED_SETUP_SCRIPTS - files assert not missing, f"missing bundled setup scripts: {missing}" + dockerfile = importlib.resources.files("holoscan_cli.setup_scripts").joinpath("Dockerfile.util") + assert "FROM ${BASE_IMAGE:-ubuntu:24.04} AS base" in dockerfile.read_text(encoding="utf-8") + + +def test_cmake_support_is_packaged(): + cmake = importlib.resources.files("holoscan_cli").joinpath("cmake") + missing = [ + relative + for relative in sorted(REQUIRED_CMAKE_FILES) + if not cmake.joinpath(relative).is_file() + ] + assert not missing, f"missing bundled CMake support: {missing}" -def test_holohub_module_template_snapshot_is_packaged(): - """The ownership snapshot must remain available as installed package data.""" +def test_standalone_module_template_is_packaged(): template = importlib.resources.files("holoscan_cli.templates").joinpath("module") missing = [ relative @@ -158,6 +182,8 @@ def test_holohub_module_template_snapshot_is_packaged(): if not template.joinpath(relative).is_file() ] assert not missing, f"missing bundled Module template assets: {missing}" + assert not template.joinpath("{{cookiecutter.module_repo_name}}/cmake").exists() + assert not template.joinpath("{{cookiecutter.module_repo_name}}/holohub").exists() def test_bundled_template_script_uses_bundled_requirements(tmp_path): @@ -174,7 +200,7 @@ def test_bundled_template_script_uses_bundled_requirements(tmp_path): args_file = tmp_path / "python-args.txt" fake_python = bin_dir / "python3" fake_python.write_text( - "#!/usr/bin/env bash\n" 'printf \'%s\\n\' "$@" > "${PYTHON_ARGS_FILE}"\n', + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$@" > "${PYTHON_ARGS_FILE}"\n', encoding="utf-8", ) fake_python.chmod(0o755) @@ -310,9 +336,8 @@ def test_pyproject_create_extra_bundles_validator_deps(): The fatal in ``commands/create.py::validate_generated_metadata`` instructs users to install this extra when ``jsonschema`` / ``referencing`` are - missing, and ``commands/create.py::run_create`` does the same for - ``cookiecutter``, so the contract here is part of the user-facing install - story. + missing, while Module generation also needs ``cookiecutter``. The + dependency set is therefore part of the user-facing install story. """ extras = _pyproject()["project"].get("optional-dependencies", {}) assert "create" in extras, sorted(extras) diff --git a/tests/unit/test_project_context.py b/tests/unit/test_project_context.py new file mode 100644 index 00000000..58b551a8 --- /dev/null +++ b/tests/unit/test_project_context.py @@ -0,0 +1,160 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from holoscan_cli.project_context import activate_project_context, discover_project_context + + +def _module(root: Path, *, full_layout: bool = True) -> Path: + root.mkdir(parents=True) + metadata = { + "module": { + "name": "holoscan-my-sensor", + "namespace": {"python": "holoscan.my_sensor"}, + "holoscan_sdk": {"minimum_required_version": "4.6.0"}, + } + } + (root / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + if full_layout: + (root / "applications").mkdir() + return root + + +def _subprocess_env() -> dict[str, str]: + source = Path(__file__).resolve().parents[2] / "src" + environment = { + name: value for name, value in os.environ.items() if not name.startswith("HOLOSCAN_CLI_") + } + environment["PYTHONPATH"] = str(source) + return environment + + +def test_module_discovery_and_activation(tmp_path, monkeypatch): + root = _module(tmp_path / "module") + (root / "holohub").write_text("#!/bin/sh\n", encoding="utf-8") + descendant = root / "applications/pipeline/python" + descendant.mkdir(parents=True) + + context = discover_project_context(cwd=descendant, environ={}) + + assert context.root == root + assert context.repo_prefix == "my_sensor" + assert context.base_sdk_version == "4.6.0" + + monkeypatch.setattr(os, "environ", os.environ.copy()) + monkeypatch.setenv("HOLOSCAN_CLI_DATA_DIR", "/custom/data") + monkeypatch.delenv("HOLOSCAN_CLI_ROOT", raising=False) + activate_project_context(context) + + assert os.environ["HOLOSCAN_CLI_ROOT"] == str(root) + assert os.environ["HOLOSCAN_CLI_BUILD_PARENT_DIR"] == str(root / "build") + assert os.environ["HOLOSCAN_CLI_DATA_DIR"] == "/custom/data" + assert os.environ["HOLOSCAN_CLI_REPO_PREFIX"] == "my_sensor" + assert os.environ["HOLOSCAN_CLI_CONTAINER_PREFIX"] == "my-sensor" + + +def test_implicit_discovery_tolerates_malformed_module_metadata(tmp_path): + root = tmp_path / "module" + descendant = root / "nested/deep" + descendant.mkdir(parents=True) + (root / "metadata.json").write_text("{not json", encoding="utf-8") + + context = discover_project_context(cwd=descendant, environ={}) + + assert context.root == root + assert context.repo_prefix is None + assert context.base_sdk_version is None + assert len(context.warnings) == 1 + assert "Invalid Module metadata" in context.warnings[0] + + +def test_source_project_precedes_nested_module(tmp_path): + source_root = tmp_path / "holohub" + app = source_root / "applications/example" + app.mkdir(parents=True) + (app / "metadata.json").write_text("{}\n", encoding="utf-8") + module = _module(source_root / "modules/holoscan-my-sensor", full_layout=False) + + assert discover_project_context(cwd=module, environ={}).root == source_root + assert discover_project_context(explicit_root=module, environ={}).root == module + + +def test_root_precedence_and_invalid_environment_fallback(tmp_path): + module = _module(tmp_path / "module") + selected = tmp_path / "selected" + selected.mkdir() + + explicit = discover_project_context( + cwd=module, + explicit_root=selected, + environ={"HOLOSCAN_CLI_ROOT": str(module)}, + ) + environment = discover_project_context( + cwd=module, + environ={"HOLOSCAN_CLI_ROOT": str(selected)}, + ) + fallback = discover_project_context( + cwd=module, + environ={"HOLOSCAN_CLI_ROOT": str(tmp_path / "missing")}, + ) + + assert explicit.root == selected + assert environment.root == selected + assert fallback.root == module + assert fallback.warnings + + +def test_lightweight_import_does_not_load_project_cli(tmp_path): + script = """ +import sys +import holoscan_cli.__main__ +import holoscan_cli.commands.registry +import holoscan_cli.utils.holohub +blocked = ('holoscan_cli.cli', 'holoscan_cli.container.core') +assert not any(name in sys.modules for name in blocked) +""" + environment = _subprocess_env() + environment["HOLOSCAN_CLI_ROOT"] = str(tmp_path / "missing") + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == result.stderr == "" + + +def test_dispatch_activates_module_without_a_runtime_version_gate(tmp_path): + root = _module(tmp_path / "module") + + result = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + "--project-root", + str(root), + "list", + "--json", + ], + cwd=tmp_path, + env=_subprocess_env(), + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + projects = json.loads(result.stdout)["projects"] + module = next(project for project in projects if project["project_type"] == "module") + assert module["source_folder"] == str(root)