Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/scripts/assert_wheel_contents.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ listing=$(unzip -l "$wheel")
required=(
'holoscan_cli/logging\.json$'
'holoscan_cli/py\.typed$'
'holoscan_cli/command_plan\.schema\.json$'
'holoscan_cli/metadata/.+\.schema\.json$'
'holoscan_cli/setup_scripts/.+'
'holoscan_cli/setup_scripts/requirements\.template\.txt$'
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,40 @@ Per-repo wrappers install this package and delegate to `holoscan`, layering on t

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.

### Structured dry-run plans

The core `holoscan build-container` command can resolve its host-dependent
Docker command without running the build and return either structured JSON or
one editable Bash script:

```bash
holoscan build-container my_app --dryrun --json
holoscan build-container my_app --dryrun --shell > build-container.sh
```

The JSON `steps` include read-only host probes as well as the ordered action
commands. The Bash replay contains the resolved action commands; review it
before execution. Both formats reflect the current host and inherited
environment rather than forming a portable or hermetic build description.

The JSON scope is `current_cli_process`. Bootstrap or provisioning performed
by a repository wrapper before it starts `holoscan` is not included. Use the
installed `holoscan` entry point for machine-readable output until a given
wrapper documents that it preserves structured stdout; wrapper diagnostics
must go to stderr.

To override an input, set it on the planning invocation and regenerate the
plan, or prefer the equivalent typed CLI option when one exists:

```bash
env HOLOSCAN_CLI_BASE_SDK_VERSION=4.5.0 \
holoscan build-container my_app --dryrun --json
```

Plain `--dryrun` retains its human-readable output. Structured planning is
being enabled one command at a time; unsupported action commands reject these
formats instead of returning a partial plan.

## Source layout

```text
Expand Down
1 change: 1 addition & 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/command_plan.schema.json", 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/testing/**/*", format = ["sdist", "wheel"] },
Expand Down
80 changes: 43 additions & 37 deletions src/holoscan_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@
import argparse
import functools
import os
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from typing import List, Optional

import holoscan_cli.metadata.gather_metadata as metadata_util
from holoscan_cli.command_plan import CommandPlanError, PlanRecorder
from holoscan_cli.commands import registry as commands_registry
from holoscan_cli.container import HoloscanContainer
from holoscan_cli.container.parsers import get_build_argparse, get_run_argparse
Expand Down Expand Up @@ -300,47 +303,26 @@ def get_effective_build_config(

if "depends" in build_config:
if config["with_operators"]:
mode_deps = [dep.strip() for dep in build_config["depends"] if dep.strip()]
msg = f"CLI args --build-with='{config['with_operators']}' "
msg += f"overrides mode depends: {', '.join(mode_deps)}"
warn(msg)
warn("CLI --build-with overrides mode build.depends")
else:
mode_deps = [dep.strip() for dep in build_config["depends"] if dep.strip()]
config["with_operators"] = ";".join(mode_deps) if mode_deps else ""

if "docker_build_args" in build_config:
if config["build_args"]:
mode_args = normalize_args_str(build_config["docker_build_args"])
msg = f"CLI args --build-args='{config['build_args']}' "
msg += f"overrides mode --build-args: {mode_args}"
warn(msg)
warn("CLI --build-args overrides mode build.docker_build_args")
else:
config["build_args"] = normalize_args_str(build_config["docker_build_args"])

if "cmake_options" in build_config:
if config["configure_args"]:
mode_opts = (
" ".join(build_config["cmake_options"])
if isinstance(build_config["cmake_options"], list)
else build_config["cmake_options"]
)
cli_opts = (
" ".join(config["configure_args"])
if isinstance(config["configure_args"], list)
else config["configure_args"]
)
msg = f"CLI args --configure-args='{cli_opts}' "
msg += f"overrides mode --configure-args: {mode_opts}"
warn(msg)
warn("CLI --configure-args overrides mode build.cmake_options")
else:
config["configure_args"] = build_config["cmake_options"]

if "run" in mode_config and "docker_run_args" in mode_config["run"]:
if getattr(args, "docker_opts", ""):
mode_opts = normalize_args_str(mode_config["run"]["docker_run_args"])
msg = f"CLI args --docker-opts='{getattr(args, 'docker_opts', '')}' "
msg += f"overrides mode --docker-opts: {mode_opts}"
warn(msg)
warn("CLI --docker-opts overrides mode run.docker_run_args")
else:
config["docker_opts"] = normalize_args_str(mode_config["run"]["docker_run_args"])

Expand All @@ -366,20 +348,11 @@ def get_effective_run_config(
config["workdir"] = run_config["workdir"]

if "command" in run_config and getattr(args, "run_args", ""):
msg = (
f"CLI args --run-args='{getattr(args, 'run_args', '')}' "
f"will be appended to mode command"
)
warn(msg)
warn("CLI --run-args will be appended to mode run.command")

if "docker_run_args" in run_config:
if getattr(args, "docker_opts", ""):
mode_opts = normalize_args_str(run_config["docker_run_args"])
msg = (
f"CLI args --docker-opts='{getattr(args, 'docker_opts', '')}' "
f"overrides mode --docker-opts: {mode_opts}"
)
warn(msg)
warn("CLI --docker-opts overrides mode run.docker_run_args")
else:
config["docker_opts"] = normalize_args_str(run_config["docker_run_args"])
return config
Expand Down Expand Up @@ -471,12 +444,45 @@ def run(self, argv: Optional[List[str]] = None) -> None:
print(file=sys.stderr)
sys.exit(1)
raise
if hasattr(args, "func"):
plan_format = getattr(args, "plan_format", None)
if plan_format is not None:
if not getattr(args, "_supports_plan", False):
self.subparsers[args.command].error(
"structured command plans are not supported for this command"
)
if not getattr(args, "dryrun", False):
self.subparsers[args.command].error(f"--{plan_format} requires --dryrun")
self._run_structured_plan(args, plan_format)
elif hasattr(args, "func"):
args.func(args)
else:
self.parser.print_help()
sys.exit(1)

@staticmethod
def _run_structured_plan(args: argparse.Namespace, plan_format: str) -> None:
"""Run one audited dry-run and write its artifact atomically to stdout."""

recorder = PlanRecorder()
legacy_stdout = StringIO()
try:
try:
with recorder.activate(), redirect_stdout(legacy_stdout):
args.func(args)
finally:
notices = legacy_stdout.getvalue()
if notices:
sys.stderr.write(notices)

output = recorder.json_text() if plan_format == "json" else recorder.shell_text()
except CommandPlanError as exc:
print(f"Command planning failed: {exc}", file=sys.stderr)
raise SystemExit(1) from exc

if plan_format == "json":
output += "\n"
sys.stdout.write(output)


def main(argv: Optional[List[str]] = None):
script_name = None
Expand Down
Loading
Loading