Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ dependencies = [
"omniverseclient==2.72.3",
"filelock",
"lazy_loader>=0.4",
# ----- project template generator -----
"Jinja2",
# Pink IK stack; the install CLI derives its force-install list from these entries.
# pin-pink: Isaac Sim 6.x needs >=3.3, 3.4+ breaks pink_ik; daqp >0.8.5 changes behavior.
"pin ; platform_system == 'Linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')",
Expand Down
11 changes: 11 additions & 0 deletions source/isaaclab/changelog.d/fix-template-generator-uv-pip.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Changed
^^^^^^^

* Changed generated projects to use the Newton backend without Isaac Sim by default. Use the ``isaacsim``, ``ov``,
``ovphysx``, or ``ovrtx`` uv extra when running a generated project that needs the corresponding optional backend.

Fixed
^^^^^

* Fixed the new project template generator in uv environments that do not include the ``pip`` module by declaring
Jinja as an Isaac Lab dependency and using the existing Rich dependency for interactive prompts.
4 changes: 0 additions & 4 deletions source/isaaclab/isaaclab/cli/commands/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,6 @@ def command_new(new_args: list[str]) -> None:
new_args: Arguments forwarded to the template generator CLI.
"""

print_info("Installing template dependencies...")
reqs = ISAACLAB_ROOT / "tools" / "template" / "requirements.txt"
run_python_command("pip", ["install", "-q", "-r", str(reqs)], is_module=True)

print_info("Running template generator...")
cli_script = ISAACLAB_ROOT / "tools" / "template" / "cli.py"
run_python_command(cli_script, new_args)
Expand Down
10 changes: 10 additions & 0 deletions source/isaaclab/test/cli/test_misc_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
pytestmark = pytest.mark.unit


def test_new_runs_template_generator_directly():
"""The template command must not modify the active environment at runtime."""
cli_script = misc.ISAACLAB_ROOT / "tools" / "template" / "cli.py"

with mock.patch.object(misc, "run_python_command") as run_python_command:
misc.command_new(["--help"])

run_python_command.assert_called_once_with(cli_script, ["--help"])


def test_build_docs_runs_sphinx_with_the_uv_test_extra():
"""The docs command must build through UV instead of an unpinned pip install."""
docs_dir = misc.ISAACLAB_ROOT / "docs"
Expand Down
9 changes: 9 additions & 0 deletions source/isaaclab/test/cli/test_wheel_builder_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ def test_wheel_builder_drops_workspace_members(tmp_path):
assert not [dep for dep in dependencies if dep.lower().startswith("isaaclab")]


def test_wheel_builder_includes_template_generator_dependencies(tmp_path):
"""The generated wheel must install everything required by the project generator."""
generated = _generate_wheel_pyproject(tmp_path)
dependencies = set(generated["project"]["dependencies"])

assert {"Jinja2", "rich"} <= dependencies
assert "InquirerPy" not in dependencies


def test_wheel_console_delegates_to_the_full_isaaclab_cli():
"""The wheel console command must expose the same workflows as a source installation."""
module_path = _repo_root() / "tools" / "wheel_builder" / "res" / "__main__.py"
Expand Down
95 changes: 54 additions & 41 deletions tools/template/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
import rich.table
from common import ROOT_DIR
from generator import generate, get_algorithms_per_rl_library
from InquirerPy import inquirer, separator
from rich.prompt import Prompt


class CLIHandler:
"""CLI handler for the Isaac Lab template."""

def __init__(self):
self.console = rich.console.Console()
def __init__(self, console: rich.console.Console | None = None):
self.console = console if console is not None else rich.console.Console()

@staticmethod
def get_choices(choices: list[str], default: list[str]) -> list[str]:
Expand Down Expand Up @@ -49,15 +49,9 @@ def input_select(
Returns:
str: The selected choice.
"""
return inquirer.select(
message=message,
choices=choices,
cycle=True,
default=default,
style=None,
wrap_lines=True,
long_instruction=long_instruction,
).execute()
if long_instruction:
self.console.print(long_instruction, markup=False)
return self._ask(message, choices=choices, default=default)

def input_checkbox(self, message: str, choices: list[str], default: str | None = None) -> list[str]:
"""Prompt the user to select one or more options from a list of choices.
Expand All @@ -71,23 +65,26 @@ def input_checkbox(self, message: str, choices: list[str], default: str | None =
The selected choices.
"""

def transformer(result: list[str]) -> str:
if "all" in result or "both" in result:
token = "all" if "all" in result else "both"
return f"{token} ({', '.join(choices[: choices.index('---')])})"
return ", ".join(result)

return inquirer.checkbox(
message=message,
choices=[separator.Separator() if "---" in item else item for item in choices],
cycle=True,
default=default,
style=None,
wrap_lines=True,
validate=lambda result: len(result) >= 1,
invalid_message="No option selected (SPACE: select/deselect an option, ENTER: confirm selection)",
transformer=transformer,
).execute()
selectable_choices = [choice for choice in choices if choice != "---"]
for index, choice in enumerate(selectable_choices, start=1):
self.console.print(f" [cyan]{index}[/cyan].", choice)

default_index = None
if default is not None and default in selectable_choices:
default_index = str(selectable_choices.index(default) + 1)

while True:
response = self._ask(
f"{message} Enter comma-separated numbers",
default=default_index,
)
try:
indices = [int(token.strip()) for token in response.split(",")]
except ValueError:
indices = []
if indices and all(1 <= index <= len(selectable_choices) for index in indices):
return list(dict.fromkeys(selectable_choices[index - 1] for index in indices))
self.console.print("Enter one or more valid numbers separated by commas.", style="red")

def input_path(
self,
Expand All @@ -107,12 +104,7 @@ def input_path(
Returns:
The input path.
"""
return inquirer.filepath(
message=message,
default=default if default is not None else "",
validate=validate,
invalid_message=invalid_message,
).execute()
return self._input_value(message, default, validate, invalid_message)

def input_text(
self,
Expand All @@ -132,12 +124,33 @@ def input_text(
Returns:
The input text.
"""
return inquirer.text(
message=message,
default=default if default is not None else "",
validate=validate,
invalid_message=invalid_message,
).execute()
return self._input_value(message, default, validate, invalid_message)

def _ask(
self,
message: str,
choices: list[str] | None = None,
default: str | None = None,
) -> str:
"""Prompt for a string with optional choices and default value."""
kwargs = {"console": self.console, "choices": choices, "case_sensitive": False}
if default is not None:
kwargs["default"] = default
return Prompt.ask(message.removesuffix(":"), **kwargs)

def _input_value(
self,
message: str,
default: str | None,
validate: Callable[[str], bool] | None,
invalid_message: str,
) -> str:
"""Prompt until the entered value passes validation."""
while True:
value = self._ask(message, default=default)
if validate is None or validate(value):
return value
self.console.print(invalid_message or "Invalid input.", style="red")


class State(str, enum.Enum):
Expand Down
5 changes: 0 additions & 5 deletions tools/template/requirements.txt

This file was deleted.

2 changes: 1 addition & 1 deletion tools/template/templates/extension/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ license = { text = "BSD-3-Clause" }
authors = [{ name = "Isaac Lab Project Developers" }]
requires-python = ">=3.12,<3.13"
dependencies = [
"isaaclab[isaacsim{% for rl_library in rl_libraries %},{{ rl_library.name | replace('_', '-') }}{% endfor %}]",
"isaaclab{% if rl_libraries %}[{% for rl_library in rl_libraries %}{% if not loop.first %},{% endif %}{{ rl_library.name | replace('_', '-') }}{% endfor %}]{% endif %}",
]

[project.entry-points."isaaclab.tasks"]
Expand Down
16 changes: 15 additions & 1 deletion tools/template/templates/external/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,26 @@ An external Isaac Lab project containing an installable Python package and Isaac

## Installation

Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then create the project environment:
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then create the project environment. The default
environment uses the Newton backend and does not install Isaac Sim:

```bash
uv sync
```

Optional backends are available through extras. Pass the extra to each `uv run` command that needs it:

```bash
# Standalone OV PhysX
uv run --extra ovphysx isaaclab random_agent --task <TASK_NAME> physics=ovphysx

# Isaac Sim with PhysX and RTX rendering
uv run --extra isaacsim isaaclab random_agent --task <TASK_NAME> physics=isaacsim_physx
```

The `ov` extra installs both the `ovphysx` and `ovrtx` runtimes. You can also select `ovrtx` independently when using
Newton physics with the OVRTX renderer.

Commit both `pyproject.toml` files and `uv.lock` so collaborators use the same environment.

## Run the generated tasks
Expand Down
6 changes: 6 additions & 0 deletions tools/template/templates/external/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ description = "Development environment for the {{ name }} Isaac Lab project."
requires-python = ">=3.12,<3.13"
dependencies = ["{{ name }}"]

[project.optional-dependencies]
isaacsim = ["isaaclab[isaacsim]"]
ov = ["isaaclab[ov]"]
ovphysx = ["isaaclab[ovphysx]"]
ovrtx = ["isaaclab[ovrtx]"]

[dependency-groups]
dev = ["pre-commit"]

Expand Down
111 changes: 111 additions & 0 deletions tools/template/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""Tests for the project template interactive prompts."""

import importlib.util
import io
import sys
from pathlib import Path
from unittest import mock

import tomllib
from rich.console import Console

_TEMPLATE_DIR = Path(__file__).parent
_SPEC = importlib.util.spec_from_file_location("isaaclab_template_cli", _TEMPLATE_DIR / "cli.py")
assert _SPEC is not None
assert _SPEC.loader is not None
_MODULE = importlib.util.module_from_spec(_SPEC)
sys.path.insert(0, str(_TEMPLATE_DIR))
try:
_SPEC.loader.exec_module(_MODULE)
finally:
sys.path.pop(0)

CLIHandler = _MODULE.CLIHandler
_GENERATOR = sys.modules["generator"]


def _handler() -> tuple[CLIHandler, io.StringIO]:
"""Create a prompt handler whose output can be asserted."""
output = io.StringIO()
return CLIHandler(Console(file=output, force_terminal=False)), output


def test_select_uses_rich_prompt_and_displays_long_instruction():
"""Single selection must retain explanatory text and return the chosen value."""
handler, output = _handler()

with mock.patch.object(_MODULE.Prompt, "ask", return_value="External") as ask:
result = handler.input_select(
"Task type:",
choices=["External", "Internal"],
long_instruction="External projects live outside Isaac Lab.",
)

assert result == "External"
assert "External projects live outside Isaac Lab." in output.getvalue()
ask.assert_called_once_with(
"Task type",
console=handler.console,
choices=["External", "Internal"],
case_sensitive=False,
)


def test_checkbox_parses_multiple_choices_and_reprompts_invalid_input():
"""Multi-selection must validate numbered input and preserve choice order."""
handler, output = _handler()

with mock.patch.object(_MODULE.Prompt, "ask", side_effect=["invalid", "1, 3, 1"]):
result = handler.input_checkbox("Workflow:", ["Direct", "Manager-based", "---", "all"])

assert result == ["Direct", "all"]
assert "Enter one or more valid numbers" in output.getvalue()


def test_text_reprompts_until_validation_succeeds():
"""Text entry must surface the validation message and retry."""
handler, output = _handler()

with mock.patch.object(_MODULE.Prompt, "ask", side_effect=["not valid", "valid_name"]):
result = handler.input_text(
"Project name:",
validate=str.isidentifier,
invalid_message="Project name must be a valid identifier.",
)

assert result == "valid_name"
assert "Project name must be a valid identifier." in output.getvalue()


def test_generated_project_keeps_simulator_backends_optional(tmp_path):
"""A default generated environment must not install an optional simulator runtime."""
specification = {
"external": True,
"path": str(tmp_path),
"name": "test_project",
"workflows": [{"name": "manager-based", "type": "single-agent"}],
"rl_libraries": [{"name": "rsl_rl", "algorithms": ["ppo"]}],
}

with mock.patch.object(_GENERATOR, "_setup_git_repo"):
_GENERATOR.generate(specification)

project_dir = tmp_path / "test_project"
with (project_dir / "pyproject.toml").open("rb") as file:
development_project = tomllib.load(file)["project"]
with (project_dir / "source" / "test_project" / "pyproject.toml").open("rb") as file:
task_package = tomllib.load(file)["project"]

assert development_project["dependencies"] == ["test_project"]
assert development_project["optional-dependencies"] == {
"isaacsim": ["isaaclab[isaacsim]"],
"ov": ["isaaclab[ov]"],
"ovphysx": ["isaaclab[ovphysx]"],
"ovrtx": ["isaaclab[ovrtx]"],
}
assert task_package["dependencies"] == ["isaaclab[rsl-rl]"]
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading