Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,15 @@ each pattern in two lists:
* **required** — files that must be present in the wheel:
* `holoscan_cli/logging.json`
* `holoscan_cli/py.typed`
* `holoscan_cli/cmake/` (support copied into generated standalone Modules)
* `holoscan_cli/metadata/*.schema.json`
* `holoscan_cli/setup_scripts/*`
* `holoscan_cli/templates/module/`
* `holoscan_cli/testing/`
* **forbidden** — paths that must NOT be present (regressions from past
cleanups):
* `holoscan_cli/cmake/` (moved to HoloHub in commit `6aeb611`)
* `holoscan_cli/testing/test_all_applications/` (decoupled in `2d2f44a`)
Comment thread
wyli marked this conversation as resolved.
* `holoscan_cli/templates/module/*/holohub` (standalone Modules ship no wrapper)

The same script runs in both pipelines so a wheel that passes
`main.yaml` will pass `release.yaml`.
Expand Down
13 changes: 11 additions & 2 deletions .github/scripts/assert_wheel_contents.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@ listing=$(unzip -l "$wheel")
required=(
'holoscan_cli/logging\.json$'
'holoscan_cli/py\.typed$'
'holoscan_cli/cmake/Config\.cmake\.in$'
'holoscan_cli/cmake/HoloHubConfigHelpers\.cmake$'
'holoscan_cli/cmake/holohub_configure_deb\.cmake$'
'holoscan_cli/cmake/pybind11_add_holohub_module\.cmake$'
'holoscan_cli/cmake/pybind11/__init__\.py\.in$'
'holoscan_cli/cmake/pydoc/macros\.hpp$'
'holoscan_cli/metadata/.+\.schema\.json$'
'holoscan_cli/setup_scripts/.+'
'holoscan_cli/setup_scripts/requirements\.template\.txt$'
'holoscan_cli/templates/module/cookiecutter\.json$'
'holoscan_cli/templates/module/hooks/pre_gen_project\.py$'
'holoscan_cli/templates/module/hooks/post_gen_project\.py$'
'holoscan_cli/templates/module/.+/holohub$'
'holoscan_cli/templates/module/.+/requirements-cli\.txt$'
'holoscan_cli/templates/module/.+/\.dockerignore$'
'holoscan_cli/templates/module/.+/\.github/workflows/scripts/check_copyright\.py$'
'holoscan_cli/testing/'
)
for pattern in "${required[@]}"; do
Expand All @@ -38,8 +47,8 @@ for pattern in "${required[@]}"; do
done

forbidden=(
'holoscan_cli/cmake/'
'holoscan_cli/testing/test_all_applications/'
'holoscan_cli/templates/module/.+/holohub(/|$)'
)
for pattern in "${forbidden[@]}"; do
if echo "$listing" | grep -qE "$pattern"; then
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,48 @@ jobs:
/tmp/holoscan-cli-smoke/bin/python -c \
'import cookiecutter, jsonschema, referencing'

create_root=$(mktemp -d)
installed_version=$(/tmp/holoscan-cli-smoke/bin/python -c \
'from importlib.metadata import version; print(version("holoscan-cli"))')
for language in cpp python; do
project_name="Artifact ${language}"
module_root="${create_root}/holoscan-artifact-${language}"
operator_extension=cpp
if [[ "${language}" == "python" ]]; then
operator_extension=py
fi
/tmp/holoscan-cli-smoke/bin/holoscan create "${project_name}" \
--interactive false \
--language "${language}" \
--directory "${create_root}"

test -f "${module_root}/metadata.json"
/tmp/holoscan-cli-smoke/bin/python -c \
'import json,sys; data=json.load(open(sys.argv[1])); assert "module" in data' \
"${module_root}/metadata.json"
grep -Fx "holoscan-cli==${installed_version}" \
"${module_root}/requirements-cli.txt"
grep -F "\"holoscan-cli==${installed_version}\"" "${module_root}/pyproject.toml"
! grep -F "holoscan-cli[create]" "${module_root}/pyproject.toml"
test ! -e "${module_root}/holohub"
test ! -e "${module_root}/holoscan"
test -f "${module_root}/CMakeLists.txt"
test -f \
"${module_root}/applications/artifact_${language}_pipeline/python/metadata.json"
test -f \
"${module_root}/operators/artifact_${language}_op/artifact_${language}_op.${operator_extension}"
(
cd "${module_root}"
/tmp/holoscan-cli-smoke/bin/holoscan version --json | \
/tmp/holoscan-cli-smoke/bin/python -c \
'import json,sys; data=json.load(sys.stdin); assert data["version"] == sys.argv[1]' \
"${installed_version}"
/tmp/holoscan-cli-smoke/bin/holoscan list --json | \
/tmp/holoscan-cli-smoke/bin/python -c \
'import json,sys; data=json.load(sys.stdin); assert any(p["project_type"] == "module" for p in data["projects"])'
)
done

- name: Install sdist in clean venv
run: |
python -m venv /tmp/holoscan-cli-sdist-smoke
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ Per-repo wrappers install this package and delegate to `holoscan`, layering on t
| [HoloHub](https://github.com/nvidia-holoscan/holohub) | `./holohub` | source-project metadata search paths, container/workspace names |
| [I4H Workflows](https://github.com/isaac-for-healthcare/i4h-workflows) | `./i4h` | RTI DDS license auto-download + mount, TTY serial device passthrough |

Common env vars: `HOLOSCAN_CLI_ROOT` (repo root), `HOLOSCAN_CLI_SEARCH_PATH` (subdirs to scan for `metadata.json`), `HOLOSCAN_CLI_PATH_PREFIX` (placeholder prefix in metadata templates), `HOLOSCAN_CLI_REPO_PREFIX` (container image name prefix). The legacy `HOLOHUB_*` spelling is no longer honored since holoscan v4.3.0 — set the `HOLOSCAN_CLI_*` names directly. `holoscan env-info` lists every env var the CLI reads in the current shell.
Common env vars:

- `HOLOSCAN_CLI_ROOT` — repo root
- `HOLOSCAN_CLI_SEARCH_PATH` — subdirs to scan for `metadata.json`
- `HOLOSCAN_CLI_PATH_PREFIX` — placeholder prefix in metadata templates
- `HOLOSCAN_CLI_REPO_PREFIX` — container image name prefix
- `HOLOSCAN_CLI_CREATE_TEMPLATE` — default template for `holoscan create`

`holoscan env-info` lists every env var the CLI reads in the current shell.

## JSON output

Expand All @@ -45,11 +53,13 @@ src/holoscan_cli/
cli.py top-level argparse + dispatch (HoloscanCLI)
commands/ one file per subcommand + a central registry
container/ HoloscanContainer + docker arg helpers + parser builders
cmake/ packaged CMake support copied into standalone Modules
utils/ io.py, text.py, sdk.py, docker.py, host_setup.py,
env_info.py, holohub.py
setup_scripts/ bundled bash scripts backing `setup --scripts` and
`build-container --extra-scripts`
metadata/ project metadata JSON schemas
templates/module/ standalone Module cookiecutter
testing/ CTest helpers shipped in the wheel
```

Expand Down Expand Up @@ -80,6 +90,23 @@ uvx --from holoscan-cli holoscan --help
pipx run --spec holoscan-cli holoscan --help
```

Creating a standalone Module needs the optional creation dependencies. NVIDIA's
index is included so release candidates are available too. This command requires
`uv` 0.4.23 or later for `uvx --index` support:

```bash
uvx --index https://pypi.nvidia.com \
--from 'holoscan-cli[create]' holoscan create my-sensor
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

To run any command against a project outside the current directory, pass the
global `--project-root PATH` before the subcommand (equivalent to setting
`HOLOSCAN_CLI_ROOT`):

```bash
holoscan --project-root ~/holoscan-my-sensor list
```

## Versioning

`holoscan-cli` release versions are aligned with Holoscan SDK GA release
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Documentation = "https://docs.nvidia.com/holoscan/sdk-user-guide/index.html"
requires-poetry = ">=2.0"
packages = [{ include = "holoscan_cli", from = "src" }]
include = [
{ path = "src/holoscan_cli/cmake/**/*", format = ["sdist", "wheel"] },
{ path = "src/holoscan_cli/metadata/*.schema.json", format = ["sdist", "wheel"] },
{ path = "src/holoscan_cli/setup_scripts/*", format = ["sdist", "wheel"] },
{ path = "src/holoscan_cli/templates/**/*", format = ["sdist", "wheel"] },
Expand All @@ -100,6 +101,7 @@ tomli = { version = "^2.4", markers = "python_version < '3.11'" }
# schema validator (``test_metadata_validator.py``) and the smoke
# fixture (``test_smoke_fixture.py``) can import them without
# requiring callers to ``pip install 'holoscan-cli[create]'`` first.
cookiecutter = ">=2.7.1"
jsonschema = ">=4.26.0,<5.0"
referencing = ">=0.37.0"

Expand Down
121 changes: 91 additions & 30 deletions src/holoscan_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
from typing import Optional, Union

from .commands.registry import project_command_help
from .project_context import (
ProjectContextError,
activate_project_context,
discover_project_context,
)

logging.getLogger("docker.api.build").setLevel(logging.WARNING)
logging.getLogger("docker.auth").setLevel(logging.WARNING)
Expand Down Expand Up @@ -59,6 +64,19 @@
)


class DispatchUsageError(ValueError):
"""A top-level option is invalid or misplaced."""


# Top-level options consumed before the subcommand, mapped to the value each one
# expects. Used for both parsing and the "requires a ..." usage errors.
TOP_LEVEL_OPTIONS = {
"-l": "logging level",
"--log-level": "logging level",
"--project-root": "directory path",
}


def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
if argv is None:
argv = sys.argv
Expand Down Expand Up @@ -97,6 +115,11 @@ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
dest="show_version",
help="display the holoscan-cli package version",
)
parser.add_argument(
"--project-root",
metavar="PATH",
help="use PATH as the source-project root (must appear before the subcommand)",
)

subparser = parser.add_subparsers(dest="command")

Expand Down Expand Up @@ -158,39 +181,57 @@ def _program_name(argv: list[str]) -> str:
return "holoscan" if command_name == "__main__.py" else command_name


def _project_dispatch_argv(argv: list[str]) -> tuple[Optional[str], list[str], Optional[str]]:
"""Return command, argv with top-level options removed, and requested log level."""
def _project_dispatch_argv(
argv: list[str],
) -> tuple[Optional[str], list[str], Optional[str], Optional[str]]:
"""Return command, stripped argv, log level, and explicit project root."""
project_argv = [argv[0]]
log_level = None
project_root = None
index = 1

while index < len(argv):
arg = argv[index]
if arg in {"-l", "--log-level"} and index + 1 < len(argv):
log_level = argv[index + 1].upper()
index += 2
continue
if arg.startswith("--log-level="):
log_level = arg.split("=", 1)[1].upper()
index += 1
continue

project_argv.extend(argv[index:])
break
name, equals, inline_value = argv[index].partition("=")
if name not in TOP_LEVEL_OPTIONS:
project_argv.extend(argv[index:])
break
if equals:
value, index = inline_value, index + 1
elif index + 1 < len(argv):
value, index = argv[index + 1], index + 2
else:
raise DispatchUsageError(f"{name} requires a {TOP_LEVEL_OPTIONS[name]}.")

if name == "--project-root":
if project_root is not None:
raise DispatchUsageError("--project-root may be specified only once.")
# A bare subcommand or another option here means the path was omitted.
if not value or value.startswith("-") or value in {*PROJECT_COMMANDS, "version"}:
raise DispatchUsageError("--project-root requires a non-empty directory path.")
project_root = value
else:
# argparse never sees this prefix form, so apply its choices here.
log_level = value.upper()
if log_level not in LOG_LEVELS:
raise DispatchUsageError(
f"{name} must be one of {', '.join(LOG_LEVELS)}; got {value!r}."
)

command = project_argv[1] if len(project_argv) > 1 else None
return command, project_argv, log_level


def _exit_if_removed_command(argv: list[str]) -> None:
"""Print a removal note and exit 2 if argv's first non-flag token names a
removed subcommand. Runs before any parser so users typing the old name
see why it's gone instead of argparse's bare "invalid choice".
"""
command, _, _ = _project_dispatch_argv(argv)
if command is None or command not in REMOVED_COMMANDS:
for arg in project_argv[2:]:
if arg == "--project-root" or arg.startswith("--project-root="):
program = _program_name(argv)
raise DispatchUsageError(
f"--project-root is a global option; place it before {command!r}, for example: "
f"{program} --project-root PATH {command}"
)
return command, project_argv, log_level, project_root


def _exit_if_removed_command(program: str, command: Optional[str]) -> None:
"""Explain a removed subcommand before argparse reports an invalid choice."""
if command not in REMOVED_COMMANDS:
return
program = _program_name(argv)
print(
f"Error: '{program} {command}' was removed since holoscan v4.3.0 — "
f"{REMOVED_COMMANDS[command]} is no longer shipped.\n"
Expand All @@ -200,12 +241,24 @@ def _exit_if_removed_command(argv: list[str]) -> None:
sys.exit(2)


def _dispatch_project_cli(argv: list[str]) -> bool:
def _dispatch_project_cli(
command: Optional[str],
project_argv: list[str],
log_level: Optional[str],
project_root: Optional[str],
) -> bool:
"""Forward source-project commands to the ported project CLI."""
command, project_argv, log_level = _project_dispatch_argv(argv)
if command not in PROJECT_COMMANDS:
return False

# Creation must not inherit an enclosing project's defaults unless the
# caller explicitly selects that project.
if command != "create" or project_root is not None:
context = discover_project_context(explicit_root=project_root)
for warning in context.warnings:
print(f"Warning: {warning}", file=sys.stderr)
activate_project_context(context)

set_up_logging(log_level)

from .cli import main as project_main
Expand All @@ -219,12 +272,17 @@ def _dispatch(argv: Optional[list[str]]) -> None:
argv = sys.argv
argv = list(argv)

_exit_if_removed_command(argv)
command, native_argv, log_level, project_root = _project_dispatch_argv(argv)

_exit_if_removed_command(_program_name(argv), command)

if _dispatch_project_cli(argv):
if _dispatch_project_cli(command, native_argv, log_level, project_root):
return

args = parse_args(argv)
args = parse_args(native_argv)
if log_level is not None:
args.log_level = log_level
args.project_root = project_root

set_up_logging(args.log_level)

Expand All @@ -237,6 +295,9 @@ def _dispatch(argv: Optional[list[str]]) -> None:
def main(argv: Optional[list[str]] = None):
try:
_dispatch(argv)
except (DispatchUsageError, ProjectContextError) as exc:
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(2) from None
except KeyboardInterrupt:
# The CLI owns pre-launch work. After launch, exec removes this frame
# and the application retains control of its signal handling and status.
Expand Down
7 changes: 7 additions & 0 deletions src/holoscan_cli/cmake/Config.cmake.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@PACKAGE_INIT@

include(CMakeFindDependencyMacro)
find_dependency(holoscan REQUIRED COMPONENTS core)

include("${CMAKE_CURRENT_LIST_DIR}/@ARG_EXPORT_NAME@.cmake")
check_required_components(@ARG_NAME@)
Loading
Loading