diff --git a/.github/CI.md b/.github/CI.md index 3eff310..50727a8 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -38,7 +38,7 @@ surface is exercised before merge. Jobs run in this order: | `test` matrix | `poetry run pytest` on Python 3.10, 3.11, 3.12, and 3.13 (Ubuntu). | | `HoloHub project integration` | Test current CLI against HoloHub's real project tree and wrapper suite. | | `build wheel + sdist` | `poetry build` + `twine check` + `assert_wheel_contents.sh`. | -| `installed artifact smoke` | Test clean wheel and sdist installs, the `create` extra, uvx, and pipx. | +| `installed artifact smoke` | Test clean installs, installed-wheel Module creation, uvx, and pipx. | | `CPU CLI + Docker smoke test` | Installed-wheel source-project dry-runs plus a tiny CPU Docker build. | The 3.12 `test` entry uploads coverage to Coveralls; the other matrix entries @@ -76,6 +76,8 @@ Pipeline: RC dispatches do not leave stray refs. 3. **`smoke-test`** — runs `scripts/smoke_test.sh` against clean installs of both the wheel and sdist, and verifies the wheel's `create` extra resolves. + Normal push/PR CI also creates one Module from that installed wheel and + checks its exact CLI requirement and launcher-free scaffold. 4. **`publish-test-pypi`** — runs for both GA and non-GA dispatches. Publishes via PyPA's trusted-publisher action (`pypa/gh-action-pypi-publish@release/v1`), no API token. Trust is @@ -265,10 +267,13 @@ each pattern in two lists: * `holoscan_cli/metadata/*.schema.json` * `holoscan_cli/setup_scripts/*` * `holoscan_cli/testing/` + * the packaged Module template, including `requirements-cli.txt`, + `.dockerignore`, and the retained local wheelhouse * **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`) + * a generated Module-root `holohub` launcher 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 0751376..cd88432 100755 --- a/.github/scripts/assert_wheel_contents.sh +++ b/.github/scripts/assert_wheel_contents.sh @@ -26,6 +26,10 @@ required=( 'holoscan_cli/setup_scripts/.+' 'holoscan_cli/setup_scripts/requirements\.template\.txt$' 'holoscan_cli/testing/' + 'holoscan_cli/templates/module/cookiecutter\.json$' + 'holoscan_cli/templates/module/.+/requirements-cli\.txt$' + 'holoscan_cli/templates/module/.+/\.dockerignore$' + 'holoscan_cli/templates/module/.+/\.holoscan-cli-wheelhouse/\.gitignore$' ) for pattern in "${required[@]}"; do if ! echo "$listing" | grep -qE "$pattern"; then @@ -37,6 +41,7 @@ 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 6ec766f..4caeb15 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -249,7 +249,24 @@ jobs: wheel=$(find dist -name 'holoscan_cli-*.whl' | head -n1) /tmp/holoscan-cli-smoke/bin/pip install "${wheel}[create]" /tmp/holoscan-cli-smoke/bin/python -c \ - 'import cookiecutter, jsonschema, referencing' + 'import cookiecutter, jsonschema, packaging, referencing' + create_root=$(mktemp -d) + ( + cd "$create_root" + /tmp/holoscan-cli-smoke/bin/holoscan create "Artifact Smoke" --interactive false + ) + module_root="$create_root/holoscan-artifact-smoke" + installed_version=$(/tmp/holoscan-cli-smoke/bin/python -c \ + 'from importlib.metadata import version; print(version("holoscan-cli"))') + 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" + test ! -e "$module_root/holohub" + test ! -e "$module_root/holoscan" + test -f "$module_root/CMakeLists.txt" + test -f "$module_root/applications/artifact_smoke_pipeline/python/metadata.json" - name: Install sdist in clean venv run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbc1c5b..d0e464f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,11 @@ # HoloHub-derived lint baseline for the consolidated Holoscan CLI. # Run `pre-commit autoupdate` to refresh to latest compatible versions. +# +# The cookiecutter output tree contains Jinja in source filenames and file +# bodies, so raw Python/JSON/YAML linters cannot parse it. Generated Python and +# metadata are exercised by tests/unit/test_create_module.py instead. +exclude: '^src/holoscan_cli/templates/module/\{\{cookiecutter\.module_repo_name\}\}/' repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/README.md b/README.md index b7eed29..0f1ecd3 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,14 @@ 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_CREATE_TEMPLATE` (a +wrapper-selected default overridden by `create --template`), +`HOLOSCAN_CLI_PATH_PREFIX` (placeholder prefix in metadata templates), and +`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. ## JSON output @@ -50,6 +57,7 @@ src/holoscan_cli/ setup_scripts/ bundled bash scripts backing `setup --scripts` and `build-container --extra-scripts` metadata/ project metadata JSON schemas + templates/module/ self-contained standalone Module cookiecutter testing/ CTest helpers shipped in the wheel ``` @@ -64,6 +72,32 @@ pip install holoscan-cli holoscan --help ``` +To scaffold a standalone Holoscan Module, install the optional creation +dependencies and run `create` from the directory that should contain the new +repository: + +```bash +pip install 'holoscan-cli[create]' +holoscan create my-sensor +``` + +This creates `./holoscan-my-sensor` from the standard Module template bundled +with the package. Use `--directory ` to select another output parent or +`--template ` to use an explicit cookiecutter template. The generated +repository contains an exact `requirements-cli.txt` contract and uses the +environment's global `holoscan` command for build, run, test, install, and +package operations. It does not contain a local launcher or require a HoloHub +clone. + +An existing empty destination, or a cloned repository containing only `.git`, +can also be populated without overwriting Git state. Because `--directory` +names the output parent, run this from inside a pre-cloned +`holoscan-my-sensor` repository: + +```bash +holoscan create "My Sensor" --directory .. +``` + For transient use without keeping an installed environment, package-name based tool runners can use the compatibility alias: diff --git a/pyproject.toml b/pyproject.toml index 3195cca..c349e4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ create = [ "jsonschema (>=4.18,<5.0)", "referencing (>=0.30)", "cookiecutter (>=2.7.1)", + "packaging (>=23.0)", ] [project.urls] @@ -78,6 +79,7 @@ packages = [{ include = "holoscan_cli", from = "src" }] include = [ { 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"] }, { path = "src/holoscan_cli/testing/**/*", format = ["sdist", "wheel"] }, ] version = "0.0.0" @@ -99,8 +101,10 @@ 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" +packaging = ">=23.0" [tool.poetry.requires-plugins] poetry-dynamic-versioning = { version = ">=1.5.0,<2.0.0", extras = ["plugin"] } @@ -118,16 +122,24 @@ quiet-level = 3 profile = "black" line_length = 100 known_first_party = "holoscan_cli" -skip_glob = ["build*/*", "dist/*", ".cache/*", ".ruff_cache/*"] +skip_glob = [ + "build*/*", + "dist/*", + ".cache/*", + ".ruff_cache/*", + "src/holoscan_cli/templates/module/**", +] [tool.black] line-length = 100 target-version = ["py310", "py311", "py312", "py313"] +force-exclude = 'src/holoscan_cli/templates/module/' extend-exclude = ''' ( ^\.cache/ | ^build[^/]*/ | ^dist/ + | ^src/holoscan_cli/templates/module/ | tests/reports/ ) ''' @@ -141,6 +153,7 @@ exclude = [ ".ruff_cache", "build*", "dist", + "src/holoscan_cli/templates/module", "tests/reports", ] diff --git a/src/holoscan_cli/__main__.py b/src/holoscan_cli/__main__.py index ff53597..f90abc3 100644 --- a/src/holoscan_cli/__main__.py +++ b/src/holoscan_cli/__main__.py @@ -22,6 +22,14 @@ from typing import Optional, Union from .commands.registry import project_command_help +from .project_context import ( + ProjectContextError, + ProjectVersionError, + activate_project_context, + discover_project_context, + enforce_project_requirement, + set_active_project_context, +) logging.getLogger("docker.api.build").setLevel(logging.WARNING) logging.getLogger("docker.auth").setLevel(logging.WARNING) @@ -59,6 +67,10 @@ ) +class DispatchUsageError(ValueError): + """A top-level option is missing, duplicated, or placed after a command.""" + + def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: if argv is None: argv = sys.argv @@ -97,6 +109,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,15 +175,20 @@ 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): + if arg in {"-l", "--log-level"}: + if index + 1 >= len(argv): + raise DispatchUsageError(f"{arg} requires a logging level.") log_level = argv[index + 1].upper() index += 2 continue @@ -174,12 +196,41 @@ def _project_dispatch_argv(argv: list[str]) -> tuple[Optional[str], list[str], O log_level = arg.split("=", 1)[1].upper() index += 1 continue + if arg == "--project-root": + if project_root is not None: + raise DispatchUsageError("--project-root may be specified only once.") + if index + 1 >= len(argv): + raise DispatchUsageError("--project-root requires a directory path.") + project_root = argv[index + 1] + if ( + not project_root + or project_root.startswith("-") + or project_root in {*PROJECT_COMMANDS, "version"} + ): + raise DispatchUsageError("--project-root requires a non-empty directory path.") + index += 2 + continue + if arg.startswith("--project-root="): + if project_root is not None: + raise DispatchUsageError("--project-root may be specified only once.") + project_root = arg.split("=", 1)[1] + if not project_root: + raise DispatchUsageError("--project-root requires a non-empty directory path.") + index += 1 + continue project_argv.extend(argv[index:]) break command = project_argv[1] if len(project_argv) > 1 else None - return command, project_argv, log_level + 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(argv: list[str]) -> None: @@ -187,7 +238,7 @@ def _exit_if_removed_command(argv: list[str]) -> None: 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) + command, _, _, _ = _project_dispatch_argv(argv) if command is None or command not in REMOVED_COMMANDS: return program = _program_name(argv) @@ -202,10 +253,23 @@ def _exit_if_removed_command(argv: list[str]) -> None: def _dispatch_project_cli(argv: list[str]) -> bool: """Forward source-project commands to the ported project CLI.""" - command, project_argv, log_level = _project_dispatch_argv(argv) + command, project_argv, log_level, project_root = _project_dispatch_argv(argv) if command not in PROJECT_COMMANDS: return False + if command == "create" and project_root is None: + # Creation produces the Module contract and must not be controlled by + # an enclosing Module that merely happens to contain the current cwd. + set_active_project_context(None) + else: + context = discover_project_context(explicit_root=project_root) + for warning in context.warnings: + print(f"Warning: {warning}", file=sys.stderr) + activate_project_context(context) + help_requested = any(arg in {"-h", "--help"} for arg in project_argv[2:]) + if command not in {"create", "env-info"} and not help_requested: + enforce_project_requirement(context) + set_up_logging(log_level) from .cli import main as project_main @@ -219,16 +283,26 @@ def _dispatch(argv: Optional[list[str]]) -> None: argv = sys.argv argv = list(argv) + command, native_argv, prefix_log_level, project_root = _project_dispatch_argv(argv) + _exit_if_removed_command(argv) if _dispatch_project_cli(argv): return - args = parse_args(argv) + args = parse_args(native_argv) + if prefix_log_level is not None: + args.log_level = prefix_log_level + args.project_root = project_root set_up_logging(args.log_level) if args.command == "version" or args.show_version: + context = discover_project_context(explicit_root=project_root) + for warning in context.warnings: + print(f"Warning: {warning}", file=sys.stderr) + set_active_project_context(context) + args.project_context = context from .version.version import execute_version_command execute_version_command(args) @@ -237,6 +311,12 @@ def _dispatch(argv: Optional[list[str]]) -> None: def main(argv: Optional[list[str]] = None): try: _dispatch(argv) + except ProjectVersionError as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1) from None + 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/commands/create.py b/src/holoscan_cli/commands/create.py index 62e170f..9aaa1c4 100644 --- a/src/holoscan_cli/commands/create.py +++ b/src/holoscan_cli/commands/create.py @@ -17,29 +17,57 @@ import argparse import datetime +import importlib +import importlib.resources import json +import os +import shutil +import stat +import subprocess +import tempfile +from contextlib import AbstractContextManager +from dataclasses import dataclass 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.io import Color, fatal +LEGACY_MODULE_TEMPLATE = Path("modules/template") +CREATE_TEMPLATE_ENV = "HOLOSCAN_CLI_CREATE_TEMPLATE" + + +@dataclass(frozen=True) +class _TargetState: + """Validated state of a prospective project destination.""" + + kind: str + git_identity: Optional[tuple[int, int, int, int, int]] = None + + +class _MaterializationError(RuntimeError): + """Staged output could not be copied without replacing an existing path.""" + 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 a different 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: the standard packaged Holoscan Module template)" + ), ) parser.add_argument( "--language", @@ -56,8 +84,8 @@ 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( @@ -84,6 +112,304 @@ def register_create_parser(cli, subparsers) -> argparse.ArgumentParser: # ---- private helpers --------------------------------------------------------- +def _packaged_module_template() -> AbstractContextManager[Path]: + """Materialize the bundled Module template as a filesystem directory.""" + template = importlib.resources.files("holoscan_cli.templates").joinpath("module") + return importlib.resources.as_file(template) + + +def _resolve_explicit_template(cli, value: str) -> Path: + """Resolve a caller/wrapper-selected template against the project root.""" + requested = Path(value).expanduser() + if not requested.is_absolute(): + requested = Path(cli.HOLOHUB_ROOT) / requested + return requested.resolve() + + +def _template_context(template_dir: Path) -> dict: + """Read the cookiecutter context used to classify a template.""" + context_path = template_dir / "cookiecutter.json" + try: + with context_path.open("r", encoding="utf-8") as context_file: + context = json.load(context_file) + 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(context, dict): + fatal(f"Template context {context_path} must contain a JSON object") + return context + + +def _is_module_template(template_context: dict) -> bool: + """Identify Module templates by their public cookiecutter variables.""" + return {"module_slug", "module_repo_name"}.issubset(template_context) + + +def _parse_extra_context(values: Optional[list[str]]) -> dict[str, str]: + context: dict[str, str] = {} + for ctx_var in values or []: + try: + key, value = ctx_var.split("=", 1) + except ValueError: + fatal(f"Invalid context variable format: {ctx_var}. Expected key=value") + context[key] = value + return context + + +def _project_slug(project_name: str) -> str: + return project_name.lower().replace(" ", "_").replace("-", "_") + + +def _output_folder(project: str, context: dict, is_module: bool) -> str: + """Predict cookiecutter's output folder for collision checks and dry runs.""" + if not is_module: + return str(context.get("project_slug") or _project_slug(project)) + + if context.get("module_repo_name"): + return str(context["module_repo_name"]) + module_slug = str( + context.get("module_slug") or _project_slug(str(context.get("project_name") or project)) + ) + return f"holoscan-{module_slug.replace('_', '-')}" + + +def _intended_project_dir(output_dir: Path, output_folder: str) -> Path: + """Require cookiecutter's output to be one direct child of the parent.""" + 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." + ) + return output_dir / folder + + +def _ensure_output_parent(output_dir: Path) -> None: + """Create the requested output parent or fail with a path-specific remedy.""" + 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." + ) + + +def _git_identity(path: Path) -> tuple[int, int, int, int, int]: + metadata = path.lstat() + return ( + metadata.st_dev, + metadata.st_ino, + stat.S_IFMT(metadata.st_mode), + metadata.st_size, + metadata.st_mtime_ns, + ) + + +def _inspect_target(path: Path, *, fatal_on_reject: bool = True) -> _TargetState: + """Accept only missing, empty, or real ``.git``-only destinations.""" + + def reject(message: str) -> None: + if fatal_on_reject: + fatal(message) + raise _MaterializationError(message) + + if path.is_symlink(): + reject( + f"Project destination {path} is a symlink and will not be populated. " + "Choose a real empty directory or a missing destination." + ) + if not path.exists(): + return _TargetState("missing") + if not path.is_dir(): + reject( + f"Project destination {path} is not a directory and will not be overwritten. " + "Choose another project name or --directory." + ) + + try: + entries = list(path.iterdir()) + except OSError as exc: + reject(f"Could not inspect project destination {path}: {exc}") + if not entries: + return _TargetState("empty") + if len(entries) == 1 and entries[0].name == ".git": + git_path = entries[0] + if git_path.is_symlink() or not (git_path.is_dir() or git_path.is_file()): + reject( + f"Project destination {path} contains an unsafe .git entry and will not be " + "populated. Use a real Git directory or worktree pointer file." + ) + return _TargetState("git-only", _git_identity(git_path)) + + reject( + f"Project directory {path} is non-empty and will not be overwritten. " + "Only an empty directory or a directory containing only .git can be populated." + ) + raise AssertionError("fatal() returned unexpectedly") # pragma: no cover + + +def _remove_created_paths(paths: list[Path]) -> None: + """Best-effort rollback that never recursively deletes destination data.""" + for path in reversed(paths): + try: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + path.rmdir() + except OSError: + # A concurrent writer may have placed data in a directory we made. + # Leaving it intact is safer than recursively deleting it. + continue + + +def _copy_staged_tree(source: Path, destination: Path, created: list[Path]) -> None: + """Copy one staged directory tree using no-replace filesystem operations.""" + for source_path in sorted(source.iterdir(), key=lambda path: path.name): + destination_path = destination / source_path.name + try: + 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_staged_tree(source_path, destination_path, created) + 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 _MaterializationError( + f"Generated project contains unsupported filesystem entry {source_path}." + ) + except FileExistsError as exc: + raise _MaterializationError( + f"Destination path appeared while creating the project: {destination_path}. " + "Nothing was overwritten." + ) from exc + except OSError as exc: + raise _MaterializationError( + f"Could not materialize {destination_path} without overwrite: {exc}" + ) from exc + + +def _materialize_staged_project( + staged_project: Path, destination: Path, initial_state: _TargetState +) -> None: + """Populate a validated destination and roll back files created on failure.""" + try: + current_state = _inspect_target(destination, fatal_on_reject=False) + except _MaterializationError as exc: + raise _MaterializationError( + f"Project destination {destination} changed during generation; nothing was " + "overwritten." + ) from exc + if current_state != initial_state: + raise _MaterializationError( + f"Project destination {destination} changed during generation; nothing was overwritten." + ) + + created: list[Path] = [] + try: + if initial_state.kind == "missing": + try: + destination.mkdir() + except FileExistsError as exc: + raise _MaterializationError( + f"Project destination {destination} appeared during generation; " + "nothing was overwritten." + ) from exc + created.append(destination) + _copy_staged_tree(staged_project, destination, created) + except BaseException: + _remove_created_paths(created) + raise + + +def _initialize_module_git(project_dir: Path) -> bool: + """Initialize and stage a new Module without touching pre-existing Git state.""" + if (project_dir / ".git").exists() or (project_dir / ".git").is_symlink(): + return False + try: + 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 _is_prerelease(version: str) -> bool: + """Classify the executing version while keeping ``packaging`` create-only.""" + try: + packaging_version = importlib.import_module("packaging.version") + except ImportError: + fatal( + "Creating a Module requires the optional creation dependencies. " + "Install them with `pip install 'holoscan-cli[create]'`." + ) + try: + return bool(packaging_version.Version(version).is_prerelease) + except packaging_version.InvalidVersion: + fatal(f"The executing holoscan-cli version is not valid: {version!r}") + raise AssertionError("fatal() returned unexpectedly") # pragma: no cover + + +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_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: + return cookiecutter_main.cookiecutter( + str(template_dir), + no_input=not interactive, + extra_context=context, + output_dir=str(output_dir), + ) + except Exception as exc: + fatal(f"Failed to create project from template {template_dir} " f"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 +428,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 @@ -137,136 +465,160 @@ def validate_generated_metadata(cli, metadata_path: Path, schema_root: Optional[ 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 + """Scaffold a project from the packaged or caller-selected template.""" + selected_template = args.template or os.environ.get(CREATE_TEMPLATE_ENV) + explicit_template: Optional[Path] = None + use_packaged_template = selected_template is None + + if selected_template: + explicit_template = _resolve_explicit_template(cli, selected_template) + if Path(selected_template) == LEGACY_MODULE_TEMPLATE and not explicit_template.exists(): + use_packaged_template = True + elif not explicit_template.is_dir(): + fatal( + f"Template directory {explicit_template} does not exist or is not a directory. " + "Choose an existing --template path and retry." + ) + + template_manager = ( + _packaged_module_template() if use_packaged_template else _PathContext(explicit_template) ) + with template_manager as template_dir: + template_dir = Path(template_dir) + template_defaults = _template_context(template_dir) + is_module = _is_module_template(template_defaults) + + context = { + "project_name": args.project, + "project_slug": _project_slug(args.project), + "language": args.language.lower() if args.language else None, + "year": datetime.datetime.now().year, + "_holoscan_cli_version": __version__, + "_holoscan_cli_prerelease": _is_prerelease(__version__) if is_module else False, + } + if HoloscanContainer.BASE_SDK_VERSION: + context["holoscan_version"] = HoloscanContainer.BASE_SDK_VERSION + context.update(_parse_extra_context(args.context)) + + if args.directory is None: + output_dir = ( + Path.cwd().resolve() + if is_module + else (Path(cli.HOLOHUB_ROOT) / "applications").resolve() + ) + else: + output_dir = Path(args.directory).expanduser().resolve() + + output_folder = _output_folder(args.project, context, is_module) + intended_dir = _intended_project_dir(output_dir, output_folder) + target_state = _inspect_target(intended_dir) + + 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: + print(f"Destination: would populate an existing {target_state.kind} directory") + for key, value in context.items(): + print(f" {key}: {value}") + if not is_module and output_dir == Path(cli.HOLOHUB_ROOT) / "applications": + print(Color.green("Would modify `applications/CMakeLists.txt`: ")) + print(f" add_holohub_application({_project_slug(args.project)})") + return + + _ensure_output_parent(output_dir) + 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 or actual_slug != output_folder: + fatal( + f"Template generated an unexpected project directory: {staged_project} " + f"(expected {staging_root / output_folder})" + ) + + 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) - # 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" - 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." + _materialize_staged_project(staged_project, intended_dir, target_state) + except _MaterializationError 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 not is_module and output_dir == (Path(cli.HOLOHUB_ROOT) / "applications").resolve(): + _add_to_cmakelists(cli, actual_slug) + + git_initialized = False + if is_module and target_state.kind != "git-only": + git_initialized = _initialize_module_git(project_dir) + + msg_next = "" + 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"- Build and test with: holoscan run-container\n" + ) + elif not is_module: + 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.") + ) - 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}" - ) +class _PathContext(AbstractContextManager[Path]): + """Context-manager adapter for an existing template directory.""" - print( - Color.green(f"Successfully created new project: {args.project}"), - f"\nDirectory: {project_dir}\n\n{msg_next}", - ) + def __init__(self, path: Optional[Path]): + if path is None: # pragma: no cover - guarded by handle_create + raise ValueError("template path is required") + self.path = path + + def __enter__(self) -> Path: + return self.path + + def __exit__(self, exc_type, exc_value, traceback) -> None: + return None diff --git a/src/holoscan_cli/commands/info.py b/src/holoscan_cli/commands/info.py index 34418b9..f8a0d99 100644 --- a/src/holoscan_cli/commands/info.py +++ b/src/holoscan_cli/commands/info.py @@ -35,7 +35,12 @@ from collections import defaultdict from holoscan_cli.commands.registry import help_for, project_command_names -from holoscan_cli.utils.env_info import collect_env_info, collect_git_info, collect_holohub_info +from holoscan_cli.utils.env_info import ( + collect_env_info, + collect_git_info, + collect_holohub_info, + collect_project_context_info, +) from holoscan_cli.utils.io import Color, format_cmd from holoscan_cli.utils.json_output import dumps as json_dumps @@ -228,6 +233,7 @@ def handle_env_info(cli, args: argparse.Namespace) -> None: data_dir=cli.DEFAULT_DATA_DIR, sdk_dir=cli.DEFAULT_SDK_DIR, ) + collect_project_context_info() collect_git_info(holohub_root=cli.HOLOHUB_ROOT) collect_env_info() print(format_cmd("Complete (Before sharing, please review and remove sensitive information)")) diff --git a/src/holoscan_cli/metadata/utils.py b/src/holoscan_cli/metadata/utils.py index 413db8b..a4b6f7d 100644 --- a/src/holoscan_cli/metadata/utils.py +++ b/src/holoscan_cli/metadata/utils.py @@ -116,10 +116,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 0000000..3a2c1ce --- /dev/null +++ b/src/holoscan_cli/project_context.py @@ -0,0 +1,511 @@ +# 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. + +"""Lightweight source-project discovery and standalone Module contracts. + +This module deliberately uses only the Python standard library. It is safe to +import from :mod:`holoscan_cli.__main__` before the project CLI and container +classes are imported; those classes still read several defaults at class-body +execution time. +""" + +from __future__ import annotations + +import importlib.metadata +import json +import os +import re +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Optional + +PACKAGE_NAME = "holoscan-cli" +REQUIREMENTS_FILENAME = "requirements-cli.txt" +MODULE_METADATA_FILENAME = "metadata.json" +MAX_REQUIREMENTS_BYTES = 64 * 1024 + +SENTINEL_FILES = ("holohub", "isaac_os", "i4h", "CMakeLists.txt", "Dockerfile") +METADATA_DIRS = ( + "applications", + "benchmarks", + "gxf_extensions", + "modules", + "operators", + "pkg", + "subgraphs", + "tutorials", +) +SEARCH_DIRS = tuple(name for name in METADATA_DIRS if name != "subgraphs") + +_VERSION_RE = re.compile(r"[0-9A-Za-z](?:[0-9A-Za-z._+!-]{0,126}[0-9A-Za-z])?") +_PYTHON_SEGMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +class ProjectContextError(ValueError): + """A project root, Module metadata, or requirement contract is invalid.""" + + +class ProjectVersionError(ProjectContextError): + """The running CLI cannot satisfy a standalone Module requirement.""" + + +@dataclass(frozen=True) +class ProjectContext: + """Discovered project information available before the full CLI import.""" + + root: Path + kind: str + discovery: str + module_metadata_path: Optional[Path] = None + module_metadata: Optional[dict] = None + requirements_path: Optional[Path] = None + required_version: Optional[str] = None + requirement_error: Optional[str] = None + running_version: Optional[str] = None + legacy_launcher: bool = False + repo_prefix: Optional[str] = None + container_prefix: Optional[str] = None + workspace_name: Optional[str] = None + hostname_prefix: Optional[str] = None + base_sdk_version: Optional[str] = None + metadata_search_paths: tuple[Path, ...] = () + dockerfile: Optional[Path] = None + warnings: tuple[str, ...] = () + + @property + def is_module(self) -> bool: + return self.kind == "module" + + @property + def is_standalone_module(self) -> bool: + return self.is_module and not self.legacy_launcher + + @property + def version_match(self) -> Optional[bool]: + if self.required_version is None or self.running_version is None: + return None + return self.required_version == self.running_version + + def profile_environment(self) -> dict[str, str]: + """Return Module-derived defaults consumed by existing CLI classes.""" + if not self.is_module: + return {"HOLOSCAN_CLI_ROOT": str(self.root)} + + values = { + "HOLOSCAN_CLI_ROOT": str(self.root), + "HOLOSCAN_CLI_BUILD_PARENT_DIR": str(self.root / "build"), + "HOLOSCAN_CLI_DATA_DIR": str(self.root / "data"), + "HOLOSCAN_CLI_SEARCH_PATH": ",".join( + str(path.relative_to(self.root)) for path in self.metadata_search_paths + ), + } + optional_values = { + "HOLOSCAN_CLI_REPO_PREFIX": self.repo_prefix, + "HOLOSCAN_CLI_CONTAINER_PREFIX": self.container_prefix, + "HOLOSCAN_CLI_WORKSPACE_NAME": self.workspace_name, + "HOLOSCAN_CLI_HOSTNAME_PREFIX": self.hostname_prefix, + "HOLOSCAN_CLI_BASE_SDK_VERSION": self.base_sdk_version, + } + values.update({key: value for key, value in optional_values.items() if value}) + return values + + def diagnostics(self) -> dict: + """Return serializable project-profile and version-contract details.""" + data = { + "kind": self.kind, + "root": str(self.root), + "discovery": self.discovery, + } + if not self.is_module: + return data + data.update( + { + "metadata": str(self.module_metadata_path), + "requirements": str(self.requirements_path), + "required_version": self.required_version, + "running_version": self.running_version, + "version_match": self.version_match, + "requirement_error": self.requirement_error, + "legacy_launcher": self.legacy_launcher, + "repo_prefix": self.repo_prefix, + "container_prefix": self.container_prefix, + "workspace_name": self.workspace_name, + "hostname_prefix": self.hostname_prefix, + "base_sdk_version": self.base_sdk_version, + "metadata_search_paths": [str(path) for path in self.metadata_search_paths], + "dockerfile": str(self.dockerfile) if self.dockerfile else None, + } + ) + return data + + +_ACTIVE_PROJECT_CONTEXT: Optional[ProjectContext] = None + + +def get_active_project_context() -> Optional[ProjectContext]: + """Return the context activated by the top-level dispatcher, if any.""" + return _ACTIVE_PROJECT_CONTEXT + + +def set_active_project_context(context: Optional[ProjectContext]) -> None: + """Set the process-local context used by version and env-info diagnostics.""" + global _ACTIVE_PROJECT_CONTEXT + _ACTIVE_PROJECT_CONTEXT = context + + +def get_running_cli_version() -> str: + """Return the installed distribution version without importing the full CLI.""" + try: + return importlib.metadata.version(PACKAGE_NAME) + except importlib.metadata.PackageNotFoundError: + return "0.0.0+local" + + +def parse_cli_requirement(path: Path) -> str: + """Parse the deliberately narrow standalone Module requirements contract.""" + try: + size = path.stat().st_size + except OSError as exc: + raise ProjectContextError(f"Could not read {path}: {exc}") from exc + if size > MAX_REQUIREMENTS_BYTES: + raise ProjectContextError( + f"{path} is too large ({size} bytes); expected one exact {PACKAGE_NAME} requirement." + ) + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + raise ProjectContextError(f"Could not read {path} as UTF-8: {exc}") from exc + + active = [line.strip() for line in lines if line.strip() and not line.lstrip().startswith("#")] + if len(active) != 1: + raise ProjectContextError( + f"{path} must contain exactly one active requirement: {PACKAGE_NAME}==." + ) + prefix = f"{PACKAGE_NAME}==" + line = active[0] + if not line.startswith(prefix): + raise ProjectContextError( + f"{path} must contain exactly {PACKAGE_NAME}==; extras, ranges, " + "URLs, paths, markers, and pip options are not supported." + ) + version = line[len(prefix) :] + if not _VERSION_RE.fullmatch(version): + raise ProjectContextError(f"{path} contains an invalid exact version: {version!r}.") + return version + + +def _matches_existing_root(candidate: Path) -> bool: + if (candidate / "src" / "holoscan_cli").is_dir() and (candidate / "pyproject.toml").exists(): + return True + if any((candidate / name).exists() for name in SENTINEL_FILES) and any( + (candidate / name).is_dir() for name in METADATA_DIRS + ): + return True + return any((candidate / name / MODULE_METADATA_FILENAME).exists() for name in METADATA_DIRS) + + +def _read_module_metadata(root: Path, *, strict: bool) -> Optional[dict]: + metadata_path = root / MODULE_METADATA_FILENAME + if not metadata_path.is_file(): + return None + try: + raw = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + if strict: + raise ProjectContextError(f"Invalid Module metadata at {metadata_path}: {exc}") from exc + return None + if not isinstance(raw, dict) or "module" not in raw: + return None + if not isinstance(raw["module"], dict): + if strict: + raise ProjectContextError(f"Module metadata at {metadata_path} must contain an object.") + return None + return raw["module"] + + +def _module_identity(module: dict, metadata_path: Path) -> tuple[str, str, Optional[str]]: + module_name = module.get("name") + if not isinstance(module_name, str) or not module_name.strip(): + raise ProjectContextError(f"Module metadata at {metadata_path} has no valid module.name.") + + namespace = module.get("namespace") + python_namespace = None + if namespace is not None: + if not isinstance(namespace, dict): + raise ProjectContextError( + f"Module metadata at {metadata_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 python_namespace.strip(): + raise ProjectContextError( + f"Module metadata at {metadata_path} has an invalid module.namespace.python." + ) + segments = python_namespace.split(".") + if not all(_PYTHON_SEGMENT_RE.fullmatch(segment) for segment in segments): + raise ProjectContextError( + f"Module metadata at {metadata_path} has an invalid Python namespace: " + f"{python_namespace!r}." + ) + repo_prefix = segments[-1] + else: + normalized_name = module_name.strip().lower() + if normalized_name.startswith("holoscan-"): + normalized_name = normalized_name[len("holoscan-") :] + repo_prefix = re.sub(r"[^a-z0-9]+", "_", normalized_name).strip("_") + if not repo_prefix: + raise ProjectContextError( + f"Module metadata at {metadata_path} cannot derive a project identity from " + f"module.name={module_name!r}." + ) + + sdk_version = None + sdk = module.get("holoscan_sdk") + if sdk is not None: + if not isinstance(sdk, dict): + raise ProjectContextError( + f"Module metadata at {metadata_path} has an invalid module.holoscan_sdk." + ) + minimum = sdk.get("minimum_required_version") + if minimum is not None: + if not isinstance(minimum, str) or not minimum.strip(): + raise ProjectContextError( + f"Module metadata at {metadata_path} has an invalid minimum SDK version." + ) + sdk_version = minimum.strip() + return repo_prefix, repo_prefix.replace("_", "-"), sdk_version + + +def _build_context( + root: Path, + *, + kind: str, + discovery: str, + module: Optional[dict] = None, + warnings: tuple[str, ...] = (), + running_version: Optional[str] = None, + load_module_contract: bool = True, +) -> ProjectContext: + if kind != "module" or module is None: + return ProjectContext(root=root, kind=kind, discovery=discovery, warnings=warnings) + + metadata_path = root / MODULE_METADATA_FILENAME + if not load_module_contract: + return ProjectContext( + root=root, + kind=kind, + discovery=discovery, + module_metadata_path=metadata_path, + module_metadata=module, + warnings=warnings, + ) + repo_prefix, container_prefix, sdk_version = _module_identity(module, metadata_path) + requirements_path = root / REQUIREMENTS_FILENAME + required_version = None + requirement_error = None + legacy_launcher = any((root / name).is_file() for name in ("holohub", "holoscan")) + if requirements_path.is_file(): + try: + required_version = parse_cli_requirement(requirements_path) + except ProjectContextError as exc: + requirement_error = str(exc) + elif not legacy_launcher: + requirement_error = ( + f"Standalone Module {root} is missing {REQUIREMENTS_FILENAME}. " + "Restore the generated file or recreate the Module." + ) + + search_paths = (metadata_path, *(root / name for name in SEARCH_DIRS)) + dockerfile_value = module.get("dockerfile") + dockerfile = None + if isinstance(dockerfile_value, str) and dockerfile_value.strip(): + candidate = Path(dockerfile_value).expanduser() + dockerfile = candidate if candidate.is_absolute() else root / candidate + elif (root / "Dockerfile").is_file(): + dockerfile = root / "Dockerfile" + + return ProjectContext( + root=root, + kind=kind, + discovery=discovery, + module_metadata_path=metadata_path, + module_metadata=module, + requirements_path=requirements_path, + required_version=required_version, + requirement_error=requirement_error, + running_version=running_version or get_running_cli_version(), + legacy_launcher=legacy_launcher, + repo_prefix=repo_prefix, + container_prefix=container_prefix, + workspace_name=repo_prefix, + hostname_prefix=container_prefix, + base_sdk_version=sdk_version, + metadata_search_paths=tuple(search_paths), + dockerfile=dockerfile, + warnings=warnings, + ) + + +def _resolve_explicit_root(value: str | os.PathLike[str], cwd: Path) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = cwd / path + return path.resolve() + + +def discover_project_context( + *, + cwd: Optional[Path] = None, + explicit_root: Optional[str | os.PathLike[str]] = None, + environ: Optional[Mapping[str, str]] = None, + running_version: Optional[str] = None, + load_module_contract: bool = True, +) -> ProjectContext: + """Discover one project root without changing HoloHub ancestor precedence.""" + original_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_explicit_root(explicit_root, original_cwd) + if not root.exists() or not root.is_dir(): + raise ProjectContextError(f"--project-root {root} does not name an existing directory.") + existing_match = _matches_existing_root(root) + module = _read_module_metadata(root, strict=load_module_contract) + if not existing_match and module is None: + raise ProjectContextError( + f"--project-root {root} is not a recognized Holoscan source-project or Module root." + ) + return _build_context( + root, + kind="module" if module is not None else "source", + discovery="project-root", + module=module, + running_version=running_version, + load_module_contract=load_module_contract, + ) + + env_root = env.get("HOLOSCAN_CLI_ROOT") + if env_root: + root = _resolve_explicit_root(env_root, original_cwd) + if root.exists() and root.is_dir(): + module = _read_module_metadata(root, strict=load_module_contract) + return _build_context( + root, + kind="module" if module is not None else "source", + discovery="environment", + module=module, + running_version=running_version, + load_module_contract=load_module_contract, + ) + warnings = (f"Ignoring invalid HOLOSCAN_CLI_ROOT={env_root!r}; discovering from cwd.",) + + module_fallback: Optional[tuple[Path, dict]] = None + for candidate in (original_cwd, *original_cwd.parents): + existing_match = _matches_existing_root(candidate) + module = _read_module_metadata(candidate, strict=existing_match and load_module_contract) + if existing_match: + return _build_context( + candidate, + kind="module" if module is not None else "source", + discovery="ancestor", + module=module, + warnings=warnings, + running_version=running_version, + load_module_contract=load_module_contract, + ) + if module is not None and module_fallback is None: + module_fallback = (candidate, module) + + if module_fallback is not None: + root, module = module_fallback + return _build_context( + root, + kind="module", + discovery="module-fallback", + module=module, + warnings=warnings, + running_version=running_version, + load_module_contract=load_module_contract, + ) + return _build_context(original_cwd, kind="cwd", discovery="cwd", warnings=warnings) + + +def activate_project_context(context: ProjectContext) -> None: + """Apply bounded defaults before importing CLI/container class bodies.""" + set_active_project_context(context) + values = context.profile_environment() + # Root selection follows CLI > environment > discovery precedence and must + # replace an invalid environment value. Other explicit environment values + # remain authoritative over Module-derived defaults. + os.environ["HOLOSCAN_CLI_ROOT"] = values.pop("HOLOSCAN_CLI_ROOT") + for key, value in values.items(): + os.environ.setdefault(key, value) + + +def _is_container() -> bool: + """Return whether this is a CLI-managed project development container. + + Generic container probes such as ``/.dockerenv`` cannot distinguish a + generated Module image from a containerized CI host that launches Docker. + ``HoloscanContainer`` already sets this marker on every recursive project + invocation, so use it to select container-specific recovery guidance. + """ + return os.environ.get("HOLOSCAN_CLI_BUILD_LOCAL", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def enforce_project_requirement( + context: ProjectContext, *, in_container: Optional[bool] = None +) -> None: + """Fail before project work when a standalone Module pin is unsatisfied.""" + if not context.is_standalone_module: + return + if context.requirement_error: + raise ProjectVersionError(context.requirement_error) + if context.required_version == context.running_version: + return + + lines = [ + f"This Module requires {PACKAGE_NAME}=={context.required_version}, " + f"but {context.running_version} is running.", + f"Requirements: {context.requirements_path}", + f"Python: {sys.executable}", + ] + executable = shutil.which("holoscan") + if executable: + lines.append(f"Holoscan executable: {executable}") + if _is_container() if in_container is None else in_container: + lines.extend( + [ + "Rebuild the development image; do not use --no-docker-build with this image.", + "The running container will not install or modify holoscan-cli.", + ] + ) + else: + lines.extend( + [ + "Activate the intended environment, or run:", + f" {sys.executable} -m pip install -r {context.requirements_path}", + ] + ) + raise ProjectVersionError("\n".join(lines)) diff --git a/src/holoscan_cli/setup_scripts/requirements.template.txt b/src/holoscan_cli/setup_scripts/requirements.template.txt index d448975..1a49511 100644 --- a/src/holoscan_cli/setup_scripts/requirements.template.txt +++ b/src/holoscan_cli/setup_scripts/requirements.template.txt @@ -1,3 +1,4 @@ cookiecutter>=2.7.1 jsonschema>=4.26.0 +packaging>=23.0 referencing>=0.36.2 diff --git a/src/holoscan_cli/templates/__init__.py b/src/holoscan_cli/templates/__init__.py new file mode 100644 index 0000000..7d8ab36 --- /dev/null +++ b/src/holoscan_cli/templates/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Cookiecutter templates distributed with Holoscan CLI.""" diff --git a/src/holoscan_cli/templates/module/cookiecutter.json b/src/holoscan_cli/templates/module/cookiecutter.json new file mode 100644 index 0000000..22751bf --- /dev/null +++ b/src/holoscan_cli/templates/module/cookiecutter.json @@ -0,0 +1,29 @@ +{ + "project_name": "My Sensor Holoscan Module", + "module_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_').replace('-', '_') }}", + "module_repo_name": "holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}", + "operator_slug": "{{ cookiecutter.module_slug }}_op", + "full_name": "Your Name", + "affiliation": "Your Organization", + "language": "cpp", + "version": "0.1.0", + "holoscan_version": "4.5.0", + "description": "A Holoscan Module extending the Holoscan SDK.", + "_holoscan_cli_version": "4.5.0", + "_holoscan_cli_prerelease": false, + "_license": "Apache-2.0", + "contact_email": "your.email@example.com", + "__prompts__": { + "project_name": "Human-readable module name (e.g. 'My Sensor'). See modules/README.md for the full naming system.", + "module_slug": "snake_case identifier — auto-derived from project_name. Used for Python import path and C++ namespace (e.g. 'my_sensor').", + "module_repo_name": "kebab-case repo/package name — auto-derived, prefixed 'holoscan-' (e.g. 'holoscan-my-sensor'). Used for PyPI and Debian packages.", + "operator_slug": "Initial operator name in snake_case (e.g. 'my_sensor_op'). CamelCase class name is derived.", + "full_name": "Author name", + "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", + "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 new file mode 100755 index 0000000..04dd6df --- /dev/null +++ b/src/holoscan_cli/templates/module/hooks/post_gen_project.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# 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. + +"""Post-generation hook: clean up language-specific files.""" + +import os +import shutil + +LANGUAGE = "{{ cookiecutter.language }}" +MODULE_SLUG = "{{ cookiecutter.module_slug }}" +MODULE_REPO_NAME = "{{ cookiecutter.module_repo_name }}" +OPERATOR_SLUG = "{{ cookiecutter.operator_slug }}" + + +def remove_paths(*paths: str) -> None: + for p in paths: + if os.path.isfile(p): + os.remove(p) + elif os.path.isdir(p): + shutil.rmtree(p) + + +def remove_empty_dirs(root: str = ".") -> None: + """Bottom-up removal of directories left empty by conditional filenames.""" + for dirpath, _dirnames, _filenames in os.walk(root, topdown=False): + if dirpath == root: + continue + if not os.listdir(dirpath): + os.rmdir(dirpath) + + +# For Python-only modules, remove directories that only make sense for C++. +if LANGUAGE == "python": + remove_paths("tests/cpp", ".clang-format") + +# Remove any directories that became empty (from Jinja2 conditional filenames). +remove_empty_dirs() + +# ── 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:") +if LANGUAGE == "cpp": + print(f" operators/{OPERATOR_SLUG}/{OPERATOR_SLUG}.cpp\n") +else: + print(f" operators/{OPERATOR_SLUG}/{OPERATOR_SLUG}.py\n") + +print("Build and run:") +print(" holoscan run-container") +print(" # Inside the container:") +print(f" holoscan build {pipeline}") +print(f" holoscan run {pipeline} --language python\n") +print("Register your module at https://nvidia-holoscan.github.io/ when ready.") diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.clang-format b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.clang-format new file mode 100644 index 0000000..70c138d --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.clang-format @@ -0,0 +1,6 @@ +BasedOnStyle: Google +ColumnLimit: 100 +IndentWidth: 2 +AccessModifierOffset: -1 +IncludeBlocks: Regroup +PointerAlignment: Left 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 0000000..490b36b --- /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 new file mode 100644 index 0000000..64ac0cd --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install ruff + - name: Python lint + run: ruff check . + - name: Validate metadata.json against the Holoscan CLI schema + run: | + pip install --find-links=.holoscan-cli-wheelhouse -r requirements-cli.txt + pip install --find-links=.holoscan-cli-wheelhouse \ + 'holoscan-cli[create]' -c requirements-cli.txt + python .github/workflows/scripts/validate_metadata.py +{% if cookiecutter.language == 'cpp' %} + - name: Install clang-format + run: sudo apt-get update && sudo apt-get install -y clang-format + - name: C++ format check + run: | + find operators applications tests \ + -name '*.cpp' -o -name '*.hpp' \ + | xargs clang-format --dry-run --Werror +{% endif %} + + build-only: + name: CMake configure (CPU, no GPU required) + # Verifies the CMake graph and Python packaging without a GPU runner. + # holoscan is installed from PyPI (CPU wheel) solely to satisfy find_package(holoscan). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install holoscan (CPU wheel) + CMake + run: | + pip install holoscan cmake ninja + - name: CMake configure + run: | + cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -D{{ cookiecutter.module_slug | upper }}_BUILD_TESTING=OFF \ + -DBUILD_ALL=ON + + build-and-test: + name: Build and Test (Holoscan {{ cookiecutter.holoscan_version }}) + # Requires a self-hosted GPU runner and the Holoscan container image. + # To set up a self-hosted runner see: + # https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners + # TODO: update the runner label and container image for your CI environment. + runs-on: [self-hosted, linux, x86_64, gpu] + + container: + image: nvcr.io/nvidia/clara-holoscan/holoscan:v{{ cookiecutter.holoscan_version }}-cuda13-dgpu + options: --gpus all + + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: | + cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -D{{ cookiecutter.module_slug | upper }}_BUILD_TESTING=ON \ + -DBUILD_ALL=ON + + - name: Build + run: cmake --build build -j"$(nproc)" +{% if cookiecutter.language == 'cpp' %} + - name: C++ tests (CTest) + run: ctest --test-dir build --output-on-failure -L unit +{% endif %} + - name: Python tests (pytest) + run: | + {{ cookiecutter.module_slug | upper }}_BUILD_DIR=build \ + PYTHONPATH=build/python/lib${PYTHONPATH:+:$PYTHONPATH} \ + python -m pytest tests/python/ -v --tb=short + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: build/Testing/ 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 0000000..e8f89e7 --- /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/copyright_excludes.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/copyright_excludes.txt new file mode 100644 index 0000000..f54e3ff --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/copyright_excludes.txt @@ -0,0 +1,17 @@ +# Copyright check exclude patterns +# One pattern per line, comments start with # +# +# Usage: +# In CI: python3 .github/workflows/scripts/check_copyright.py --exclude-config .github/workflows/scripts/copyright_excludes.txt . +# Locally: python3 .github/workflows/scripts/check_copyright.py --exclude-config .github/workflows/scripts/copyright_excludes.txt --git-modified-only . +# +# You can also combine with additional excludes: +# python3 .github/workflows/scripts/check_copyright.py --exclude-config .github/workflows/scripts/copyright_excludes.txt --exclude "my_temp_file.py" . +build +install +.cache +.local +_CPack_Packages +# CMake helpers copied in from the HoloHub clone by the post-gen hook — these +# carry their own upstream copyright and are not maintained in this module. +cmake 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 0000000..11d2751 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/gitutils.py @@ -0,0 +1,143 @@ +""" +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""" + cmd = "git " + " ".join(list(opts)) + ret = subprocess.check_output(cmd, shell=True) + 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 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. + """ + files = __git("status", "-u", "-s") + ret = [] + for f in files.splitlines(): + f = f.strip(" ") + f = re.sub(r"\s+", " ", f) + tmp = f.split(" ", 1) + # only consider staged files or uncommitted files + # in other words, ignore untracked files + if tmp[0] == "M" or tmp[0] == "A": + ret.append(tmp[1]) + return ret + + +def changed_files_between(base_ref, new_ref): + """ + Returns a list of files changed between base_ref and new_ref + """ + files = __gitdiff("--name-only", "--ignore-submodules", f"{base_ref}..{new_ref}") + 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""" + current = branch() + __git("checkout", "--quiet", b1) + __git("checkout", "--quiet", b2) + diffs = __gitdiff("--ignore-submodules", "-w", "--minimal", "-U0", f"{b1}...{b2}", "--", file) + __git("checkout", "--quiet", current) + 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 new file mode 100644 index 0000000..1292c90 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.github/workflows/scripts/validate_metadata.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# 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. +"""Validate metadata.json files against holoscan-cli JSON schemas. + +Walks the repository, loads every metadata.json that isn't under an +excluded directory, and validates it using holoscan_cli's schema +registry (operator, application, module, package, …). The correct +schema is chosen automatically from the top-level envelope key. +""" + +import json +import os +import sys +from pathlib import Path + +_EXCLUDE_DIRS = {"build", "install", ".local", ".cache", "_CPack_Packages"} + + +def iter_metadata_files(repo_root: Path): + for dirpath, dirnames, filenames in os.walk(repo_root): + dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIRS] + if "metadata.json" in filenames: + yield Path(dirpath) / "metadata.json" + + +def main(): + try: + from holoscan_cli.metadata.metadata_validator import validate_json + except ImportError: + print( + "error: holoscan-cli is not installed in the active Python environment.\n" + " Activate the environment that has holoscan-cli before running " + "pre-commit.", + file=sys.stderr, + ) + sys.exit(1) + + repo_root = Path(__file__).resolve().parents[3] + failed = False + + for path in sorted(iter_metadata_files(repo_root)): + rel = path.relative_to(repo_root) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"{rel}: invalid JSON — {exc}", file=sys.stderr) + failed = True + continue + + ok, message = validate_json(data, path.parent) + if not ok: + print(f"{rel}: {message}", file=sys.stderr) + failed = True + else: + print(f"{rel}: ok") + + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.gitignore b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.gitignore new file mode 100644 index 0000000..846acb1 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.gitignore @@ -0,0 +1,26 @@ +# CMake build artifacts +build/ +.cmake/ + +# Caller-owned Python environments +.venv/ + +# Container HOME-mapped junk (dev container sets HOME=/workspace/{{ cookiecutter.module_slug }}) +.local/ +.cupy/ +.cache/ +.bash_history +.python_history +.ngc/ +.nv/ + +# Python packaging artifacts +dist/ +*.egg-info/ +__pycache__/ +*.py[cod] + +# Editor / IDE +.vscode/ +.idea/ +*.swp diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.holoscan-cli-wheelhouse/.gitignore b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.holoscan-cli-wheelhouse/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.holoscan-cli-wheelhouse/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.markdownlint.yaml b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.markdownlint.yaml new file mode 100644 index 0000000..83ed800 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.markdownlint.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +MD013: + line_length: 800 + code_block_line_length: 800 + heading_line_length: 800 +MD046: + style: fenced diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.markdownlintignore b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.markdownlintignore new file mode 100644 index 0000000..10d72b5 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.markdownlintignore @@ -0,0 +1,4 @@ +# Build and tool output directories +build/ +install/ +.cache/ 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 new file mode 100644 index 0000000..769143e --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/.pre-commit-config.yaml @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +# Run `pre-commit install` once, then hooks run on every commit. +# 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 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: + # General hygiene + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-merge-conflict + - id: trailing-whitespace + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + + # Python: ruff (lint + format) — configured in pyproject.toml + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.17 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + # Spelling + - repo: https://github.com/codespell-project/codespell + rev: v2.4.2 + hooks: + - id: codespell + args: + - --write-changes + - --ignore-words=codespell_ignore_words.txt + - --exclude-file=codespell.txt + + # C++: cpplint + - repo: https://github.com/cpplint/cpplint + rev: 2.0.2 + hooks: + - id: cpplint + # Line width is owned by clang-format (ColumnLimit 100); TODO markers in + # the generated stubs intentionally omit a username. + # NOTE: keep the comma-separated --filter value on one block-list line. + # In flow style ([a, b]) YAML splits on the comma, turning the second + # category into a stray "-whitespace/line_length" arg that cpplint + # rejects with a usage error. + args: + - --quiet + - --filter=-readability/todo,-whitespace/line_length + + # Markdown + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.48.0 + hooks: + - id: markdownlint + args: [--fix, --config, .markdownlint.yaml, --ignore-path, .markdownlintignore] + + # CMake, metadata, and copyright headers (local hooks) + - repo: local + hooks: + - id: holoscan-metadata-validate + name: validate metadata.json against the Holoscan CLI schema + entry: python3 .github/workflows/scripts/validate_metadata.py + language: python + additional_dependencies: ["holoscan-cli[create]"] + files: '(^|/)metadata\.json$' + pass_filenames: false + + - id: cmakelint + name: cmakelint + entry: cmakelint + language: python + additional_dependencies: ["cmakelint==1.4.3"] + args: + - --filter=-whitespace/indent,-linelength,-readability/wonkycase,-convention/filename,-package/stdargs + files: '(^|/)CMakeLists\.txt$|\.cmake$' + + - id: check-copyright + name: check-copyright + entry: python3 .github/workflows/scripts/check_copyright.py + args: + - . + - --git-modified-only + - --exclude-config + - .github/workflows/scripts/copyright_excludes.txt + - --update-current-year + language: system + pass_filenames: false 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 new file mode 100644 index 0000000..13cc043 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/CMakeLists.txt @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +cmake_minimum_required(VERSION 3.24) + +project(holoscan_{{ cookiecutter.module_slug }} + VERSION {{ cookiecutter.version }} + DESCRIPTION "{{ cookiecutter.description }}" + LANGUAGES {% if cookiecutter.language == 'cpp' %}CXX{% else %}NONE{% endif %} +) + +{% if cookiecutter.language == 'cpp' %} +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +{% endif %} +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- +{% if cookiecutter.language == 'cpp' %} +find_package(holoscan REQUIRED COMPONENTS core) +{% endif %} + +# --------------------------------------------------------------------------- +# Build options +# --------------------------------------------------------------------------- +# BUILD_ALL toggles every operator/application/package on by default. It +# matches the HoloHub-monorepo convention: each subproject is a CMake option +# (e.g. APP_, OP_, PKG_) defined via add_holohub_application +# / add_holohub_operator / add_holohub_package, defaulting to ${BUILD_ALL}. +# To build a single subproject, configure with -DBUILD_ALL=OFF -DAPP_=ON. +# +# Default to ON only when this is the top-level CMake project (standalone +# build). When nested under another project (e.g. consumed as a HoloHub +# external dependency via add_subdirectory), defer to whatever the parent +# build has decided — otherwise this module's BUILD_ALL=ON would leak into +# the parent's variable scope and force-enable unrelated subprojects there. + +if(NOT DEFINED BUILD_ALL) + if(PROJECT_IS_TOP_LEVEL) + set(BUILD_ALL ON CACHE BOOL "Build all operators, applications, and packages by default") + else() + set(BUILD_ALL OFF) + endif() +endif() + +# Module tests live behind a module-namespaced flag rather than the standard +# CMake BUILD_TESTING variable. This lets a parent project (HoloHub, another +# module) keep its own BUILD_TESTING setting independent of whether *this* +# module's tests are built. Defaults ON when standalone, OFF when nested. +if(NOT DEFINED {{ cookiecutter.module_slug | upper }}_BUILD_TESTING) + if(PROJECT_IS_TOP_LEVEL) + set({{ cookiecutter.module_slug | upper }}_BUILD_TESTING ON + CACHE BOOL "Build the {{ cookiecutter.module_slug }} module's CTest and pytest suites") + else() + set({{ cookiecutter.module_slug | upper }}_BUILD_TESTING OFF + CACHE BOOL "Build the {{ cookiecutter.module_slug }} module's CTest and pytest suites") + endif() +endif() + +# --------------------------------------------------------------------------- +# Python package root in the build tree. +# PYTHONPATH=${{'{'}}{{ cookiecutter.module_slug | upper }}_PYTHON_ROOT} lets Python find +# 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 `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. +# --------------------------------------------------------------------------- + +set({{ cookiecutter.module_slug | upper }}_PYTHON_ROOT + ${CMAKE_BINARY_DIR}/python/lib) +set({{ cookiecutter.module_slug | upper }}_PYTHON_PKG_DIR + ${{'{'}}{{ cookiecutter.module_slug | upper }}_PYTHON_ROOT}/holoscan/{{ cookiecutter.module_slug }}) + +# Variables consumed by pybind11_add_holohub_module: +# HOLOHUB_PYTHON_MODULE_OUT_DIR — where per-operator subpackages land +# HOLOSCAN_INSTALL_LIB_DIR — relative lib dir, used for rpath resolution +set(HOLOHUB_PYTHON_MODULE_OUT_DIR + ${{'{'}}{{ cookiecutter.module_slug | upper }}_PYTHON_PKG_DIR}) +file(MAKE_DIRECTORY ${HOLOHUB_PYTHON_MODULE_OUT_DIR}) +if(NOT DEFINED HOLOSCAN_INSTALL_LIB_DIR) + set(HOLOSCAN_INSTALL_LIB_DIR lib) +endif() + +# --------------------------------------------------------------------------- +# CMake helpers +# --------------------------------------------------------------------------- +# HoloHubConfigHelpers.cmake provides add_holohub_application/operator/package +# which gate each subproject on its own option (APP_/OP_/PKG_). The +# 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) + +# --------------------------------------------------------------------------- +# Subprojects +# --------------------------------------------------------------------------- +# pkg/ must come *before* operators/ and applications/: add_holohub_package() +# inside pkg/ uses set(... CACHE BOOL "" FORCE) to flip OP_=ON / APP_=ON +# when its PKG_ option is ON, and the operator/application macros that +# read those values run their `option(OP_ ... ${BUILD_ALL})` calls when +# their subdirectories are processed — those bind the cache value the FIRST +# time option() is encountered, so the FORCE has to happen before then. + +add_subdirectory(pkg) +add_subdirectory(operators) +add_subdirectory(applications) + +if({{ cookiecutter.module_slug | upper }}_BUILD_TESTING) + enable_testing() + add_subdirectory(tests) +endif() + +# --------------------------------------------------------------------------- +# Stage dev-mode hook files in the build tree +# --------------------------------------------------------------------------- +# `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 `holoscan build` never +# touches the host's Python environment. + +set(_DEV_HELPER_FILE "${CMAKE_BINARY_DIR}/holoscan_{{ cookiecutter.module_slug }}_dev.py") +set(_DEV_PTH_FILE "${CMAKE_BINARY_DIR}/holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}-dev.pth") +set(_DEV_TARGET_PATH "${{ '{' }}{{ cookiecutter.module_slug | upper }}_PYTHON_ROOT}/holoscan") + +file(WRITE ${_DEV_HELPER_FILE} +"# 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 +# `holoscan install --dev`. +import os + +_BUILD_PATH = r\"${_DEV_TARGET_PATH}\" + +try: + import holoscan + if os.path.isdir(_BUILD_PATH) and _BUILD_PATH not in holoscan.__path__: + holoscan.__path__.insert(0, _BUILD_PATH) +except Exception: + # Stay silent — a stale dev hook must never abort Python startup. + pass +") + +file(WRITE ${_DEV_PTH_FILE} +"import holoscan_{{ cookiecutter.module_slug }}_dev +") + +# --------------------------------------------------------------------------- +# Packaging +# --------------------------------------------------------------------------- +# Debian / wheel packaging lives under pkg/. pkg/CMakeLists.txt calls +# add_holohub_package({{ cookiecutter.module_repo_name }} OPERATORS {{ cookiecutter.operator_slug }} +# APPLICATIONS {{ cookiecutter.module_slug }}_pipeline), which defines the +# PKG_{{ cookiecutter.module_repo_name.replace('-', '_') }} option, FORCEs the OP_/APP_ deps to ON when the +# package is enabled, and enters pkg/{{ cookiecutter.module_repo_name }}/CMakeLists.txt +# for the holohub_configure_deb() call. 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 new file mode 100644 index 0000000..560f574 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/DEVELOPER.md @@ -0,0 +1,149 @@ +# Developer Guide — {{ cookiecutter.project_name }} + +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +This guide covers the layout, build system, and day-to-day workflow for developing and +distributing this Holoscan Module. + +--- + +## Module layout + +```text +{{ cookiecutter.module_repo_name }}/ +├── requirements-cli.txt # Exact holoscan-cli version contract +├── .holoscan-cli-wheelhouse/ # Ignored local source for unpublished wheels +├── Dockerfile # Development container image +├── CMakeLists.txt # Root CMake — orchestrates operators/applications/tests +├── pyproject.toml # Python packaging metadata (scikit-build-core) +├── metadata.json # Module-level metadata (schema: urn:holohub:module:v2) +├── operators/ +│ └── {{ cookiecutter.operator_slug }}/ +│ ├── {{ cookiecutter.operator_slug }}.{% if cookiecutter.language == 'cpp' %}cpp / .hpp{% else %}py{% endif %} # Operator implementation +│ └── metadata.json # Operator-level metadata +├── applications/ +│ └── {{ cookiecutter.module_slug }}_pipeline/ +│ ├── python/ # Python pipeline + metadata.json (every module) +│ └── cpp/ # C++ pipeline + metadata.json (cpp-language modules) +├── python/holoscan/{{ cookiecutter.module_slug }}/ +│ └── __init__.py # Re-exports operators for `from holoscan.{{ cookiecutter.module_slug }} import ...` +└── tests/ + ├── cpp/ # GTest suite (C++ modules only) + └── python/ # pytest suite +``` + +--- + +## Holoscan CLI environment and commands + +Create or activate the Python environment of your choice, then install the exact CLI version +committed by this Module: + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-cli.txt +``` + +The global `holoscan` command discovers this Module from its metadata and applies the Module's +build, data, SDK, image, and workspace defaults. It refuses lifecycle work when the environment +contains a different CLI version; it never creates or repairs a virtual environment for you. + +| Command | What it does | +| --- | --- | +| `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 | + +To upgrade, edit the exact pin in `requirements-cli.txt`, reinstall it in your selected +environment, and rebuild the development image. For a release candidate, use NVIDIA's package +index as shown in the comments generated in that file and pass the corresponding Docker build +argument: + +```bash +python -m pip install --extra-index-url https://pypi.nvidia.com -r requirements-cli.txt +holoscan build-container --build-args="--build-arg PIP_EXTRA_INDEX_URL=https://pypi.nvidia.com" +``` + +For an unpublished development CLI, put the one pre-built wheel matching the exact pin in +`.holoscan-cli-wheelhouse/`, install with +`python -m pip install --find-links=.holoscan-cli-wheelhouse -r requirements-cli.txt`, and build +the image normally. Reuse the same wheel bytes on host and in the image; do not rebuild a Git +checkout independently in each environment. + +If a container reports a version mismatch, rebuild it. Runtime commands deliberately never +pip-install into a running container. + +--- + +## Building without the Holoscan CLI + +```bash +cmake -S . -B build -DBUILD_ALL=ON -D{{ cookiecutter.module_slug | upper }}_BUILD_TESTING=ON +cmake --build build -j"$(nproc)" +``` + +{% if cookiecutter.language == 'cpp' -%} +Run C++ tests: + +```bash +ctest --test-dir build --output-on-failure -L unit +``` + +{% endif -%} +Run Python tests: + +```bash +{{ cookiecutter.module_slug | upper }}_BUILD_DIR=build \ +PYTHONPATH=build/python/lib${PYTHONPATH:+:$PYTHONPATH} \ +pytest tests/python/ -v +``` + +`PYTHONPATH` is **prepended via `${PYTHONPATH:+:$PYTHONPATH}`** so that an existing entry on the variable is kept while an unset/empty variable doesn't yield a trailing colon. Two failure modes the shorter forms invite: + +- **`PYTHONPATH=build/python/lib`** (replace): drops any ambient holoscan SDK install on `PYTHONPATH`. The module-level `importorskip("holoscan")` then fires, pytest exits with code 5, and CTest marks the run as Skipped. +- **`PYTHONPATH=build/python/lib:$PYTHONPATH`** (naive prepend): on a fresh shell or CI runner where `$PYTHONPATH` is unset, this expands to `PYTHONPATH=build/python/lib:` — Python treats the trailing empty entry as the current directory, silently shadowing installed packages with whatever happens to live in the test CWD. + +--- + +## `pyproject.toml` + +`pyproject.toml` configures [scikit-build-core](https://scikit-build-core.readthedocs.io/) for +wheel packaging. Key fields to update before publishing: + +| Field | Purpose | +| --- | --- | +| `[project].name` | PyPI package name — should match `metadata.json:module.binary_packages.pypi` | +| `[project].version` | Sync with `metadata.json:module.version` | +| `[project].description` | Short description shown on PyPI | +| `[project].authors` | Your name / organisation | +| `[tool.scikit-build].cmake.args` | Extra CMake flags passed during `pip install` | + +Build a wheel: + +```bash +pip install build +python -m build --wheel +``` + +--- + +## Naming conventions + +| Context | Convention | Example | +| --- | --- | --- | +| Python import / C++ namespace | `snake_case` | `holoscan.{{ cookiecutter.module_slug }}` | +| Repository folder | `holoscan-` (kebab) | `{{ cookiecutter.module_repo_name }}` | +| Debian package | `holoscan-` (kebab) | `holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}` | +| PyPI package | `holoscan-` (kebab) | `holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}` | +| CMake option prefix | `UPPER_SNAKE` | `{{ cookiecutter.module_slug | upper }}_BUILD_TESTING` | + +--- + +## Further reading + +- [HoloHub documentation](https://github.com/nvidia-holoscan/holohub) +- [Holoscan SDK documentation](https://docs.nvidia.com/holoscan/sdk-user-guide/introduction/getting-started) +- [Holoscan Module ecosystem](https://nvidia-holoscan.github.io/) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/Dockerfile b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/Dockerfile new file mode 100644 index 0000000..70f9efa --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +# +# Development container for {{ cookiecutter.project_name }}. +# +# Extends the Holoscan SDK image with the build tools needed to configure, +# compile, and test the module from a live source mount. No source is copied +# 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 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 +FROM ${BASE_IMAGE} + +ARG DEBIAN_FRONTEND=noninteractive +ARG PIP_EXTRA_INDEX_URL + +# Install the Module's exact CLI requirement. The optional local wheelhouse is +# bind-mounted only for this step, so unpublished development wheels do not +# become image-layer contents. Published versions resolve from the configured +# package index when the wheelhouse is empty. +COPY requirements-cli.txt /tmp/requirements-cli.txt +RUN --mount=type=bind,source=.holoscan-cli-wheelhouse,target=/tmp/holoscan-cli-wheelhouse,readonly \ + python3 -m pip install --no-cache-dir \ + --find-links=/tmp/holoscan-cli-wheelhouse \ + -r /tmp/requirements-cli.txt \ + && command -v holoscan \ + && holoscan version \ + && python3 -c 'from importlib.metadata import version; from pathlib import Path; lines = [line.strip() for line in Path("/tmp/requirements-cli.txt").read_text().splitlines() if line.strip() and not line.lstrip().startswith("#")]; expected = lines[0].split("==", 1)[1]; actual = version("holoscan-cli"); assert actual == expected, f"installed {actual}, expected {expected}"' + +# TODO: add module-specific runtime / build dependencies below. +{% if cookiecutter.language == "cpp" %}RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + libgtest-dev \ + clang-format \ + pybind11-dev \ + && rm -rf /var/lib/apt/lists/* +{% endif %} +# Python tooling: pytest-timeout for tests, build + scikit-build-core for +# `holoscan package --pkg-generator WHEEL` (drives `python -m build`). +RUN pip install --no-cache-dir pytest-timeout build scikit-build-core diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/LICENSE b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. 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 new file mode 100644 index 0000000..06f5261 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/README.md @@ -0,0 +1,147 @@ +# {{ cookiecutter.project_name }} + +{{ cookiecutter.description }} + +A **Holoscan Module** — a self-contained, redistributable library that extends +[Holoscan SDK](https://developer.nvidia.com/holoscan-sdk) with reusable operators under the +`holoscan.{{ cookiecutter.module_slug }}` namespace. +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} +{%- set mod_kebab = cookiecutter.module_slug.replace('_', '-') %} + +--- + +## Quick Start + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-cli.txt + +# Run the Python demo application +holoscan run {{ cookiecutter.module_slug }}_pipeline --language python +{% if cookiecutter.language == 'cpp' %} +# Run the C++ demo application +holoscan run {{ cookiecutter.module_slug }}_pipeline +{% endif %} +``` + +--- + +## Operators + +| Operator | Implementation | Ports | Parameters | +| --- | --- | --- | --- | +| `{{ op_class }}` | {% if cookiecutter.language == 'cpp' %}C++ + pybind11{% else %}Pure Python{% endif %} | TODO | TODO | + +### Namespace + +{% if cookiecutter.language == 'cpp' -%} +- C++: `holoscan::{{ cookiecutter.module_slug }}` + +{% endif -%} +- Python: `holoscan.{{ cookiecutter.module_slug }}` + +--- + +## Usage + +### Python + +```python +from holoscan.core import Application +from holoscan.{{ cookiecutter.module_slug }} import {{ op_class }} + + +class MyApp(Application): + def compose(self): + op = {{ op_class }}(self, name="{{ cookiecutter.operator_slug }}") + # TODO: connect operators and add_flow calls + + +MyApp().run() +``` + +{% if cookiecutter.language == 'cpp' -%} +### C++ + +```cpp +#include +#include <{{ cookiecutter.operator_slug }}/{{ cookiecutter.operator_slug }}.hpp> + +class MyApp : public holoscan::Application { + public: + void compose() override { + auto op = make_operator("{{ cookiecutter.operator_slug }}"); + // TODO: connect operators + } +}; + +int main() { holoscan::make_application()->run(); } +``` + +{% endif -%} +--- + +## 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`) | +| CMake | ≥ 3.24 | +{%- if cookiecutter.language == 'cpp' %} +| C++ compiler | C++17 (GCC 11+) | +| pybind11 | ≥ 2.11 | +{%- endif %} +| Python | 3.10–3.13 | + +```bash +cmake -S . -B build -DBUILD_ALL=ON -D{{ cookiecutter.module_slug | upper }}_BUILD_TESTING=ON +cmake --build build -j$(nproc) +``` + +--- + +## Testing + +```bash +holoscan test +``` + +Or, without the Holoscan CLI: + +{% if cookiecutter.language == 'cpp' %} + +```bash +# C++ (GTest via CTest) +ctest --test-dir build --output-on-failure -L unit +``` + +{% endif %} + +```bash +# Python (pytest) +PYTHONPATH=build/python/lib${PYTHONPATH:+:$PYTHONPATH} {{ cookiecutter.module_slug | upper }}_BUILD_DIR=build pytest tests/python/ -v +``` + +`PYTHONPATH` is **prepended** (with `${PYTHONPATH:+:$PYTHONPATH}`) so that an +ambient holoscan SDK install stays visible. A bare `PYTHONPATH=build/python/lib` +would replace the variable and hide the SDK from pytest. A bare +`PYTHONPATH=build/python/lib:$PYTHONPATH` looks safe but, when `$PYTHONPATH` is +unset (typical on a fresh shell or CI runner), it expands to a trailing colon +that Python reads as an empty path entry — equivalent to `.`, which silently +adds the test CWD to `sys.path` and lets a local file shadow installed +packages. + +The pytest suite currently covers importability and build-smoke only; full +live-pipeline coverage is a TODO and may require real hardware. CTest is +configured with `SKIP_RETURN_CODE 5` on the pytest entry — if a CTest run +reports "Skipped" unexpectedly, the most common cause is that the holoscan +SDK is not importable in the test environment (pytest collects zero items +and exits 5). + +--- + +## License + +{{ cookiecutter._license }} — see [LICENSE](LICENSE). diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/CMakeLists.txt new file mode 100644 index 0000000..f481aa4 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/CMakeLists.txt @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +# Each application is gated on its own APP_ option, defaulting to ${BUILD_ALL}. +# Add one add_holohub_application() call per application; declare operator +# dependencies via DEPENDS OPERATORS so they auto-enable when the app is built. +add_holohub_application({{ cookiecutter.module_slug }}_pipeline + DEPENDS OPERATORS {{ cookiecutter.operator_slug }} +) 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 new file mode 100644 index 0000000..99f86bd --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/CMakeLists.txt @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +# 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. `holoscan run {{ cookiecutter.module_slug }}_pipeline --language python`). +add_subdirectory(python) +{% if cookiecutter.language == 'cpp' %}add_subdirectory(cpp) +{% endif %} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} new file mode 100644 index 0000000..4604a37 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +add_executable({{ cookiecutter.module_slug }}_pipeline {{ cookiecutter.module_slug }}_pipeline.cpp) + +target_link_libraries({{ cookiecutter.module_slug }}_pipeline PRIVATE + holoscan::{{ cookiecutter.operator_slug }} + holoscan::core) + +target_include_directories({{ cookiecutter.module_slug }}_pipeline PRIVATE + ${PROJECT_SOURCE_DIR}/operators) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}metadata.json{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}metadata.json{% endif %} new file mode 100644 index 0000000..3deb37e --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}metadata.json{% endif %} @@ -0,0 +1,35 @@ +{ + "$schema": "urn:holohub:application:v1", + "application": { + "name": "{{ cookiecutter.module_slug }}_pipeline", + "authors": [ + { + "name": "{{ cookiecutter.full_name }}", + "affiliation": "{{ cookiecutter.affiliation }}" + } + ], + "language": ["C++"], + "version": "{{ cookiecutter.version }}", + "changelog": { + "{{ cookiecutter.version }}": "TODO: describe this example application" + }, + "holoscan_sdk": { + "minimum_required_version": "{{ cookiecutter.holoscan_version }}", + "tested_versions": ["{{ cookiecutter.holoscan_version }}"] + }, + "platforms": ["x86_64", "aarch64"], + "tags": ["TODO: add tags"], + "ranking": 3, + "requirements": {}, + "default_mode": "default", + "modes": { + "default": { + "description": "Run the C++ {{ cookiecutter.module_slug }} pipeline", + "run": { + "command": "/{{ cookiecutter.module_slug }}_pipeline", + "workdir": "holohub_app_bin" + } + } + } + } +} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.module_slug}}_pipeline.cpp{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.module_slug}}_pipeline.cpp{% endif %} new file mode 100644 index 0000000..7e7866d --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/cpp/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.module_slug}}_pipeline.cpp{% endif %} @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +// SPDX-License-Identifier: {{ cookiecutter._license }} +// +// C++ pipeline example for {{ cookiecutter.project_name }}. +// TODO: replace the stub with your actual pipeline topology. +// +// Build: cmake -S . -B build -DBUILD_ALL=ON && cmake --build build +// Run: ./build/applications/{{ cookiecutter.module_slug }}_pipeline/{{ cookiecutter.module_slug }}_pipeline +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} +{%- set app_class = cookiecutter.module_slug.split('_')|map('capitalize')|join('') + 'PipelineApp' %} + +#include +#include <{{ cookiecutter.operator_slug }}/{{ cookiecutter.operator_slug }}.hpp> + +namespace mm = holoscan::{{ cookiecutter.module_slug }}; + +class {{ app_class }} : public holoscan::Application { + public: + void compose() override { + // TODO: instantiate and connect your operators, e.g.: + auto op = make_operator("{{ cookiecutter.operator_slug }}"); + // add_flow(source, op, {{"out", "in"}}); + } +}; + +int main(int, char**) { + auto app = holoscan::make_application<{{ app_class }}>(); + app->run(); + return 0; +} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/CMakeLists.txt new file mode 100644 index 0000000..3c178c9 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/CMakeLists.txt @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/metadata.json b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/metadata.json new file mode 100644 index 0000000..5bb0fec --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/metadata.json @@ -0,0 +1,35 @@ +{ + "$schema": "urn:holohub:application:v1", + "application": { + "name": "{{ cookiecutter.module_slug }}_pipeline", + "authors": [ + { + "name": "{{ cookiecutter.full_name }}", + "affiliation": "{{ cookiecutter.affiliation }}" + } + ], + "language": ["Python"], + "version": "{{ cookiecutter.version }}", + "changelog": { + "{{ cookiecutter.version }}": "TODO: describe this example application" + }, + "holoscan_sdk": { + "minimum_required_version": "{{ cookiecutter.holoscan_version }}", + "tested_versions": ["{{ cookiecutter.holoscan_version }}"] + }, + "platforms": ["x86_64", "aarch64"], + "tags": ["TODO: add tags"], + "ranking": 3, + "requirements": {}, + "default_mode": "default", + "modes": { + "default": { + "description": "Run the Python {{ cookiecutter.module_slug }} pipeline", + "run": { + "command": "python3 /{{ cookiecutter.module_slug }}_pipeline.py", + "workdir": "holohub_app_bin" + } + } + } + } +} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/{{cookiecutter.module_slug}}_pipeline.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/{{cookiecutter.module_slug}}_pipeline.py new file mode 100644 index 0000000..2894937 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/applications/{{cookiecutter.module_slug}}_pipeline/python/{{cookiecutter.module_slug}}_pipeline.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +# +# Python pipeline example for {{ cookiecutter.project_name }}. +# TODO: replace the stub with your actual pipeline topology. +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +import logging + +from holoscan.core import Application +from holoscan.{{cookiecutter.module_slug}} import {{ op_class }} + +logging.basicConfig(level=logging.INFO) + + +class {{ cookiecutter.module_slug.split('_')|map('capitalize')|join('') }}PipelineApp(Application): + def compose(self) -> None: + # TODO: instantiate and connect your operators, e.g.: + op = {{ op_class }}(self, name="{{ cookiecutter.operator_slug }}") # noqa: F841 (wire up via add_flow below) + # self.add_flow(source, op, {("out", "in")}) + + +if __name__ == "__main__": + app = {{ cookiecutter.module_slug.split('_')|map('capitalize')|join('') }}PipelineApp() + app.run() diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/Config.cmake.in b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/Config.cmake.in new file mode 100644 index 0000000..991d327 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/Config.cmake.in @@ -0,0 +1,4 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/@ARG_EXPORT_NAME@.cmake") +check_required_components(@ARG_NAME@) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/HoloHubConfigHelpers.cmake b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/HoloHubConfigHelpers.cmake new file mode 100644 index 0000000..7c0d60d --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/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/templates/module/{{cookiecutter.module_repo_name}}/cmake/holohub_configure_deb.cmake b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/holohub_configure_deb.cmake new file mode 100644 index 0000000..828c458 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/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) + set(multiValueArgs COMPONENTS EXPORT_NAME) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGV}) + + # validate required args + 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 + NO_CHECK_REQUIRED_COMPONENTS_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/templates/module/{{cookiecutter.module_repo_name}}/cmake/pybind11/__init__.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/pybind11/__init__.py new file mode 100644 index 0000000..c1529d6 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/pybind11/__init__.py @@ -0,0 +1,69 @@ +# 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 +try: + from ._@MODULE_NAME@ import @MODULE_CLASS_NAME@ +except ImportError as e: + pybind11_hsdk_err = 'unknown base type "holoscan::' + + if pybind11_hsdk_err not in str(e): + # Unknown import error, raise it + raise e + + # Provide information regarding pybind11 ABI protection + note = """ +- Holoscan SDK >= 3.3.0: make sure to link your bindings against 'holoscan::pybind11'. +- Holoscan SDK < 3.3.0: use the same compiler version as your installation of the Holoscan SDK. + +See https://docs.nvidia.com/holoscan/sdk-user-guide/using-the-sdk/python-operator-bindings#pybind11-abi-compatibility for details. +""" + + # Raise with note if available (Python 3.11+) ... + if hasattr(e, "add_note"): + e.add_note(note) + raise e + + # ... or raise new exception with same trace and message + e = ImportError(e.msg + "\n" + note).with_traceback(e.__traceback__) + raise e from None + + +# Register types with the SDK +try: + # If a register_types function exists, register the types with the SDK + from ._@MODULE_NAME@ import register_types as _register_types + + try: + from holoscan.core import io_type_registry + except ImportError as e: + import warnings + warnings.warn( + "`holoscan.core.io_type_registry` is unavailable in Holoscan SDK < 2.1.0. " + "To use a user-defined `register_types` function, you must upgrade Holoscan SDK." + ) + raise e + + # register any custom emitter/receiver types with the SDK's registry + _register_types(io_type_registry) +except ImportError as e: + # Most extensions will not provide a user-defined `register_types` function, so don't warn or + # raise an error in that case. + pass diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/pybind11_add_holohub_module.cmake b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/pybind11_add_holohub_module.cmake new file mode 100644 index 0000000..83bb800 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/pybind11_add_holohub_module.cmake @@ -0,0 +1,147 @@ +# 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(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 + ${MODULE_CPP_CMAKE_TARGET} + ) + + # Conditionally link to the ABI config target if it exists (for HSDK >= 3.3.0) + set(pybind11_abi_details_msg "See https://docs.nvidia.com/holoscan/sdk-user-guide/using-the-sdk/python-operator-bindings#pybind11-abi-compatibility for details") + if(TARGET holoscan::pybind11) + message(STATUS "${target_name}: Linking against holoscan::pybind11 to disable strict ABI protection in pybind11. ${pybind11_abi_details_msg}") + target_link_libraries(${target_name} PRIVATE holoscan::pybind11) + else() + message(STATUS "${target_name}: holoscan::pybind11 target not found, using pybind11's default ABI protection. ${pybind11_abi_details_msg}") + endif() + + # 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. + file(RELATIVE_PATH install_lib_relative_path + ${CMAKE_CURRENT_LIST_DIR} + ${PROJECT_SOURCE_DIR}/${HOLOSCAN_INSTALL_LIB_DIR} + ) + list(APPEND _rpath + "\$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 + set(CMAKE_SUBMODULE_OUT_DIR ${_module_base_dir}/${MODULE_NAME}) + configure_file( + ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pybind11/__init__.py + ${_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/templates/module/{{cookiecutter.module_repo_name}}/cmake/pydoc/macros.hpp b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/cmake/pydoc/macros.hpp new file mode 100644 index 0000000..e945d38 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/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/templates/module/{{cookiecutter.module_repo_name}}/codespell.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/codespell.txt new file mode 100644 index 0000000..bb05548 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/codespell.txt @@ -0,0 +1 @@ +# This file contains the exact lines of code that should be ignored by codespell diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/codespell_ignore_words.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/codespell_ignore_words.txt new file mode 100644 index 0000000..91e1d34 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/codespell_ignore_words.txt @@ -0,0 +1 @@ +# This file contains the exact words in code that should be ignored by codespell 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 new file mode 100644 index 0000000..eaeae57 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/metadata.json @@ -0,0 +1,41 @@ +{ + "$schema": "urn:holohub:module:v2", + "module": { + "name": "holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}", + "version": "{{ cookiecutter.version }}", + "description": "{{ cookiecutter.description }}", + "authors": [ + { + "name": "{{ cookiecutter.full_name }}", + "affiliation": "{{ cookiecutter.affiliation }}" + } + ], + "license": "{{ cookiecutter._license }}", + "language": {% if cookiecutter.language == "cpp" %}["C++", "Python"]{% else %}["Python"]{% endif %}, + "namespace": { + {% if cookiecutter.language == "cpp" %}"cpp": "holoscan::{{ cookiecutter.module_slug }}", + {% endif %}"python": "holoscan.{{ cookiecutter.module_slug }}" + }, + "holoscan_sdk": { + "minimum_required_version": "{{ cookiecutter.holoscan_version }}", + "tested_versions": ["{{ cookiecutter.holoscan_version }}"] + }, + "platforms": ["x86_64", "aarch64"], + "tags": ["TODO: add tags"], + "source_repository": "https://github.com/TODO/holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}", + "operator_names": ["{{ cookiecutter.operator_slug }}"], + "dockerfile": "Dockerfile", + "binary_packages": { + "debian": "holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}", + "pypi": "holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}", + "install_commands": ["pip install holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}"] + }, + "documentation": { + "readme": "README.md" + }, + "testing": { + {% if cookiecutter.language == "cpp" %}"cpp": "ctest", + {% endif %}"python": "pytest" + } + } +} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/CMakeLists.txt new file mode 100644 index 0000000..1b41ff9 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/CMakeLists.txt @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +# Python3 + pybind11 are pulled in (find_package + FetchContent) by +# pybind11_add_holohub_module, which the operator's own python/CMakeLists.txt +# includes when {{ cookiecutter.language }} == 'cpp'. No find_package needed +# here at the aggregate operators/ level. + +# Each operator is gated on its own OP_ option, defaulting to ${BUILD_ALL}. +# Add one add_holohub_operator() call per operator. +add_holohub_operator({{ cookiecutter.operator_slug }}) + +# Install the aggregate __init__.py alongside each operator's built module. +configure_file( + ${PROJECT_SOURCE_DIR}/python/holoscan/{{ cookiecutter.module_slug }}/__init__.py + ${{'{'}}{{ cookiecutter.module_slug | upper }}_PYTHON_PKG_DIR}/__init__.py COPYONLY) + +# Relative DESTINATION — interpreted as /holoscan/ +# in plain `cmake --install` (set --prefix to ${Python3_SITEARCH}), and as +# /holoscan/ when scikit-build-core builds the wheel. +install(DIRECTORY ${{'{'}}{{ cookiecutter.module_slug | upper }}_PYTHON_PKG_DIR} + DESTINATION holoscan) 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 new file mode 100644 index 0000000..a7b30e0 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/CMakeLists.txt @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +{% if cookiecutter.language == 'cpp' %}add_library({{ cookiecutter.operator_slug }} {{ cookiecutter.operator_slug }}.cpp) +add_library(holoscan::{{ cookiecutter.operator_slug }} ALIAS {{ cookiecutter.operator_slug }}) + +target_include_directories({{ cookiecutter.operator_slug }} PUBLIC + $ + $) + +target_link_libraries({{ cookiecutter.operator_slug }} PRIVATE holoscan::core) + +install(FILES {{ cookiecutter.operator_slug }}.hpp DESTINATION include/{{ cookiecutter.operator_slug }}) + +# Add the operator to an export set. The package layer installs the set and +# generates the CMake config — see holohub_configure_deb(... EXPORT_NAME ...) in +# pkg/{{ cookiecutter.module_repo_name }}/CMakeLists.txt — so downstream projects +# can find_package({{ cookiecutter.module_repo_name }}) and link +# holoscan::{{ cookiecutter.operator_slug }}. +install(TARGETS {{ cookiecutter.operator_slug }} + EXPORT holoscan_{{ cookiecutter.module_slug }}_targets) + +add_subdirectory(python) +{%- else %}# Pure Python operator — copy source into the Python package tree. +configure_file( + {{ cookiecutter.operator_slug }}.py + ${{'{'}}{{ cookiecutter.module_slug | upper }}_PYTHON_PKG_DIR}/{{ cookiecutter.operator_slug }}.py COPYONLY) +{%- endif %} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/metadata.json b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/metadata.json new file mode 100644 index 0000000..63bf9a3 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/metadata.json @@ -0,0 +1,25 @@ +{ + "$schema": "urn:holohub:operator:v1", + "operator": { + "name": "{{ cookiecutter.operator_slug.split('_')|map('capitalize')|join('') }}", + "authors": [ + { + "name": "{{ cookiecutter.full_name }}", + "affiliation": "{{ cookiecutter.affiliation }}" + } + ], + "language": {% if cookiecutter.language == "cpp" %}["C++", "Python"]{% else %}["Python"]{% endif %}, + "version": "{{ cookiecutter.version }}", + "changelog": { + "{{ cookiecutter.version }}": "TODO: describe what this operator does" + }, + "holoscan_sdk": { + "minimum_required_version": "{{ cookiecutter.holoscan_version }}", + "tested_versions": ["{{ cookiecutter.holoscan_version }}"] + }, + "platforms": ["x86_64", "aarch64"], + "tags": ["Development"], + "ranking": 3, + "requirements": {} + } +} 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 %} new file mode 100644 index 0000000..87f722b --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +# pybind11_add_holohub_module: +# - 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+). +# - Generates ${HOLOHUB_PYTHON_MODULE_OUT_DIR}/{{ cookiecutter.operator_slug }}/__init__.py +# with helpful import-error diagnostics for ABI mismatches. +include(pybind11_add_holohub_module) +pybind11_add_holohub_module( + CPP_CMAKE_TARGET {{ cookiecutter.operator_slug }} + CLASS_NAME {{ op_class }} + SOURCES _{{ cookiecutter.operator_slug }}_bindings.cpp +) + +target_include_directories({{ cookiecutter.operator_slug }}_python PRIVATE + ${PROJECT_SOURCE_DIR}/operators) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}_{{cookiecutter.operator_slug}}_bindings.cpp{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}_{{cookiecutter.operator_slug}}_bindings.cpp{% endif %} new file mode 100644 index 0000000..8534548 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/python/{% if cookiecutter.language == 'cpp' %}_{{cookiecutter.operator_slug}}_bindings.cpp{% endif %} @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +// SPDX-License-Identifier: {{ cookiecutter._license }} +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include <{{ cookiecutter.operator_slug }}/{{ cookiecutter.operator_slug }}.hpp> + +namespace py = pybind11; + +namespace holoscan::{{ cookiecutter.module_slug }} { + +// Forward optional Condition / Resource positional args from py::args onto the +// operator. Takes a raw Operator* — calling shared_from_this() inside the +// constructor doesn't work because the object isn't yet owned by a shared_ptr. +static void add_conditions_and_resources(holoscan::Operator* op, const py::args& args) { + for (const auto& item : args) { + auto obj = py::cast(item); + try { + op->add_arg(obj.cast>()); + continue; + } catch (const py::cast_error&) {} + try { + op->add_arg(obj.cast>()); + } catch (const py::cast_error&) { + HOLOSCAN_LOG_WARN( + "Operator '{}': positional arg of type '{}' is neither a Condition nor a Resource — " + "add_arg skipped.", + op->name(), + py::str(obj.get_type()).cast()); + } + } +} + +class Py{{ op_class }} : public {{ op_class }} { + public: + using {{ op_class }}::{{ op_class }}; + + Py{{ op_class }}(holoscan::Fragment* fragment, const py::args& args, + // TODO: add keyword parameters here, e.g.: + // int my_param = 1, + const std::string& name = "{{ cookiecutter.operator_slug }}") + : {{ op_class }}() { + name_ = name; + fragment_ = fragment; + spec_ = std::make_shared(fragment); + // TODO: forward keyword parameters to args_, e.g.: + // this->add_arg(holoscan::Arg("my_param") = my_param); + setup(*spec_); + add_conditions_and_resources(this, args); + } +}; + +} // namespace holoscan::{{ cookiecutter.module_slug }} + +PYBIND11_MODULE(_{{ cookiecutter.operator_slug }}, m) { + using holoscan::{{ cookiecutter.module_slug }}::{{ op_class }}; + using holoscan::{{ cookiecutter.module_slug }}::Py{{ op_class }}; + + m.doc() = "{{ op_class }} Python bindings"; + + py::class_<{{ op_class }}, Py{{ op_class }}, holoscan::Operator, std::shared_ptr<{{ op_class }}>>( + m, "{{ op_class }}", + "Construct {{ op_class }}.\n\n" + // TODO: expand the docstring to document ports and parameters. + "Parameters\n----------\n" + "fragment : holoscan.core.Fragment\n" + " The fragment this operator belongs to.\n" + "*args\n Optional Condition or Resource objects.\n" + "name : str, optional\n" + " Operator name (default '{{ cookiecutter.operator_slug }}').\n") + .def(py::init(), + py::arg("fragment"), + py::arg("name") = "{{ cookiecutter.operator_slug }}") + .def("setup", &{{ op_class }}::setup, py::arg("spec")); +} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.operator_slug}}.cpp{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.operator_slug}}.cpp{% endif %} new file mode 100644 index 0000000..3c0a943 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.operator_slug}}.cpp{% endif %} @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +// SPDX-License-Identifier: {{ cookiecutter._license }} +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +#include "{{ cookiecutter.operator_slug }}/{{ cookiecutter.operator_slug }}.hpp" + +namespace holoscan::{{ cookiecutter.module_slug }} { + +void {{ op_class }}::setup(OperatorSpec& spec) { + // TODO: declare ports and parameters, e.g.: + // spec.input("in"); + // spec.output("out"); + // spec.param(my_param_, "my_param", "My Parameter", "Description", 1); +} + +void {{ op_class }}::compute(InputContext& op_input, OutputContext& op_output, + ExecutionContext& /*context*/) { + // TODO: implement compute logic, e.g.: + // auto value = op_input.receive("in").value(); + // op_output.emit(value, "out"); +} + +} // namespace holoscan::{{ cookiecutter.module_slug }} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.operator_slug}}.hpp{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.operator_slug}}.hpp{% endif %} new file mode 100644 index 0000000..dead7ef --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'cpp' %}{{cookiecutter.operator_slug}}.hpp{% endif %} @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +// SPDX-License-Identifier: {{ cookiecutter._license }} +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +#pragma once + +#include + +namespace holoscan::{{ cookiecutter.module_slug }} { + +// TODO: rename {{ op_class }} and update port/parameter declarations. +class {{ op_class }} : public holoscan::Operator { + public: + HOLOSCAN_OPERATOR_FORWARD_ARGS({{ op_class }}) + + {{ op_class }}() = default; + + void setup(OperatorSpec& spec) override; + void compute(InputContext& op_input, OutputContext& op_output, + ExecutionContext& /*context*/) override; + + private: + // TODO: declare parameters, e.g.: + // Parameter my_param_; +}; + +} // namespace holoscan::{{ cookiecutter.module_slug }} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'python' %}__init__.py{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'python' %}__init__.py{% endif %} new file mode 100644 index 0000000..97da387 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'python' %}__init__.py{% endif %} @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +from .{{ cookiecutter.operator_slug }} import {{ op_class }} + +__all__ = ["{{ op_class }}"] diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'python' %}{{cookiecutter.operator_slug}}.py{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'python' %}{{cookiecutter.operator_slug}}.py{% endif %} new file mode 100644 index 0000000..7122aac --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/operators/{{cookiecutter.operator_slug}}/{% if cookiecutter.language == 'python' %}{{cookiecutter.operator_slug}}.py{% endif %} @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +"""{{ op_class }} — pure Python Holoscan operator.""" + +import logging + +from holoscan.core import Operator, OperatorSpec + +logger = logging.getLogger(__name__) + + +class {{ op_class }}(Operator): + """TODO: describe what this operator does. + + Ports + ----- + in : TODO + TODO + out : TODO + TODO + + Parameters + ---------- + fragment : holoscan.core.Fragment + The fragment (or Application) this operator belongs to. + *args + Optional positional conditions/resources. + name : str, optional + Operator name (default ``"{{ cookiecutter.operator_slug }}"``). + """ + + def __init__(self, fragment, *args, name: str = "{{ cookiecutter.operator_slug }}", **kwargs): + super().__init__(fragment, *args, name=name, **kwargs) + + def setup(self, spec: OperatorSpec) -> None: + # TODO: declare ports, e.g.: + # spec.input("in") + # spec.output("out") + pass + + def compute(self, op_input, op_output, context) -> None: + # TODO: implement compute logic, e.g.: + # value = op_input.receive("in") + # logger.info("received: %s", value) + # op_output.emit(value, "out") + pass 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 new file mode 100644 index 0000000..04ce86b --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/CMakeLists.txt @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +# add_holohub_package() handles the full cascade for both standalone +# 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: +# +# 1. Defines option(PKG_{{ cookiecutter.module_repo_name.replace('-', '_') }} ... ${BUILD_ALL}) — ON +# when BUILD_ALL=ON (default standalone) or when the CLI passes the +# option explicitly. +# 2. add_subdirectory({{ cookiecutter.module_repo_name }}) — enters +# pkg/{{ cookiecutter.module_repo_name }}/ where holohub_configure_deb() emits +# CPackConfig-{{ cookiecutter.module_repo_name }}.cmake. +# 3. set("OP_" ON CACHE BOOL ... FORCE) and the equivalent for +# APPLICATIONS — cascades to the operator and application subprojects +# so the .deb gets actual content. pkg/ is add_subdirectory()'d +# *before* operators/ and applications/, so those see the forced ON +# value when their own helpers run option(). +add_holohub_package({{ cookiecutter.module_repo_name }} + OPERATORS {{ cookiecutter.operator_slug }} + APPLICATIONS {{ cookiecutter.module_slug }}_pipeline) diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/CMakeLists.txt new file mode 100644 index 0000000..babe3fc --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/CMakeLists.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +# This file is entered via add_holohub_package({{ cookiecutter.module_repo_name }} ...) in +# pkg/CMakeLists.txt, which only fires when PKG_{{ cookiecutter.module_repo_name.replace('-', '_') }} is ON. +include(holohub_configure_deb) + +holohub_configure_deb( + NAME "{{ cookiecutter.module_repo_name }}" + DESCRIPTION "${PROJECT_DESCRIPTION}" + VERSION "${PROJECT_VERSION}" + VENDOR "{% if cookiecutter.affiliation %}{{ cookiecutter.affiliation }}{% else %}NVIDIA{% endif %}" + CONTACT "{{ cookiecutter.contact_email }}" + DEPENDS "holoscan (>= {{ cookiecutter.holoscan_version }})" +{% if cookiecutter.language == 'cpp' %} EXPORT_NAME "holoscan_{{ cookiecutter.module_slug }}_targets" +{% endif %}) 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 new file mode 100644 index 0000000..a0c1cc6 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/README.md @@ -0,0 +1,24 @@ +# {{ cookiecutter.module_repo_name }} package + +This directory defines the Debian package for `{{ cookiecutter.module_repo_name }}`. + +## Usage + +```bash +holoscan package {{ cookiecutter.module_repo_name }} --pkg-generator DEB +``` + +## metadata.json + +`metadata.json` registers this package with the holohub CLI. Two fields matter: + +- **`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 `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, `holoscan package` instead selects the root `module` record and its + `module.dockerfile`. + +Looking for Python packaging? Review the project [pyproject.toml](../../pyproject.toml) +for configuration. diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/metadata.json b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/metadata.json new file mode 100644 index 0000000..50f7f6c --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pkg/{{cookiecutter.module_repo_name}}/metadata.json @@ -0,0 +1,5 @@ +{ + "package": { + "dockerfile": "Dockerfile" + } +} 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 new file mode 100644 index 0000000..e99439c --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pyproject.toml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +[build-system] +requires = ["scikit-build-core>=0.10"] +build-backend = "scikit_build_core.build" + +[project] +name = "holoscan-{{ cookiecutter.module_slug.replace('_', '-') }}" +version = "{{ cookiecutter.version }}" +description = "{{ cookiecutter.description }}" +readme = "README.md" +license = { text = "{{ cookiecutter._license }}" } +authors = [ + { name = "{{ cookiecutter.full_name }}" }, +] +requires-python = ">=3.10" + +# NOTE: holoscan SDK is not declared as a runtime dependency here because the +# wheel cannot guarantee binary compatibility across CUDA variants (cu12/cu13). +# Document the install command in the README. + +[tool.scikit-build] +cmake.version = ">=3.24" +# Build only what the wheel needs: turn off test and app subprojects, and +# turn off the deb option (which is build-type-driven, not wheel-driven). +cmake.args = [ + "-D{{ cookiecutter.module_slug | upper }}_BUILD_TESTING=OFF", + "-DBUILD_ALL=OFF", + "-DOP_{{ cookiecutter.operator_slug }}=ON", +] +# Wheel content comes from CMake install rules. Use *relative* DESTINATIONs in +# install(...) calls — scikit-build-core stages those into the wheel root. +# Setting wheel.install-dir = "/" would require the experimental flag. +wheel.packages = [] + +[tool.ruff] +line-length = 100 +target-version = "py310" +# cmake/pybind11 is a scaffolded third-party helper; 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] +select = ["E", "F", "I", "W", "UP"] +ignore = ["E501", "UP007"] + +[tool.ruff.lint.isort] +known-first-party = ["holoscan"] diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pytest.ini b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pytest.ini new file mode 100644 index 0000000..7f75c4f --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +testpaths = tests/python + +addopts = --tb=short -v -p no:cacheprovider + +log_cli = true +log_cli_level = INFO +log_format = %(levelname)s %(name)s:%(lineno)d — %(message)s + +markers = + requires_gpu: test runs a live Holoscan pipeline and needs a GPU + +timeout = 300 + +# PYTHONPATH is resolved by conftest.py via the {{ cookiecutter.module_slug | upper }}_BUILD_DIR +# environment variable (injected by CTest automatically). +# Manual invocation: {{ cookiecutter.module_slug | upper }}_BUILD_DIR=build pytest diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/python/holoscan/{{cookiecutter.module_slug}}/__init__.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/python/holoscan/{{cookiecutter.module_slug}}/__init__.py new file mode 100644 index 0000000..3b1c7c5 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/python/holoscan/{{cookiecutter.module_slug}}/__init__.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +# Re-export each operator from its per-operator subpackage so callers can +# write `from holoscan.{{ cookiecutter.module_slug }} import {{ op_class }}` +# in addition to the longer +# `from holoscan.{{ cookiecutter.module_slug }}.{{ cookiecutter.operator_slug }} import {{ op_class }}`. +# +# The per-operator subpackages and their `__init__.py` are generated by +# pybind11_add_holohub_module (cmake/pybind11_add_holohub_module.cmake) — the +# helper handles ABI error diagnostics there. +from .{{cookiecutter.operator_slug}} import {{ op_class }} + +__all__ = ["{{ op_class }}"] 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 0000000..4e6a5d6 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/requirements-cli.txt @@ -0,0 +1,5 @@ +# Generated by holoscan create; update deliberately and rebuild the development image. +{% if cookiecutter._holoscan_cli_prerelease %}# This pre-release may require NVIDIA's Python package index. +# Host: python -m pip install --extra-index-url https://pypi.nvidia.com -r requirements-cli.txt +# Image: pass --build-arg PIP_EXTRA_INDEX_URL=https://pypi.nvidia.com +{% endif %}holoscan-cli=={{ cookiecutter._holoscan_cli_version }} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/CMakeLists.txt b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/CMakeLists.txt new file mode 100644 index 0000000..f97d9f9 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/CMakeLists.txt @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +{% if cookiecutter.language == 'cpp' %}add_subdirectory(cpp) + +{% endif %}find_package(Python3 REQUIRED COMPONENTS Interpreter) + +add_test( + NAME pytest + COMMAND ${Python3_EXECUTABLE} -m pytest ${PROJECT_SOURCE_DIR}/tests/python/ -v --tb=short + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}) + +# PYTHONPATH must be *prepended* so an ambient holoscan SDK install on +# PYTHONPATH stays visible. Use ENVIRONMENT_MODIFICATION + path_list_prepend +# (CMake 3.22+) rather than splicing `:$ENV{PYTHONPATH}` into ENVIRONMENT: +# - Splicing evaluates $ENV{PYTHONPATH} at *configure* time, so a value +# exported between cmake and ctest invocations would be lost. +# - When the env var is empty/unset the spliced form yields a trailing +# colon (`PYTHONPATH=…ROOT:`), and Python treats an empty path entry as +# `.` — silently adding the test CWD to sys.path and letting any local +# file shadow installed packages. +# path_list_prepend handles unset/empty/set correctly and runs at test time. +# SKIP_RETURN_CODE 5 is the safety net for the env-broken case where the +# holoscan SDK still isn't importable (pytest collects zero items, exits 5). +set_tests_properties(pytest PROPERTIES + ENVIRONMENT "{{ cookiecutter.module_slug | upper }}_BUILD_DIR=${CMAKE_BINARY_DIR}" + ENVIRONMENT_MODIFICATION "PYTHONPATH=path_list_prepend:${{'{'}}{{ cookiecutter.module_slug | upper }}_PYTHON_ROOT}" + SKIP_RETURN_CODE 5 + LABELS "python") diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/cpp/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/cpp/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} new file mode 100644 index 0000000..24eb046 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/cpp/{% if cookiecutter.language == 'cpp' %}CMakeLists.txt{% endif %} @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} + +find_package(GTest REQUIRED) + +add_executable(test_operators test_operators.cpp) + +target_link_libraries(test_operators PRIVATE + holoscan::{{ cookiecutter.operator_slug }} + holoscan::core + GTest::gtest_main) + +target_include_directories(test_operators PRIVATE + ${PROJECT_SOURCE_DIR}/operators) + +include(GoogleTest) +gtest_discover_tests(test_operators PROPERTIES LABELS "unit") diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/cpp/{% if cookiecutter.language == 'cpp' %}test_operators.cpp{% endif %} b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/cpp/{% if cookiecutter.language == 'cpp' %}test_operators.cpp{% endif %} new file mode 100644 index 0000000..17a3eab --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/cpp/{% if cookiecutter.language == 'cpp' %}test_operators.cpp{% endif %} @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +// SPDX-License-Identifier: {{ cookiecutter._license }} +// +// GTest functional tests for {{ cookiecutter.operator_slug.split('_')|map('capitalize')|join('') }}. +// TODO: replace the placeholder with real pipeline coverage. +// See holoscan-example-module/tests/cpp/test_operators.cpp for a worked example. +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +#include + +#include +#include <{{ cookiecutter.operator_slug }}/{{ cookiecutter.operator_slug }}.hpp> + +// Convenience alias for holoscan::{{ cookiecutter.module_slug }} — use in real tests. +namespace mm = holoscan::{{ cookiecutter.module_slug }}; + +// TODO: implement minimal Application wrappers that exercise your operator, +// then write test functions that construct and run those apps and assert on results. + +TEST({{ op_class }}Test, Placeholder) { + // TODO: replace with a real pipeline test. + SUCCEED(); +} diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/python/conftest.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/python/conftest.py new file mode 100644 index 0000000..cc16658 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/python/conftest.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +# +# conftest.py — makes the build-tree Python package visible to pytest. +# +# Holoscan SDK is installed as a regular package (has __init__.py), so Python +# discards namespace-package directories alongside it. We extend holoscan.__path__ +# directly after import to insert our build-tree holoscan/ directory, making +# holoscan.{{ cookiecutter.module_slug }} resolvable to our compiled modules. + +import os + +build_dir = os.environ.get( + "{{ cookiecutter.module_slug | upper }}_BUILD_DIR", + os.path.join(os.path.dirname(__file__), "..", "..", "build"), +) + +try: + import holoscan # noqa: E402 + + build_holoscan_path = os.path.join(build_dir, "python", "lib", "holoscan") + if build_holoscan_path not in holoscan.__path__: + holoscan.__path__.insert(0, build_holoscan_path) +except ImportError: + # holoscan not importable (e.g. CUDA not available outside the dev container). + # Individual tests use pytest.importorskip("holoscan") to skip gracefully. + pass diff --git a/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/python/test_{{cookiecutter.operator_slug}}.py b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/python/test_{{cookiecutter.operator_slug}}.py new file mode 100644 index 0000000..84eb2c6 --- /dev/null +++ b/src/holoscan_cli/templates/module/{{cookiecutter.module_repo_name}}/tests/python/test_{{cookiecutter.operator_slug}}.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright (c) {% now 'utc', '%Y' %} {{ cookiecutter.full_name }}{% if cookiecutter.affiliation %} / {{ cookiecutter.affiliation }}{% endif %}. All rights reserved. +# SPDX-License-Identifier: {{ cookiecutter._license }} +# +# Functional tests for {{ cookiecutter.operator_slug.split('_')|map('capitalize')|join('') }}. +# TODO: extend test_placeholder() with real pipeline coverage. +# See holoscan-example-module/tests/python/ for worked examples. +{%- set op_class = cookiecutter.operator_slug.split('_')|map('capitalize')|join('') %} + +import importlib +import inspect + +import pytest + +# Skip the whole module only when the Holoscan SDK itself is unavailable +# (e.g. CUDA missing outside the dev container). We deliberately do NOT +# importorskip("holoscan.{{ cookiecutter.module_slug }}") at module level — that +# would mask an actual build/import failure of *our* module as a "Skipped" +# result, and CTest would silently report success-with-zero-tests (exit code 5). +pytest.importorskip("holoscan", reason="holoscan SDK not installed", exc_type=ImportError) + + +EXPECTED_OPERATORS = ("{{ op_class }}",) + + +@pytest.mark.parametrize("operator_name", EXPECTED_OPERATORS) +def test_operator_is_importable(operator_name): + module = importlib.import_module("holoscan.{{ cookiecutter.module_slug }}") + + assert hasattr(module, operator_name), ( + f"holoscan.{{ cookiecutter.module_slug }} does not expose {operator_name}; " + f"available names: {sorted(n for n in dir(module) if not n.startswith('_'))}" + ) + operator_cls = getattr(module, operator_name) + assert inspect.isclass(operator_cls), ( + f"holoscan.{{ cookiecutter.module_slug }}.{operator_name} is not a class (got {type(operator_cls)!r})" + ) + + +def test_placeholder(): + # TODO: build a minimal Application, call app.run(), assert on results. + pass diff --git a/src/holoscan_cli/utils/env_info.py b/src/holoscan_cli/utils/env_info.py index 6cc334f..493fed5 100644 --- a/src/holoscan_cli/utils/env_info.py +++ b/src/holoscan_cli/utils/env_info.py @@ -30,6 +30,7 @@ from typing import List, Optional import holoscan_cli +from holoscan_cli.project_context import ProjectContext, get_active_project_context from holoscan_cli.utils.holohub import get_sccache_dir from holoscan_cli.utils.io import Color, run_info_command from holoscan_cli.utils.json_output import dumps as json_dumps @@ -125,6 +126,16 @@ def collect_holohub_info( print(f" HOLOSCAN_CLI_SDK_DIR: {sdk_dir}") +def collect_project_context_info(context: Optional[ProjectContext] = None) -> None: + """Display standalone Module profile and exact-version contract details.""" + context = context or get_active_project_context() + if context is None: + return + print(f"\n{Color.blue('Project Context:')}") + for key, value in context.diagnostics().items(): + print(f" {key}: {value if value is not None else '(not set)'}") + + def collect_git_info(holohub_root: Path) -> None: """Collect and display Git repository information""" print(f"\n{Color.blue('Git Repository Information:')}") @@ -218,6 +229,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", @@ -360,6 +372,12 @@ def gather_source_project_info( } +def gather_project_context_info(context: Optional[ProjectContext] = None) -> Optional[dict]: + """Return structured Module profile/version details, when activated.""" + context = context or get_active_project_context() + return context.diagnostics() if context is not None else None + + def gather_git_info(holohub_root: Path) -> Optional[dict]: """Structured git state for ``holohub_root``, or ``None`` when unavailable.""" if not holohub_root.exists() or not holohub_root.is_dir(): @@ -462,6 +480,7 @@ def format_env_info_json(holohub_root: Path, build_dir: Path, data_dir: Path, sd "system": gather_system_info(), "python": gather_python_info(), "source_project": gather_source_project_info(holohub_root, build_dir, data_dir, sdk_dir), + "project_context": gather_project_context_info(), "git": gather_git_info(holohub_root), "docker": gather_docker_info(), "cuda_gpu": gather_cuda_gpu_info(), diff --git a/src/holoscan_cli/utils/holohub.py b/src/holoscan_cli/utils/holohub.py index d080726..9d86377 100644 --- a/src/holoscan_cli/utils/holohub.py +++ b/src/holoscan_cli/utils/holohub.py @@ -33,6 +33,7 @@ from pathlib import Path from typing import Mapping, Optional, Tuple +from holoscan_cli.project_context import 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 @@ -88,38 +89,10 @@ 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 + context = discover_project_context(load_module_contract=False) + for message in context.warnings: + warn(message) + return context.root HOLOHUB_ROOT = _get_holohub_root() diff --git a/src/holoscan_cli/version/version.py b/src/holoscan_cli/version/version.py index bbf5632..47b80ff 100644 --- a/src/holoscan_cli/version/version.py +++ b/src/holoscan_cli/version/version.py @@ -18,6 +18,7 @@ from pathlib import Path from holoscan_cli import __version__ +from holoscan_cli.project_context import ProjectContext, get_active_project_context from holoscan_cli.utils.json_output import dumps as json_dumps PACKAGE_NAME = "holoscan-cli" @@ -30,18 +31,31 @@ def get_package_version() -> str: return __version__ -def collect_version_info() -> dict: +def collect_version_info(context: ProjectContext | None = None) -> dict: """Return the version fields shared by the prose and JSON renderers.""" - return { + info = { "package": PACKAGE_NAME, "version": get_package_version(), "executable": str(Path(sys.argv[0]).resolve()), "module": str(Path(__file__).resolve()), } + context = context or get_active_project_context() + if context is not None and context.is_module: + info.update( + { + "project_root": str(context.root), + "requirements_file": str(context.requirements_path), + "required_version": context.required_version, + "version_match": context.version_match, + } + ) + if context.requirement_error: + info["requirement_error"] = context.requirement_error + return info def execute_version_command(args: Namespace): - info = collect_version_info() + info = collect_version_info(getattr(args, "project_context", None)) if getattr(args, "json", False): print(json_dumps(info)) return @@ -49,3 +63,9 @@ def execute_version_command(args: Namespace): print(f"Version: {info['version']}") print(f"Executable: {info['executable']}") print(f"Module: {info['module']}") + if "project_root" in info: + print(f"Project: {info['project_root']}") + print(f"Requirement: {info.get('required_version') or '(invalid or missing)'}") + print(f"Version match: {info.get('version_match')}") + if info.get("requirement_error"): + print(f"Requirement error: {info['requirement_error']}") diff --git a/tests/unit/test_create_module.py b/tests/unit/test_create_module.py index 29c097f..dbb5e74 100644 --- a/tests/unit/test_create_module.py +++ b/tests/unit/test_create_module.py @@ -1,36 +1,13 @@ # 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. -""" + +"""Behavior tests for standalone Module creation.""" from __future__ import annotations import argparse +import json +import subprocess from pathlib import Path from types import SimpleNamespace @@ -40,10 +17,9 @@ def _make_args(**overrides) -> argparse.Namespace: - """Build a ``--dryrun``-shaped Namespace with sensible defaults.""" defaults = dict( project="My Mod", - template="modules/template", + template=None, language="python", dryrun=True, directory=None, @@ -56,119 +32,577 @@ def _make_args(**overrides) -> argparse.Namespace: @pytest.fixture() def fake_cli(tmp_path): - """Stand-in for the real ``HoloscanCLI`` — handle_create only reads - ``HOLOHUB_ROOT`` and ``script_name``.""" return SimpleNamespace(HOLOHUB_ROOT=tmp_path, script_name="holoscan") -# ---- dryrun smoke ------------------------------------------------------------ +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_name }}", + "module_repo_name": "holoscan-{{ cookiecutter.module_slug }}", + } + ) + (template / "cookiecutter.json").write_text(json.dumps(context), encoding="utf-8") + return template + + +def _assert_generated_sources_and_metadata(project: Path) -> None: + from holoscan_cli.metadata import metadata_validator + + for metadata_path in project.rglob("metadata.json"): + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + is_valid, message = metadata_validator.validate_json(metadata, metadata_path.parent) + assert is_valid, f"{metadata_path}: {message}" + + for source_path in project.rglob("*.py"): + relative = source_path.relative_to(project) + if relative.parts[:2] == ("cmake", "pybind11"): + # This helper is configured by CMake before becoming Python source. + continue + compile(source_path.read_text(encoding="utf-8"), str(source_path), "exec") + + +def test_default_uses_packaged_module_template_and_current_directory( + fake_cli, tmp_path, capsys, monkeypatch +): + monkeypatch.chdir(tmp_path) + + create.handle_create(fake_cli, _make_args()) + + output = capsys.readouterr().out + assert "Template: packaged Module template" in output + assert f"Directory: {tmp_path / 'holoscan-my-mod'}" in output + + +def test_missing_legacy_module_template_resolves_to_packaged_alias(fake_cli, tmp_path, capsys): + output_parent = tmp_path / "output" + + create.handle_create( + fake_cli, + _make_args(template="modules/template", directory=output_parent), + ) + + output = capsys.readouterr().out + assert "Template: packaged Module template" in output + assert str(output_parent / "holoscan-my-mod") in output + + +def test_existing_legacy_module_template_wins_over_alias(fake_cli, tmp_path, capsys): + template = _write_template(tmp_path, "modules/template", module=True) + + create.handle_create( + fake_cli, + _make_args(template="modules/template", directory=tmp_path / "output"), + ) + + output = capsys.readouterr().out + assert f"Template: {template}" in output + assert "packaged Module template" not in output + + +def test_wrapper_environment_selects_application_default(fake_cli, tmp_path, capsys, monkeypatch): + template = _write_template(tmp_path, "applications/template", module=False) + monkeypatch.setenv(create.CREATE_TEMPLATE_ENV, "applications/template") + + create.handle_create(fake_cli, _make_args()) + + output = capsys.readouterr().out + assert f"Template: {template}" in output + assert str(tmp_path / "applications" / "my_mod") in output + assert "applications/CMakeLists.txt" in output + + +def test_explicit_template_overrides_wrapper_environment(fake_cli, tmp_path, capsys, monkeypatch): + _write_template(tmp_path, "applications/template", module=False) + explicit = _write_template(tmp_path, "custom/module", module=True) + monkeypatch.setenv(create.CREATE_TEMPLATE_ENV, "applications/template") + + create.handle_create( + fake_cli, + _make_args(template="custom/module", directory=tmp_path / "output"), + ) + + output = capsys.readouterr().out + assert f"Template: {explicit}" in output + assert str(tmp_path / "output" / "holoscan-my-mod") in output + + +def test_template_classification_uses_cookiecutter_context_not_path(tmp_path): + module_template = _write_template(tmp_path, "looks-like-an-application", module=True) + app_template = _write_template(tmp_path, "modules/not-a-module", module=False) + assert create._is_module_template(create._template_context(module_template)) + assert not create._is_module_template(create._template_context(app_template)) -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) - 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_missing_explicit_template_is_fatal(fake_cli, tmp_path, capsys): + missing = tmp_path / "missing-template" + + with pytest.raises(SystemExit): + create.handle_create(fake_cli, _make_args(template=str(missing))) + + assert str(missing) in capsys.readouterr().err + + +def test_dryrun_does_not_create_missing_output_parent(fake_cli, tmp_path): + output_parent = tmp_path / "missing" / "nested" + + create.handle_create(fake_cli, _make_args(directory=output_parent)) + + assert not output_parent.exists() + + +def test_create_makes_missing_output_parents(fake_cli, tmp_path, monkeypatch): + output_parent = tmp_path / "missing" / "nested" + + def fake_generate(_cli, _template, *, interactive, context, output_dir): + assert not interactive + assert context["_holoscan_cli_version"] == create.__version__ + assert output_dir.parent == output_parent + assert output_dir.name.startswith(".holoscan-my-mod.holoscan-create-") + assert output_dir.is_dir() + project = output_dir / "holoscan-my-mod" + project.mkdir() + return str(project) + + monkeypatch.setattr(create, "_run_cookiecutter", fake_generate) + monkeypatch.setattr(create, "validate_generated_metadata", lambda *_args: None) + + create.handle_create( + fake_cli, + _make_args(dryrun=False, directory=output_parent), + ) + + assert (output_parent / "holoscan-my-mod").is_dir() + + +def test_existing_project_is_not_overwritten(fake_cli, tmp_path, monkeypatch, capsys): + output_parent = tmp_path / "output" + project = output_parent / "holoscan-my-mod" + project.mkdir(parents=True) + marker = project / "keep.txt" + marker.write_text("keep", encoding="utf-8") + called = False + + def fail_if_called(*_args, **_kwargs): + nonlocal called + called = True + + monkeypatch.setattr(create, "_run_cookiecutter", fail_if_called) + + with pytest.raises(SystemExit): + create.handle_create( + fake_cli, + _make_args(dryrun=False, directory=output_parent), + ) + + assert not called + assert marker.read_text(encoding="utf-8") == "keep" + assert str(project) in capsys.readouterr().err + + +def _install_fake_generator(monkeypatch, *, filename: str = "generated.txt"): + def fake_generate(_cli, _template, *, interactive, context, output_dir): + del interactive, context + project = output_dir / "holoscan-my-mod" + project.mkdir() + (project / filename).write_text("generated", encoding="utf-8") + return str(project) + + monkeypatch.setattr(create, "_run_cookiecutter", fake_generate) + monkeypatch.setattr(create, "validate_generated_metadata", lambda *_args: None) + + +def test_existing_empty_project_directory_is_populated(fake_cli, tmp_path, monkeypatch): + output_parent = tmp_path / "output" + project = output_parent / "holoscan-my-mod" + project.mkdir(parents=True) + _install_fake_generator(monkeypatch) + initialized = [] + monkeypatch.setattr( + create, + "_initialize_module_git", + lambda path: initialized.append(path) is None, + ) + + create.handle_create(fake_cli, _make_args(dryrun=False, directory=output_parent)) + + assert (project / "generated.txt").read_text(encoding="utf-8") == "generated" + assert initialized == [project] + + +def test_git_only_destination_is_populated_without_git_mutation(fake_cli, tmp_path, monkeypatch): + output_parent = tmp_path / "output" + project = output_parent / "holoscan-my-mod" + git_dir = project / ".git" + git_dir.mkdir(parents=True) + marker = git_dir / "config" + marker.write_bytes(b"remote configuration\n") + before = (marker.read_bytes(), marker.stat().st_ino, git_dir.stat().st_ino) + _install_fake_generator(monkeypatch) + monkeypatch.setattr( + create, + "_initialize_module_git", + lambda _path: pytest.fail("existing Git state must not be initialized"), + ) + + create.handle_create(fake_cli, _make_args(dryrun=False, directory=output_parent)) + + after = (marker.read_bytes(), marker.stat().st_ino, git_dir.stat().st_ino) + assert after == before + assert (project / "generated.txt").is_file() + + +def test_precloned_git_head_index_and_remote_are_preserved(fake_cli, tmp_path, monkeypatch): + output_parent = tmp_path / "output" + project = output_parent / "holoscan-my-mod" + project.mkdir(parents=True) + subprocess.run(["git", "init", "."], cwd=project, check=True, capture_output=True) + subprocess.run( + ["git", "symbolic-ref", "HEAD", "refs/heads/review"], + cwd=project, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", "ssh://example.invalid/module.git"], + cwd=project, + check=True, + capture_output=True, + ) + subprocess.run(["git", "read-tree", "--empty"], cwd=project, check=True, capture_output=True) + git_dir = project / ".git" + before = {name: (git_dir / name).read_bytes() for name in ("HEAD", "config", "index")} + _install_fake_generator(monkeypatch) + + create.handle_create(fake_cli, _make_args(dryrun=False, directory=output_parent)) + + after = {name: (git_dir / name).read_bytes() for name in before} + assert after == before + assert (project / "generated.txt").is_file() + + +def test_worktree_git_pointer_is_preserved(fake_cli, tmp_path, monkeypatch): + output_parent = tmp_path / "output" + project = output_parent / "holoscan-my-mod" + project.mkdir(parents=True) + git_pointer = project / ".git" + git_pointer.write_text("gitdir: ../storage/worktree\n", encoding="utf-8") + before = (git_pointer.read_bytes(), git_pointer.stat().st_ino) + _install_fake_generator(monkeypatch) + monkeypatch.setattr( + create, + "_initialize_module_git", + lambda _path: pytest.fail("worktree Git state must not be initialized"), + ) + + create.handle_create(fake_cli, _make_args(dryrun=False, directory=output_parent)) + assert (git_pointer.read_bytes(), git_pointer.stat().st_ino) == before + assert (project / "generated.txt").is_file() -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) - 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 +def test_git_symlink_destination_is_rejected(fake_cli, tmp_path, monkeypatch): + output_parent = tmp_path / "output" + project = output_parent / "holoscan-my-mod" + project.mkdir(parents=True) + (project / ".git").symlink_to(tmp_path / "external-git", target_is_directory=True) + called = False + + def fail_if_called(*_args, **_kwargs): + nonlocal called + called = True + + monkeypatch.setattr(create, "_run_cookiecutter", fail_if_called) + + with pytest.raises(SystemExit): + create.handle_create(fake_cli, _make_args(dryrun=False, directory=output_parent)) + + assert not called + assert (project / ".git").is_symlink() + + +def test_destination_change_during_generation_is_not_overwritten(tmp_path): + destination = tmp_path / "project" + destination.mkdir() + initial_state = create._inspect_target(destination) + staged = tmp_path / "staged" + staged.mkdir() + (staged / "generated.txt").write_text("generated", encoding="utf-8") + raced = destination / "raced.txt" + raced.write_text("keep", encoding="utf-8") + + with pytest.raises(create._MaterializationError, match="changed during generation"): + create._materialize_staged_project(staged, destination, initial_state) + + assert raced.read_text(encoding="utf-8") == "keep" + assert not (destination / "generated.txt").exists() + + +def test_materialization_failure_rolls_back_only_created_paths(tmp_path, monkeypatch): + destination = tmp_path / "project" + destination.mkdir() + initial_state = create._inspect_target(destination) + staged = tmp_path / "staged" + staged.mkdir() + (staged / "a.txt").write_text("a", encoding="utf-8") + (staged / "b.txt").write_text("b", encoding="utf-8") + calls = 0 + real_copy = create.shutil.copyfileobj + + def fail_second_copy(source, target): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("simulated copy failure") + return real_copy(source, target) + + monkeypatch.setattr(create.shutil, "copyfileobj", fail_second_copy) + + with pytest.raises(create._MaterializationError, match="simulated copy failure"): + create._materialize_staged_project(staged, destination, initial_state) + + assert list(destination.iterdir()) == [] + + +def test_dangling_project_symlink_is_not_overwritten(fake_cli, tmp_path): + output_parent = tmp_path / "output" + output_parent.mkdir() + project = output_parent / "holoscan-my-mod" + project.symlink_to(tmp_path / "missing-target", target_is_directory=True) + + with pytest.raises(SystemExit): + create.handle_create(fake_cli, _make_args(directory=output_parent)) + + assert project.is_symlink() + + +def test_blocking_output_ancestor_has_actionable_error(fake_cli, tmp_path, capsys): + blocker = tmp_path / "not-a-directory" + blocker.write_text("block", encoding="utf-8") + output_parent = blocker / "nested" + + with pytest.raises(SystemExit): + create.handle_create( + fake_cli, + _make_args(dryrun=False, directory=output_parent), + ) + + error = capsys.readouterr().err + assert str(output_parent) in error + assert "Choose a writable --directory" in error + + +def test_output_parent_permission_error_is_actionable(tmp_path, capsys, monkeypatch): + output_parent = tmp_path / "denied" / "nested" + + def deny_mkdir(_path, *, parents, exist_ok): + assert parents + assert exist_ok + raise PermissionError("permission denied by test") + + monkeypatch.setattr(Path, "mkdir", deny_mkdir) + + with pytest.raises(SystemExit): + create._ensure_output_parent(output_parent) + + error = capsys.readouterr().err + assert str(output_parent) in error + assert "permission denied by test" in error + assert "Choose a writable --directory" in error + + +def test_context_can_override_predicted_module_repo_name(fake_cli, tmp_path, capsys): + create.handle_create( + fake_cli, + _make_args( + directory=tmp_path / "output", + context=["module_repo_name=custom-repository"], + ), + ) + + assert str(tmp_path / "output" / "custom-repository") in capsys.readouterr().out + + +def test_project_output_must_be_direct_child(fake_cli, tmp_path, capsys): + output_parent = tmp_path / "output" + + with pytest.raises(SystemExit): + create.handle_create( + fake_cli, + _make_args( + directory=output_parent, + context=["module_repo_name=../escaped"], + ), + ) + + assert "one directory name" in capsys.readouterr().err + assert not output_parent.exists() + assert not (tmp_path / "escaped").exists() 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) - captured = capsys.readouterr().out - assert "holoscan_version" not in captured + create.handle_create(fake_cli, _make_args(directory=tmp_path / "output")) + assert "holoscan_version" not in capsys.readouterr().out -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_parser_defaults_select_packaged_template_and_implicit_directory(): + parser = argparse.ArgumentParser() + cli_stub = SimpleNamespace(HOLOHUB_ROOT=Path("/dev/null"), script_name="holoscan") + subparsers = parser.add_subparsers() + create.register_create_parser(cli_stub, subparsers) + + args = parser.parse_args(["create", "MyProj"]) + + assert args.template is None + assert args.directory is None - captured = capsys.readouterr().out - assert str(out_dir / "holoscan-my-mod") in captured +def test_missing_cookiecutter_points_to_create_extra(fake_cli, tmp_path, capsys, monkeypatch): + def missing_cookiecutter(_module_name): + raise ImportError + + monkeypatch.setattr(create.importlib, "import_module", missing_cookiecutter) -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) with pytest.raises(SystemExit): - create.handle_create(fake_cli, args) + create._run_cookiecutter( + fake_cli, + tmp_path, + interactive=False, + context={}, + output_dir=tmp_path, + ) + + assert "pip install 'holoscan-cli[create]'" in capsys.readouterr().err -# ---- detection edge cases ---------------------------------------------------- +def test_packaged_template_generates_self_contained_python_module( + fake_cli, tmp_path, capsys, monkeypatch +): + pytest.importorskip("cookiecutter") + output_parent = tmp_path / "output" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.delenv("HOLOSCAN_CLI_ROOT", raising=False) + + create.handle_create( + fake_cli, + _make_args(dryrun=False, directory=output_parent), + ) + + project = output_parent / "holoscan-my-mod" + expected = [ + "cmake/HoloHubConfigHelpers.cmake", + "cmake/holohub_configure_deb.cmake", + "cmake/Config.cmake.in", + "cmake/pybind11_add_holohub_module.cmake", + "cmake/pybind11/__init__.py", + "cmake/pydoc/macros.hpp", + ".github/workflows/scripts/check_copyright.py", + ".github/workflows/scripts/gitutils.py", + "operators/my_mod_op/my_mod_op.py", + "applications/my_mod_pipeline/python/my_mod_pipeline.py", + "requirements-cli.txt", + ".dockerignore", + ".holoscan-cli-wheelhouse/.gitignore", + ] + assert all((project / path).is_file() for path in expected) + assert not (project / "holohub").exists() + assert not (project / "holoscan").exists() + active_requirements = [ + line + for line in (project / "requirements-cli.txt").read_text(encoding="utf-8").splitlines() + if line and not line.startswith("#") + ] + assert active_requirements == [f"holoscan-cli=={create.__version__}"] + assert not any( + "./holohub" in path.read_text(encoding="utf-8", errors="ignore") + for path in project.rglob("*") + if path.is_file() + ) + dockerfile = (project / "Dockerfile").read_text(encoding="utf-8") + assert "ARG PIP_EXTRA_INDEX_URL" in dockerfile + assert "source=.holoscan-cli-wheelhouse" in dockerfile + assert "-r /tmp/requirements-cli.txt" in dockerfile + assert "holohub" not in dockerfile + dockerignore = (project / ".dockerignore").read_text(encoding="utf-8").splitlines() + assert "requirements-cli.txt" not in dockerignore + assert ".holoscan-cli-wheelhouse" not in dockerignore + app_metadata = (project / "applications/my_mod_pipeline/python/metadata.json").read_text( + encoding="utf-8" + ) + assert "holohub_app_bin" in app_metadata + assert "" in app_metadata + _assert_generated_sources_and_metadata(project) + output = capsys.readouterr() + assert "HoloHub root not found" not in output.out + output.err + assert "Validated metadata.json" in output.out @pytest.mark.parametrize( - "template,is_module", - [ - ("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), - ], + "version,expects_index_hint", + [("4.6.0", False), ("4.6.0rc3", True), ("5.0.0a123", True)], ) -def test_module_template_detection_keys_on_path_parts( - fake_cli, tmp_path, template, is_module, capsys +def test_generated_requirement_pins_runtime_and_hints_for_prereleases( + fake_cli, tmp_path, monkeypatch, version, expects_index_hint ): - """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).""" - 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 + pytest.importorskip("cookiecutter") + monkeypatch.setattr(create, "__version__", version) + monkeypatch.setattr(create, "_initialize_module_git", lambda _path: False) + output_parent = tmp_path / "output" + + create.handle_create( + fake_cli, + _make_args(dryrun=False, directory=output_parent), + ) + + requirement = (output_parent / "holoscan-my-mod" / "requirements-cli.txt").read_text( + encoding="utf-8" + ) + active = [line for line in requirement.splitlines() if line and not line.startswith("#")] + assert active == [f"holoscan-cli=={version}"] + assert ("--extra-index-url https://pypi.nvidia.com" in requirement) is expects_index_hint + assert ("PIP_EXTRA_INDEX_URL=https://pypi.nvidia.com" in requirement) is expects_index_hint + + +def test_packaged_template_generates_self_contained_cpp_module(fake_cli, tmp_path, monkeypatch): + pytest.importorskip("cookiecutter") + output_parent = tmp_path / "output" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.delenv("HOLOSCAN_CLI_ROOT", raising=False) + + create.handle_create( + fake_cli, + _make_args( + project="Cpp Mod", + language="cpp", + dryrun=False, + directory=output_parent, + ), + ) + + project = output_parent / "holoscan-cpp-mod" + expected = [ + ".clang-format", + "operators/cpp_mod_op/cpp_mod_op.cpp", + "operators/cpp_mod_op/cpp_mod_op.hpp", + "operators/cpp_mod_op/python/CMakeLists.txt", + "operators/cpp_mod_op/python/_cpp_mod_op_bindings.cpp", + "applications/cpp_mod_pipeline/cpp/metadata.json", + "applications/cpp_mod_pipeline/cpp/cpp_mod_pipeline.cpp", + "tests/cpp/CMakeLists.txt", + "tests/cpp/test_operators.cpp", + ] + assert all((project / path).is_file() for path in expected) + assert not any("{%" in path.name for path in project.rglob("*")) + _assert_generated_sources_and_metadata(project) diff --git a/tests/unit/test_package_data.py b/tests/unit/test_package_data.py index 68f940e..0b7bd38 100644 --- a/tests/unit/test_package_data.py +++ b/tests/unit/test_package_data.py @@ -72,6 +72,24 @@ "requirements.template.txt", } +REQUIRED_MODULE_TEMPLATE_FILES = { + "cookiecutter.json", + "hooks/post_gen_project.py", + "{{cookiecutter.module_repo_name}}/requirements-cli.txt", + "{{cookiecutter.module_repo_name}}/.dockerignore", + "{{cookiecutter.module_repo_name}}/.holoscan-cli-wheelhouse/.gitignore", + "{{cookiecutter.module_repo_name}}/Dockerfile", + "{{cookiecutter.module_repo_name}}/cmake/HoloHubConfigHelpers.cmake", + "{{cookiecutter.module_repo_name}}/cmake/holohub_configure_deb.cmake", + "{{cookiecutter.module_repo_name}}/cmake/Config.cmake.in", + "{{cookiecutter.module_repo_name}}/cmake/pybind11_add_holohub_module.cmake", + "{{cookiecutter.module_repo_name}}/cmake/pybind11/__init__.py", + "{{cookiecutter.module_repo_name}}/cmake/pydoc/macros.hpp", + ("{{cookiecutter.module_repo_name}}/.github/workflows/scripts/" "check_copyright.py"), + "{{cookiecutter.module_repo_name}}/.github/workflows/scripts/gitutils.py", + ("{{cookiecutter.module_repo_name}}/.github/workflows/scripts/" "validate_metadata.py"), +} + PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml" README = Path(__file__).resolve().parents[2] / "README.md" @@ -132,6 +150,17 @@ def test_setup_scripts_are_packaged(): assert not missing, f"missing bundled setup scripts: {missing}" +def test_standalone_module_template_assets_are_packaged(): + """The default creator must not reach back into a HoloHub checkout.""" + template = importlib.resources.files("holoscan_cli.templates").joinpath("module") + missing = [ + relative + for relative in sorted(REQUIRED_MODULE_TEMPLATE_FILES) + if not template.joinpath(relative).is_file() + ] + assert not missing, f"missing bundled Module template assets: {missing}" + + def test_bundled_template_script_uses_bundled_requirements(tmp_path): """The fallback template setup script must not depend on HoloHub's ``utilities/requirements.template.txt`` being present.""" @@ -294,6 +323,7 @@ def test_pyproject_create_extra_bundles_validator_deps(): "jsonschema", "referencing", "cookiecutter", + "packaging", }, create_specs jsonschema_spec = next(spec for spec in create_specs if spec.startswith("jsonschema")) diff --git a/tests/unit/test_project_context.py b/tests/unit/test_project_context.py new file mode 100644 index 0000000..3ea2e20 --- /dev/null +++ b/tests/unit/test_project_context.py @@ -0,0 +1,426 @@ +# 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 + +import pytest + +from holoscan_cli.project_context import ( + ProjectContextError, + ProjectVersionError, + _is_container, + activate_project_context, + discover_project_context, + enforce_project_requirement, + get_running_cli_version, + parse_cli_requirement, +) + + +def _write_module( + root: Path, + *, + required_version: str | None = None, + full_layout: bool = True, + launcher: str | None = None, +) -> Path: + root.mkdir(parents=True) + metadata = { + "module": { + "name": "holoscan-my-sensor", + "namespace": {"python": "holoscan.my_sensor"}, + "holoscan_sdk": {"minimum_required_version": "4.6.0"}, + "dockerfile": "Dockerfile", + } + } + (root / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + if full_layout: + (root / "applications").mkdir() + (root / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + if required_version is not None: + (root / "requirements-cli.txt").write_text( + f"# generated\nholoscan-cli=={required_version}\n", encoding="utf-8" + ) + if launcher: + (root / launcher).write_text("#!/bin/sh\n", encoding="utf-8") + return root + + +def _subprocess_env() -> dict[str, str]: + source = Path(__file__).resolve().parents[2] / "src" + return {**os.environ, "PYTHONPATH": str(source)} + + +def test_requirement_parser_accepts_comments_and_one_exact_pin(tmp_path): + requirement = tmp_path / "requirements-cli.txt" + requirement.write_text( + "# resolver hint\n\n holoscan-cli==5.0.0a123+branch.1 \n", encoding="utf-8" + ) + + assert parse_cli_requirement(requirement) == "5.0.0a123+branch.1" + + +@pytest.mark.parametrize( + "contents", + [ + "", + "holoscan-cli>=4.6\n", + "holoscan-cli[create]==4.6.0\n", + "holoscan-cli @ git+https://example.invalid/repo\n", + "--extra-index-url https://example.invalid\nholoscan-cli==4.6.0\n", + "holoscan-cli==4.6.0; python_version > '3.10'\n", + "holoscan-cli==4.6.0\nholoscan-cli==4.6.1\n", + ], +) +def test_requirement_parser_rejects_non_contract_content(tmp_path, contents): + requirement = tmp_path / "requirements-cli.txt" + requirement.write_text(contents, encoding="utf-8") + + with pytest.raises(ProjectContextError): + parse_cli_requirement(requirement) + + +def test_full_module_is_discovered_from_descendant(tmp_path): + root = _write_module( + tmp_path / "holoscan-my-sensor", required_version=get_running_cli_version() + ) + descendant = root / "applications" / "pipeline" / "python" + descendant.mkdir(parents=True) + + context = discover_project_context(cwd=descendant, environ={}) + + assert context.root == root + assert context.is_standalone_module + assert context.repo_prefix == "my_sensor" + assert context.container_prefix == "my-sensor" + assert context.base_sdk_version == "4.6.0" + + +def test_metadata_only_nested_module_does_not_steal_holohub_root(tmp_path): + holohub = tmp_path / "holohub" + (holohub / "applications").mkdir(parents=True) + (holohub / "holohub").write_text("#!/bin/sh\n", encoding="utf-8") + nested = _write_module( + holohub / "modules" / "holoscan-my-sensor", + required_version=get_running_cli_version(), + full_layout=False, + ) + + implicit = discover_project_context(cwd=nested, environ={}) + explicit = discover_project_context(cwd=tmp_path, explicit_root=nested, environ={}) + + assert implicit.root == holohub + assert implicit.kind == "source" + assert explicit.root == nested + assert explicit.is_standalone_module + + +def test_explicit_project_root_precedes_environment(tmp_path): + selected = _write_module(tmp_path / "selected", required_version=get_running_cli_version()) + other = tmp_path / "other" + other.mkdir() + + context = discover_project_context( + cwd=tmp_path, + explicit_root=selected, + environ={"HOLOSCAN_CLI_ROOT": str(other)}, + ) + + assert context.root == selected + assert context.discovery == "project-root" + + +def test_invalid_explicit_project_root_is_specific(tmp_path): + with pytest.raises(ProjectContextError, match="--project-root"): + discover_project_context(cwd=tmp_path, explicit_root=tmp_path / "missing", environ={}) + + +def test_module_profile_sets_defaults_but_preserves_explicit_environment(tmp_path, monkeypatch): + root = _write_module(tmp_path / "module", required_version=get_running_cli_version()) + context = discover_project_context(cwd=root, environ={}) + monkeypatch.delenv("HOLOSCAN_CLI_ROOT", raising=False) + monkeypatch.setenv("HOLOSCAN_CLI_DATA_DIR", "/explicit/data") + for name in ( + "HOLOSCAN_CLI_BUILD_PARENT_DIR", + "HOLOSCAN_CLI_SEARCH_PATH", + "HOLOSCAN_CLI_REPO_PREFIX", + "HOLOSCAN_CLI_CONTAINER_PREFIX", + "HOLOSCAN_CLI_WORKSPACE_NAME", + "HOLOSCAN_CLI_HOSTNAME_PREFIX", + "HOLOSCAN_CLI_BASE_SDK_VERSION", + "HOLOSCAN_CLI_PATH_PREFIX", + ): + monkeypatch.delenv(name, 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"] == "/explicit/data" + assert os.environ["HOLOSCAN_CLI_REPO_PREFIX"] == "my_sensor" + assert os.environ["HOLOSCAN_CLI_CONTAINER_PREFIX"] == "my-sensor" + assert os.environ["HOLOSCAN_CLI_SEARCH_PATH"].startswith("metadata.json,applications") + assert "HOLOSCAN_CLI_PATH_PREFIX" not in os.environ + + +def test_host_requirement_mismatch_has_install_guidance(tmp_path): + root = _write_module(tmp_path / "module", required_version="999.0.0") + context = discover_project_context(cwd=root, environ={}, running_version="1.0.0") + + with pytest.raises(ProjectVersionError) as exc_info: + enforce_project_requirement(context, in_container=False) + + message = str(exc_info.value) + assert "requires holoscan-cli==999.0.0" in message + assert "python" in message.lower() + assert "-m pip install -r" in message + + +def test_container_requirement_mismatch_never_installs(tmp_path): + root = _write_module(tmp_path / "module", required_version="999.0.0") + context = discover_project_context(cwd=root, environ={}, running_version="1.0.0") + + with pytest.raises(ProjectVersionError) as exc_info: + enforce_project_requirement(context, in_container=True) + + message = str(exc_info.value) + assert "Rebuild the development image" in message + assert "will not install or modify" in message + assert "pip install" not in message + + +def test_container_guidance_uses_cli_recursion_marker(monkeypatch): + monkeypatch.delenv("HOLOSCAN_CLI_BUILD_LOCAL", raising=False) + assert not _is_container() + + monkeypatch.setenv("HOLOSCAN_CLI_BUILD_LOCAL", "1") + assert _is_container() + + monkeypatch.setenv("HOLOSCAN_CLI_BUILD_LOCAL", "false") + assert not _is_container() + + +def test_legacy_module_launcher_remains_unlocked(tmp_path): + root = _write_module(tmp_path / "module", launcher="holohub") + context = discover_project_context(cwd=root, environ={}, running_version="1.0.0") + + enforce_project_requirement(context) + + assert context.is_module + assert context.legacy_launcher + assert not context.is_standalone_module + + +def test_main_and_registry_imports_preserve_profile_barrier(): + blocked = ( + "holoscan_cli.cli", + "holoscan_cli.container.core", + "holoscan_cli.utils.holohub", + ) + script = ( + "import sys; import holoscan_cli.__main__; import holoscan_cli.commands.registry; " + f"blocked={blocked!r}; " + "assert not [name for name in blocked if name in sys.modules]" + ) + + subprocess.run([sys.executable, "-c", script], check=True, env=_subprocess_env()) + + +def test_project_root_must_precede_subcommand(tmp_path): + root = _write_module(tmp_path / "module", required_version=get_running_cli_version()) + + proc = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + "list", + "--project-root", + str(root), + ], + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=tmp_path, + ) + + assert proc.returncode == 2 + assert "global option" in proc.stderr + assert "before 'list'" in proc.stderr + + +def test_fresh_dispatch_activates_profile_before_cli_import(tmp_path): + root = _write_module(tmp_path / "module", required_version=get_running_cli_version()) + + proc = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + "--project-root", + str(root), + "list", + "--json", + ], + check=True, + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=tmp_path, + ) + payload = json.loads(proc.stdout) + + module = next(project for project in payload["projects"] if project["project_type"] == "module") + assert module["source_folder"] == str(root) + + +def test_version_reports_mismatch_without_blocking(tmp_path): + root = _write_module(tmp_path / "module", required_version="999.0.0") + + proc = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + "--project-root", + str(root), + "version", + "--json", + ], + check=True, + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=tmp_path, + ) + payload = json.loads(proc.stdout) + + assert payload["required_version"] == "999.0.0" + assert payload["version_match"] is False + + +def test_project_root_equals_form_works_for_native_version(tmp_path): + root = _write_module(tmp_path / "module", required_version=get_running_cli_version()) + + proc = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + f"--project-root={root}", + "version", + "--json", + ], + check=True, + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=tmp_path, + ) + + assert json.loads(proc.stdout)["project_root"] == str(root) + + +@pytest.mark.parametrize( + "tail,error", + [ + (["--project-root", "version"], "requires a non-empty directory path"), + ( + ["--project-root", "one", "--project-root", "two", "version"], + "specified only once", + ), + ], +) +def test_project_root_missing_and_duplicate_values_are_targeted(tmp_path, tail, error): + proc = subprocess.run( + [sys.executable, "-m", "holoscan_cli", *tail], + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=tmp_path, + ) + + assert proc.returncode == 2 + assert error in proc.stderr + + +def test_project_help_is_available_during_version_mismatch(tmp_path): + root = _write_module(tmp_path / "module", required_version="999.0.0") + + proc = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + "--project-root", + str(root), + "list", + "--help", + ], + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=tmp_path, + ) + + assert proc.returncode == 0 + assert "requires holoscan-cli" not in proc.stderr + + +def test_lifecycle_command_fails_before_work_on_version_mismatch(tmp_path): + root = _write_module(tmp_path / "module", required_version="999.0.0") + + proc = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + "--project-root", + str(root), + "list", + "--json", + ], + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=tmp_path, + ) + + assert proc.returncode == 1 + assert "requires holoscan-cli==999.0.0" in proc.stderr + assert '"projects"' not in proc.stdout + + +def test_create_ignores_enclosing_module_requirement(tmp_path): + root = _write_module(tmp_path / "module", required_version="999.0.0") + child_output = tmp_path / "children" + + proc = subprocess.run( + [ + sys.executable, + "-m", + "holoscan_cli", + "create", + "Child Module", + "--interactive", + "false", + "--dryrun", + "--directory", + str(child_output), + ], + capture_output=True, + text=True, + env=_subprocess_env(), + cwd=root, + ) + + assert proc.returncode == 0 + assert "Would create project folder" in proc.stdout + assert "requires holoscan-cli" not in proc.stderr + assert not child_output.exists()