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
7 changes: 6 additions & 1 deletion .github/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
5 changes: 5 additions & 0 deletions .github/scripts/assert_wheel_contents.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
19 changes: 18 additions & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```

Expand All @@ -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 <path>` to select another output parent or
`--template <path>` 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:

Expand Down
15 changes: 14 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ create = [
"jsonschema (>=4.18,<5.0)",
"referencing (>=0.30)",
"cookiecutter (>=2.7.1)",
"packaging (>=23.0)",
]

[project.urls]
Expand All @@ -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"
Expand All @@ -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"] }
Expand All @@ -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/
)
'''
Expand All @@ -141,6 +153,7 @@ exclude = [
".ruff_cache",
"build*",
"dist",
"src/holoscan_cli/templates/module",
"tests/reports",
]

Expand Down
94 changes: 87 additions & 7 deletions src/holoscan_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -158,36 +175,70 @@ 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
if arg.startswith("--log-level="):
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:
"""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)
command, _, _, _ = _project_dispatch_argv(argv)
if command is None or command not in REMOVED_COMMANDS:
return
program = _program_name(argv)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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.
Expand Down
Loading
Loading